RSS Reader using AT Protocol rssbase.io
feed atom rss reader atproto social
2

Configure Feed

Select the types of activity you want to include in your feed.

add dagger & symfony skills

+16376
+51
.agents/skills/dagger-chores/SKILL.md
··· 1 + --- 2 + name: dagger-chores 3 + 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. 4 + --- 5 + 6 + # Dagger Chores 7 + 8 + ## Go Version Bump 9 + 10 + Use this checklist when asked to bump Go. 11 + 12 + 1. Update the Go version string in `engine/distconsts/consts.go`: 13 + 14 + - `GolangVersion = "X.Y.Z"` 15 + 16 + 1. Update the Go default version string in `toolchains/go/main.go`: 17 + 18 + - `// +default="X.Y.Z"` on the `version` argument in `New(...)` 19 + 20 + 1. Use a short commit message in this format: 21 + 22 + - `chore: bump to go <major.minor>` 23 + - Example: `chore: bump to go 1.26` 24 + 25 + 1. Create a signed commit: 26 + 27 + - `git commit -s -m "chore: bump to go <major.minor>"` 28 + 29 + 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. 30 + 31 + - 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.` 32 + 33 + ## Regenerate Generated Files 34 + 35 + Use this checklist when asked to regenerate generated files. 36 + 37 + 1. From the Dagger repo root, create a temp file for command output and store its path in `tmp_log`. 38 + 39 + 1. Run generation and redirect all output to the temp file: 40 + 41 + - `dagger --progress=plain call generate layer export --path . >"$tmp_log" 2>&1` 42 + 43 + 1. Search the temp file as needed instead of printing full output. 44 + 45 + 1. Delete the temp file when done. 46 + 47 + ## Regenerate Golden Tests 48 + 49 + Use this checklist when asked to regenerate telemetry golden tests. 50 + 51 + 1. From the Dagger repo root, run `dagger -c 'engine-dev | test-telemetry --update | export .'`
+169
.agents/skills/dagger-design-proposals/SKILL.md
··· 1 + --- 2 + name: dagger-design-proposals 3 + description: Write design proposals for Dagger features. Use when asked to draft, review, or iterate on Dagger design documents, RFCs, or proposals. 4 + --- 5 + 6 + # Dagger Design Proposals 7 + 8 + Guidelines for writing design proposals for Dagger features. 9 + 10 + ## Before Writing 11 + 12 + **Always research first:** 13 + 14 + 1. Check relevant internal docs, such as `internal-docs/dagger-codegen.md`, for context 15 + 2. Look at related code in the Dagger codebase: 16 + - GraphQL schema: `core/schema/*.go` 17 + - CLI commands: `cmd/dagger/*.go` 18 + - Core types: `core/*.go` 19 + 3. For public API or workspace proposals, read `internal-docs/version-gating.md` 20 + 4. Understand existing patterns before proposing new ones 21 + 22 + ## Structure 23 + 24 + ````markdown 25 + # Part N: Title 26 + 27 + *Builds on [Part N-1: Title](link)* 28 + 29 + ## Table of Contents 30 + - [Problem](#problem) 31 + - [Solution](#solution) 32 + - [Core Concept](#core-concept) 33 + - [CLI](#cli) 34 + - [Status](#status) 35 + 36 + ## Problem 37 + 38 + Numbered, concise limitations: 39 + 40 + 1. **Short title** - One sentence explanation. 41 + 2. **Short title** - One sentence explanation. 42 + 43 + ## Solution 44 + 45 + One paragraph summary. 46 + 47 + ## Core Concept 48 + 49 + GraphQL type definitions with inline docstrings: 50 + 51 + ```graphql 52 + """ 53 + Type description here. 54 + """ 55 + type Example { 56 + """Method description.""" 57 + method(arg: String!): Result! 58 + } 59 + ``` 60 + 61 + Go for implementation examples: 62 + 63 + ```go 64 + func New(ws dagger.Workspace) *Example { 65 + // ... 66 + } 67 + ``` 68 + 69 + ## CLI 70 + 71 + Real command examples: 72 + 73 + ```bash 74 + $ dagger command --flag 75 + OUTPUT 76 + ``` 77 + 78 + ## Status 79 + 80 + One line. 81 + 82 + --- 83 + 84 + - Previous: [Part N-1](link) 85 + - Next: [Part N+1](link) or "Part N+1: Title (coming soon)" 86 + 87 + ```` 88 + 89 + ## Style 90 + 91 + - **Concise** - Trust the reader. Remove fluff. 92 + - **Tables** - Use for comparisons. 93 + - **GraphQL for APIs** - Type definitions, not Go interfaces. 94 + - **Go for implementation** - Examples showing how modules use the API. 95 + - **Real examples** - go-toolchain, node-toolchain, not abstract Foo/Bar. 96 + - **Less is more** - Remove sections when challenged. 97 + 98 + ## What to Avoid 99 + 100 + - Separate "Methods" sections (use GraphQL docstrings) 101 + - "Design Rationale" sections unless specifically valuable 102 + - "Open Questions" that aren't real blockers 103 + - Go for type definitions (use GraphQL) 104 + - Layout examples that might confuse 105 + - Over-explaining 106 + 107 + ## Process 108 + 109 + 1. **Gists as source of truth** - Publish early, iterate in gist 110 + 2. **Link parts together** - Previous/Next at bottom, "Builds on" at top 111 + 3. **Each part stands alone** - But builds on previous 112 + 4. **Iterate quickly** - User feedback drives changes 113 + 114 + ## Iterating with User 115 + 116 + When you have clarifying questions or notes: 117 + 118 + 1. **List them first** - Present a high-level numbered list of all questions/notes 119 + 2. **One at a time** - Walk through each item individually, waiting for user response 120 + 3. **Don't dump** - Never present all questions with full details at once 121 + 122 + ## Codebase References 123 + 124 + When writing proposals, reference actual Dagger code: 125 + 126 + | Topic | Location | 127 + |-------|----------| 128 + | GraphQL schema definitions | `core/schema/*.go` | 129 + | CLI commands | `cmd/dagger/*.go` | 130 + | Core types (Directory, File, etc.) | `core/*.go` | 131 + | Engine internals | `engine/*.go` | 132 + | SDK codegen | `internal-docs/dagger-codegen.md`, `cmd/codegen/*.go` | 133 + | API version gates | `internal-docs/version-gating.md`, `core/schema/*.go` | 134 + 135 + Example: To understand how `Host.findUp` works before proposing `Workspace.findUp`: 136 + 137 + ```bash 138 + # Find the schema definition 139 + grep -r "findUp" core/schema/host.go 140 + 141 + # Find the implementation 142 + grep -r "FindUp" core/host.go 143 + ``` 144 + 145 + ## Publishing 146 + 147 + ```bash 148 + # Create new gist 149 + gh gist create file.md --desc "Dagger Design: Part N - Title" --public 150 + 151 + # Update existing gist 152 + gh gist edit GIST_ID file.md 153 + 154 + # Post changelog comment (always do this after updates) 155 + gh api --method POST /gists/GIST_ID/comments -f body="## Changelog 156 + - Change 1 157 + - Change 2" 158 + ``` 159 + 160 + **Always post a changelog comment** after updating a gist with significant changes. 161 + 162 + ## Related Skills 163 + 164 + Check for other Dagger skills that may help with research: 165 + 166 + - `engine-debugging` - Engine debugging workflows, trace replay, cache snapshots 167 + - `internal-docs/dagger-codegen.md` - SDK codegen, templates, bindings 168 + - `internal-docs/version-gating.md` - schema views and public API version gates 169 + - `internal-docs/` - Cache and engine implementation references
+351
.agents/skills/engine-debugging/SKILL.md
··· 1 + --- 2 + name: engine-debugging 3 + 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. 4 + --- 5 + 6 + # Engine Debugging 7 + 8 + This is the default guide for running Dagger engine/core tests and for debugging 9 + the failures those tests expose. 10 + 11 + Start from evidence, not broad guesses. 12 + 13 + ## Core Loop 14 + 15 + 1. Write down the expected flow through the subsystem being debugged. 16 + 2. Log actual values at each boundary. 17 + 3. Find the first divergence. 18 + 4. Decide whether the bug is in identity construction, lookup, lifecycle, 19 + compatibility behavior, or an external integration boundary. 20 + 21 + Use focused repros, recorded traces, and small log windows. Avoid dumping full 22 + test output into the conversation. 23 + 24 + Prefer small, high-signal log lines over broad dumps. Good debug logs identify 25 + the boundary being checked and include the relevant stable IDs, digests, keys, 26 + hit path, or lifecycle state needed to compare expected and actual behavior. 27 + 28 + ## Repro First 29 + 30 + Use a tight test repro before adding logs. 31 + 32 + Recommended integration command format: 33 + 34 + ```bash 35 + dagger --progress=plain call engine-dev test --pkg ./core/integration --run='<TestSuiteName>/<SubtestName>' 36 + ``` 37 + 38 + This command rebuilds the dev engine, runs it as an ephemeral service, and then 39 + runs tests against it. Output includes: 40 + 41 + - dev engine build output 42 + - test runner output 43 + - engine logs/printlns 44 + - test logs, such as `t.Logf` 45 + 46 + Capture output to a file under `/tmp` to avoid overwhelming terminal context: 47 + 48 + ```bash 49 + dagger --progress=plain call engine-dev test --pkg ./core/integration --run='<TestSuiteName>/<SubtestName>' > /tmp/engine-debug.log 2>&1 50 + rg -n "panic:|--- FAIL:|^FAIL\s" /tmp/engine-debug.log 51 + ``` 52 + 53 + During long runs, periodically grep for panics. If the engine panics, tests may 54 + hang indefinitely: 55 + 56 + ```bash 57 + rg -n "panic:|fatal error:|SIGSEGV|stack trace" /tmp/engine-debug.log 58 + ``` 59 + 60 + If a test appears hung, capture a goroutine dump from the inner dev engine 61 + process with `SIGQUIT`. Follow this closely so SIGQUIT is not sent to the wrong 62 + process: 63 + 64 + ```bash 65 + engine_ctr="$(docker ps --format '{{.Names}}' | rg '^dagger-engine-v' | head -n1)" 66 + docker exec "$engine_ctr" sh -lc ' 67 + for p in /proc/[0-9]*; do 68 + pid=${p#/proc/} 69 + [ "$pid" = "1" ] && continue 70 + cmd="$(tr "\0" " " < "$p/cmdline" 2>/dev/null || true)" 71 + case "$cmd" in 72 + *"/usr/local/bin/dagger-engine"*) 73 + echo "sending SIGQUIT to inner dagger-engine pid=$pid" >&2 74 + kill -QUIT "$pid" 75 + exit 0 76 + ;; 77 + esac 78 + done 79 + echo "no inner dagger-engine process found" >&2 80 + exit 1 81 + ' 82 + ``` 83 + 84 + Then inspect the same run log for the dump: 85 + 86 + ```bash 87 + rg -n "goroutine [0-9]+|fatal error:|SIGQUIT|chan receive|chan send|semacquire|sync\\.Mutex|deadlock" /tmp/engine-debug.log 88 + ``` 89 + 90 + After sending SIGQUIT, the tests may hang. Once you confirm the log has SIGQUIT 91 + stack traces, you are done and do not need to wait for the test hang to end. 92 + 93 + To compare behavior against an engine from another git ref: 94 + 95 + ```bash 96 + dagger --progress=plain call engine-dev --source 'https://github.com/dagger/dagger#main' test --pkg ./core/integration --run='TestSomeSuite/TestSomeSubtestYouWant' 97 + ``` 98 + 99 + Do not run multiple suites in parallel unless necessary. Each suite is CPU-heavy 100 + and concurrent runs significantly degrade performance. 101 + 102 + Do not use broad `./...` when running tests during engine-debug loops. You can 103 + accidentally capture integration tests or other tests you did not mean to run. 104 + 105 + `./core/integration`, `./dagql/idtui`, and `./dagql/idtui/multiprefixw` are 106 + integration-style test packages, not quick unit loops. Avoid running them during 107 + tight debug cycles unless you explicitly need those integration paths. 108 + 109 + ## CI Trace Replay 110 + 111 + When a failure happens in CI, start from the trace if one is available. The user 112 + may provide either a raw trace ID or a command copied from the web UI, such as: 113 + 114 + ```bash 115 + dagger trace <trace-id> 116 + ``` 117 + 118 + Replay that trace locally with plain progress and capture it to a temp file: 119 + 120 + ```bash 121 + dagger --progress=plain trace <trace-id> > /tmp/ci-trace-<trace-id>.log 2>&1 122 + ``` 123 + 124 + This does not rerun the CI job. It fetches and prints the recorded trace in the 125 + same style as local `--progress=plain` output. Keep the full output in `/tmp`, 126 + inspect it with `rg`, and avoid dumping the whole trace into the conversation. 127 + 128 + ### Finding Trace IDs From GitHub PR Checks 129 + 130 + If the user gives a GitHub PR URL instead of a trace ID, first inspect the PR's 131 + commit statuses and collect the Dagger Cloud target URLs for the checks of 132 + interest. With GitHub CLI this usually looks like: 133 + 134 + ```bash 135 + pr_url='https://github.com/dagger/dagger/pull/13119' 136 + head_sha="$(gh pr view "$pr_url" --json headRefOid --jq .headRefOid)" 137 + gh api "repos/dagger/dagger/commits/$head_sha/status" \ 138 + --jq '.statuses[] | select(.target_url | startswith("https://dagger.cloud/")) | [.state, .context, .target_url] | @tsv' 139 + ``` 140 + 141 + For failed checks, add `select(.state != "success")`. A Dagger status target URL 142 + has this shape: 143 + 144 + ```text 145 + https://dagger.cloud/{org}/checks/{moduleRef}@{moduleVersion}?check={checkName} 146 + ``` 147 + 148 + For public repos, the Cloud GraphQL API can map that URL data to check IDs and 149 + trace IDs without rerunning anything: 150 + 151 + ```bash 152 + curl -sS -X POST https://api.dagger.cloud/query \ 153 + -H 'Content-Type: application/json' \ 154 + --data '{ 155 + "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 } } } }", 156 + "variables": { 157 + "org": "dagger", 158 + "moduleRef": "github.com/dagger/dagger", 159 + "moduleVersion": "e7600fda40142627a4206ec04de3a5f702be5a45" 160 + } 161 + }' > /tmp/ci-checks.json 162 + 163 + jq -r --arg check 'test-split:test-base' \ 164 + '.data.org.moduleChecks[].checks[] 165 + | select(.name == $check) 166 + | [.status, .name, .id, .traceId] 167 + | @tsv' /tmp/ci-checks.json 168 + ``` 169 + 170 + If the Dagger Cloud URL contains `run=<checkID>`, prefer that exact check ID. 171 + Current GitHub status URLs often only include `check=<name>`, so the lookup is 172 + "latest matching check for this org/module/version/name"; be careful after 173 + reruns and prefer the non-success/latest row that matches the status being 174 + debugged. 175 + 176 + Once you have the trace ID, replay it with `dagger --progress=plain trace ...` 177 + and capture output to `/tmp` as described above. 178 + 179 + Start with the usual failure scan: 180 + 181 + ```bash 182 + rg -n "panic:|fatal error:|SIGSEGV|--- FAIL:|^FAIL\s|Error:|error:" /tmp/ci-trace-<trace-id>.log 183 + ``` 184 + 185 + Then inspect around the interesting spans: 186 + 187 + ```bash 188 + rg -n "TestName|FieldName|module name|command text" /tmp/ci-trace-<trace-id>.log 189 + sed -n '<start>,<end>p' /tmp/ci-trace-<trace-id>.log 190 + ``` 191 + 192 + Use the replayed trace to identify the exact failing call, subtest, generated 193 + command, or engine error. Once the failing surface is clear, decide whether to 194 + reproduce it locally with a tight `dagger --progress=plain call engine-dev ...` 195 + command or debug directly from the recorded CI trace. 196 + 197 + ## Performance Debugging With Persistent Dev Engine 198 + 199 + For most testing/debugging flows, prefer ephemeral engines via: 200 + 201 + ```bash 202 + dagger --progress=plain call engine-dev ... 203 + ``` 204 + 205 + For performance debugging, such as pprof snapshots, repeated profiling loops, or 206 + endpoint inspection, use a persistent dev engine running in Docker. 207 + 208 + ### Start Persistent Dev Engine 209 + 210 + ```bash 211 + docker rm -fv dagger-engine.dev 212 + docker volume rm dagger-engine.dev 213 + ./hack/dev 214 + ``` 215 + 216 + Notes: 217 + 218 + - The container is named `dagger-engine.dev`. 219 + - This engine persists across commands/runs, so it is better for iterative perf 220 + investigation. 221 + - A clean reset is often desirable for consistent baselines, but is not always 222 + required; it depends on whether cache/warm state is part of what you are 223 + measuring. 224 + 225 + ### Run Commands Against Persistent Engine 226 + 227 + Use `./hack/with-dev` to target the running `dagger-engine.dev`: 228 + 229 + ```bash 230 + ./hack/with-dev go test -v -count=1 -run='TestWorkspace/TestWorkspaceContentAddressed/storing_a_Directory' ./core/integration/ 231 + ``` 232 + 233 + You can also run Dagger commands through the same wrapper: 234 + 235 + ```bash 236 + ./hack/with-dev ./bin/dagger ... 237 + ``` 238 + 239 + Important CLI gotcha: 240 + 241 + - If you do `./hack/with-dev bash -c 'dagger ...'`, you may accidentally pick 242 + up a non-dev `dagger` binary from `PATH`. 243 + - In shell-wrapped commands, explicitly use `./bin/dagger` to avoid ambiguity. 244 + 245 + ### Docker-Level Debugging 246 + 247 + Because the engine is a normal Docker container, you can use standard Docker 248 + tools: 249 + 250 + - `docker logs dagger-engine.dev` 251 + - `docker exec -it dagger-engine.dev sh` 252 + - `docker kill -s <SIGNAL> dagger-engine.dev` 253 + 254 + ### pprof and Debug Endpoints 255 + 256 + The dev engine exposes debug endpoints on `localhost:6060`. 257 + 258 + - Current routes are defined in `cmd/engine/debug.go`. 259 + - Use whichever endpoint/tooling fits the question: point-in-time snapshots, 260 + time-window captures, pprof profiles, or debug endpoint snapshots. 261 + 262 + Example heap profile capture over 15 seconds: 263 + 264 + ```bash 265 + curl 'http://localhost:6060/debug/pprof/heap?seconds=15' > /tmp/heap.pprof 266 + ``` 267 + 268 + Then inspect with: 269 + 270 + ```bash 271 + go tool pprof /tmp/heap.pprof 272 + ``` 273 + 274 + General profiling guidance: 275 + 276 + - Choose profile type and capture window based on the symptom. 277 + - For long-running or phase-specific regressions, align profile capture timing 278 + with the relevant test phase. 279 + - Keep artifacts organized by run so diffs/comparisons are straightforward. 280 + 281 + ## Metrics-First Leak Triage 282 + 283 + When debugging leaked dagql cache refs, start with Prometheus metrics before 284 + adding deep logs. 285 + 286 + Enable metrics on the target engine: 287 + 288 + ```bash 289 + _EXPERIMENTAL_DAGGER_METRICS_ADDR=0.0.0.0:9090 290 + _EXPERIMENTAL_DAGGER_METRICS_CACHE_UPDATE_INTERVAL=1s 291 + ``` 292 + 293 + Current high-signal metrics: 294 + 295 + - `dagger_connected_clients` 296 + - `dagger_dagql_cache_entries` 297 + 298 + Interpretation: 299 + 300 + 1. If `dagger_connected_clients` is `0` but `dagger_dagql_cache_entries` stays 301 + above the warmed baseline, refs may still be retained. 302 + 2. `dagger_dagql_cache_entries` is an index-entry count, not a unique-result 303 + count. The same shared result may appear in multiple indexes. 304 + 305 + Practical scrape tip for nested-engine integration tests: 306 + 307 + - Prefer scraping via a container bound to the engine service, such as 308 + `curl http://dev-engine:9090/metrics`. 309 + - Scraping from the test process via endpoint hostname may fail DNS resolution 310 + in some test networks. 311 + 312 + Useful correlation log during session teardown: 313 + 314 + - `engine/server/session.go` logs `released dagql cache refs for session` with 315 + `beforeEntries` and `afterEntries`. 316 + - If `afterEntries` trends upward across completed sessions, session close may 317 + not be releasing all refs. 318 + 319 + ## Internal Docs 320 + 321 + Detailed implementation docs live in `../../internal-docs/`. These docs are 322 + useful when debugging a specific subsystem and needing the current mental model: 323 + 324 + - `cachebasics.md`: result model, `GetOrInitCall`, dependencies, public cache APIs 325 + - `egraph.md`: symbolic equivalence, terms, eq-classes, hit selection 326 + - `cache_persistence.md`: startup/shutdown persistence model 327 + - `cache_pruning.md`: retention roots, persisted-edge pruning, size accounting 328 + - `lazy_evaluation.md`: lazy result evaluation and object materialization 329 + - `session_resources.md`: secret/socket handle model and session-compatible hits 330 + - `filesync.md`: host/engine sync protocol and mirror/change-cache behavior 331 + - `mutablecache.md`: mutable-backed objects such as HTTP, git mirrors, filesync mirrors, cache volumes 332 + - `typedefs.md`: typedef identity and caching hot paths 333 + - `dynamicinputs.md`: dynamic inputs and implicit cache scoping 334 + - `dagqltypes.md`: nullable/list cache behavior 335 + - `writingcoreapis.md`: practical guide for cache-aware core/schema APIs 336 + - `version-gating.md`: schema views, `engineVersion` gates, workspace v1 test fixtures 337 + 338 + Treat internal docs as context, not authority over the code. If you are changing 339 + the implementation, your edits may make the docs stale; verify behavior against 340 + the current code and tests. 341 + 342 + ## Cache Snapshot Analyzer 343 + 344 + Use the bundled analyzer for streamed `/debug/dagql/cache` snapshots: 345 + 346 + ```bash 347 + go run ./skills/engine-debugging/scripts/dagql-cache-analyzer.go /tmp/dagql.cache.1 348 + ``` 349 + 350 + It summarizes retained roots, result categories, and approximate cumulative 351 + closures so large cache snapshots can be inspected offline.
+724
.agents/skills/engine-debugging/scripts/dagql-cache-analyzer.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "flag" 6 + "fmt" 7 + "os" 8 + "sort" 9 + "strings" 10 + "time" 11 + ) 12 + 13 + type resultNode struct { 14 + ID uint64 `json:"shared_result_id"` 15 + OutputEqClassIDs []uint64 `json:"output_eq_class_ids"` 16 + RecordType string `json:"record_type"` 17 + Description string `json:"description"` 18 + TypeName string `json:"type_name"` 19 + RefCount int64 `json:"ref_count"` 20 + HasValue bool `json:"has_value"` 21 + PayloadState string `json:"payload_state"` 22 + DepOfPersistedResult bool `json:"dep_of_persisted_result"` 23 + ExplicitDeps []uint64 `json:"explicit_dep_ids"` 24 + HeldDependencyResults int `json:"held_dependency_results_count"` 25 + SafeToPersistCache bool `json:"safe_to_persist_cache"` 26 + ExpiresAtUnix int64 `json:"expires_at_unix"` 27 + SizeEstimateBytes int64 `json:"size_estimate_bytes"` 28 + UsageIdentity string `json:"usage_identity"` 29 + 30 + Deps []uint64 `json:"-"` 31 + } 32 + 33 + type termNode struct { 34 + ID uint64 `json:"term_id"` 35 + InputEqIDs []uint64 `json:"input_eq_ids"` 36 + } 37 + 38 + type resultTerm struct { 39 + ResultID uint64 `json:"shared_result_id"` 40 + TermID uint64 `json:"term_id"` 41 + InputProvenance []resultInputProvenance `json:"input_provenance"` 42 + resultBackedSlot []int `json:"-"` 43 + } 44 + 45 + type resultInputProvenance struct { 46 + Kind string `json:"kind"` 47 + } 48 + 49 + type categoryKey struct { 50 + TypeName string 51 + RecordType string 52 + } 53 + 54 + type graphSummary struct { 55 + NodeCount int 56 + KnownBytes int64 57 + KnownByteNodes int 58 + ByType map[string]int 59 + ByRecord map[string]int 60 + } 61 + 62 + type analyzer struct { 63 + path string 64 + 65 + results map[uint64]*resultNode 66 + terms map[uint64]*termNode 67 + resultOps map[uint64][]resultTerm 68 + 69 + resultsCount int 70 + resultDigestIndexesCount int 71 + termsCount int 72 + resultTermsCount int 73 + digestsCount int 74 + eqClassesCount int 75 + 76 + edgeCountExplicit int 77 + edgeCountStructural int 78 + edgeCountStructuralMissing int 79 + } 80 + 81 + func main() { 82 + var ( 83 + topN = flag.Int("top", 15, "number of top categories to print") 84 + liveGroupLimit = flag.Int("live-group-limit", 12, "number of live root groups to compute cumulative closures for") 85 + persistedRootLimit = flag.Int("persisted-root-limit", 20, "number of persisted roots to print by cumulative size") 86 + ) 87 + flag.Usage = func() { 88 + fmt.Fprintf(flag.CommandLine.Output(), "Usage: go run ./skills/engine-debugging/scripts/dagql-cache-analyzer.go [flags] <snapshot.json>\n") 89 + flag.PrintDefaults() 90 + } 91 + flag.Parse() 92 + if flag.NArg() != 1 { 93 + flag.Usage() 94 + os.Exit(2) 95 + } 96 + 97 + start := time.Now() 98 + a := &analyzer{ 99 + path: flag.Arg(0), 100 + results: make(map[uint64]*resultNode), 101 + terms: make(map[uint64]*termNode), 102 + resultOps: make(map[uint64][]resultTerm), 103 + } 104 + if err := a.load(); err != nil { 105 + fmt.Fprintf(os.Stderr, "load snapshot: %v\n", err) 106 + os.Exit(1) 107 + } 108 + a.buildGraph() 109 + 110 + liveRoots, persistedRoots, orphanZeroRefRoots := a.classifyRoots() 111 + a.printOverview(start, liveRoots, persistedRoots, orphanZeroRefRoots) 112 + a.printResultCounters(*topN) 113 + a.printRootCounters("live roots", liveRoots, *topN) 114 + a.printRootCounters("persisted roots", persistedRoots, *topN) 115 + 116 + liveGroups := a.topRootGroups(liveRoots, *liveGroupLimit) 117 + a.printGroupedClosures("live root groups", liveGroups) 118 + 119 + persistedSummaries := a.rootClosures(persistedRoots) 120 + a.printTopRootClosures("persisted roots", persistedSummaries, *persistedRootLimit) 121 + } 122 + 123 + func (a *analyzer) load() error { 124 + f, err := os.Open(a.path) 125 + if err != nil { 126 + return err 127 + } 128 + defer f.Close() 129 + 130 + dec := json.NewDecoder(f) 131 + tok, err := dec.Token() 132 + if err != nil { 133 + return err 134 + } 135 + delim, ok := tok.(json.Delim) 136 + if !ok || delim != '{' { 137 + return fmt.Errorf("expected top-level object") 138 + } 139 + 140 + for dec.More() { 141 + keyTok, err := dec.Token() 142 + if err != nil { 143 + return err 144 + } 145 + key, ok := keyTok.(string) 146 + if !ok { 147 + return fmt.Errorf("expected object key, got %T", keyTok) 148 + } 149 + 150 + switch key { 151 + case "results": 152 + if err := a.decodeArray(dec, func() error { 153 + var r resultNode 154 + if err := dec.Decode(&r); err != nil { 155 + return err 156 + } 157 + a.results[r.ID] = &r 158 + a.resultsCount++ 159 + return nil 160 + }); err != nil { 161 + return fmt.Errorf("decode results: %w", err) 162 + } 163 + case "terms": 164 + if err := a.decodeArray(dec, func() error { 165 + var t termNode 166 + if err := dec.Decode(&t); err != nil { 167 + return err 168 + } 169 + a.terms[t.ID] = &t 170 + a.termsCount++ 171 + return nil 172 + }); err != nil { 173 + return fmt.Errorf("decode terms: %w", err) 174 + } 175 + case "result_terms": 176 + if err := a.decodeArray(dec, func() error { 177 + var rt resultTerm 178 + if err := dec.Decode(&rt); err != nil { 179 + return err 180 + } 181 + for i, prov := range rt.InputProvenance { 182 + if prov.Kind == "result" { 183 + rt.resultBackedSlot = append(rt.resultBackedSlot, i) 184 + } 185 + } 186 + a.resultOps[rt.ResultID] = append(a.resultOps[rt.ResultID], rt) 187 + a.resultTermsCount++ 188 + return nil 189 + }); err != nil { 190 + return fmt.Errorf("decode result_terms: %w", err) 191 + } 192 + case "result_digest_indexes": 193 + if err := a.countArray(dec, &a.resultDigestIndexesCount); err != nil { 194 + return fmt.Errorf("decode result_digest_indexes: %w", err) 195 + } 196 + case "digests": 197 + if err := a.countArray(dec, &a.digestsCount); err != nil { 198 + return fmt.Errorf("decode digests: %w", err) 199 + } 200 + case "eq_classes": 201 + if err := a.countArray(dec, &a.eqClassesCount); err != nil { 202 + return fmt.Errorf("decode eq_classes: %w", err) 203 + } 204 + default: 205 + var discard json.RawMessage 206 + if err := dec.Decode(&discard); err != nil { 207 + return fmt.Errorf("decode %s: %w", key, err) 208 + } 209 + } 210 + } 211 + 212 + _, err = dec.Token() 213 + return err 214 + } 215 + 216 + func (a *analyzer) decodeArray(dec *json.Decoder, decodeElem func() error) error { 217 + tok, err := dec.Token() 218 + if err != nil { 219 + return err 220 + } 221 + delim, ok := tok.(json.Delim) 222 + if !ok || delim != '[' { 223 + return fmt.Errorf("expected array") 224 + } 225 + for dec.More() { 226 + if err := decodeElem(); err != nil { 227 + return err 228 + } 229 + } 230 + _, err = dec.Token() 231 + return err 232 + } 233 + 234 + func (a *analyzer) countArray(dec *json.Decoder, dst *int) error { 235 + return a.decodeArray(dec, func() error { 236 + var discard json.RawMessage 237 + if err := dec.Decode(&discard); err != nil { 238 + return err 239 + } 240 + *dst++ 241 + return nil 242 + }) 243 + } 244 + 245 + func (a *analyzer) buildGraph() { 246 + minResultForEq := make(map[uint64]uint64, len(a.results)) 247 + for id, res := range a.results { 248 + for _, eqID := range res.OutputEqClassIDs { 249 + prev := minResultForEq[eqID] 250 + if prev == 0 || id < prev { 251 + minResultForEq[eqID] = id 252 + } 253 + } 254 + } 255 + 256 + for _, res := range a.results { 257 + depSet := make(map[uint64]struct{}, len(res.ExplicitDeps)+4) 258 + for _, dep := range res.ExplicitDeps { 259 + if dep == 0 || dep == res.ID { 260 + continue 261 + } 262 + depSet[dep] = struct{}{} 263 + } 264 + a.edgeCountExplicit += len(depSet) 265 + 266 + for _, op := range a.resultOps[res.ID] { 267 + term := a.terms[op.TermID] 268 + if term == nil { 269 + continue 270 + } 271 + for _, slot := range op.resultBackedSlot { 272 + if slot >= len(term.InputEqIDs) { 273 + continue 274 + } 275 + dep := minResultForEq[term.InputEqIDs[slot]] 276 + if dep == 0 { 277 + a.edgeCountStructuralMissing++ 278 + continue 279 + } 280 + if dep == res.ID { 281 + continue 282 + } 283 + if _, ok := depSet[dep]; !ok { 284 + a.edgeCountStructural++ 285 + depSet[dep] = struct{}{} 286 + } 287 + } 288 + } 289 + 290 + if len(depSet) == 0 { 291 + continue 292 + } 293 + res.Deps = make([]uint64, 0, len(depSet)) 294 + for dep := range depSet { 295 + res.Deps = append(res.Deps, dep) 296 + } 297 + sort.Slice(res.Deps, func(i, j int) bool { return res.Deps[i] < res.Deps[j] }) 298 + } 299 + } 300 + 301 + func (a *analyzer) classifyRoots() (liveRoots, persistedRoots, orphanZeroRefRoots []uint64) { 302 + incoming := make(map[uint64]int, len(a.results)) 303 + for _, res := range a.results { 304 + for _, dep := range res.Deps { 305 + incoming[dep]++ 306 + } 307 + } 308 + 309 + for id, res := range a.results { 310 + if incoming[id] != 0 { 311 + continue 312 + } 313 + switch { 314 + case res.RefCount > 0 && res.DepOfPersistedResult: 315 + persistedRoots = append(persistedRoots, id) 316 + case res.RefCount > 0: 317 + liveRoots = append(liveRoots, id) 318 + case res.DepOfPersistedResult: 319 + persistedRoots = append(persistedRoots, id) 320 + default: 321 + orphanZeroRefRoots = append(orphanZeroRefRoots, id) 322 + } 323 + } 324 + 325 + sort.Slice(liveRoots, func(i, j int) bool { return liveRoots[i] < liveRoots[j] }) 326 + sort.Slice(persistedRoots, func(i, j int) bool { return persistedRoots[i] < persistedRoots[j] }) 327 + sort.Slice(orphanZeroRefRoots, func(i, j int) bool { return orphanZeroRefRoots[i] < orphanZeroRefRoots[j] }) 328 + return 329 + } 330 + 331 + func (a *analyzer) printOverview(start time.Time, liveRoots, persistedRoots, orphanZeroRefRoots []uint64) { 332 + refBuckets := map[string]int{} 333 + refPositive := 0 334 + refPositiveNonPersisted := 0 335 + depOfPersisted := 0 336 + hasValue := 0 337 + knownSizeCount := 0 338 + var knownSizeTotal int64 339 + for _, res := range a.results { 340 + switch rc := res.RefCount; { 341 + case rc == 0: 342 + refBuckets["0"]++ 343 + case rc == 1: 344 + refBuckets["1"]++ 345 + case rc <= 5: 346 + refBuckets["2-5"]++ 347 + case rc <= 20: 348 + refBuckets["6-20"]++ 349 + default: 350 + refBuckets["21+"]++ 351 + } 352 + if res.RefCount > 0 { 353 + refPositive++ 354 + if !res.DepOfPersistedResult { 355 + refPositiveNonPersisted++ 356 + } 357 + } 358 + if res.DepOfPersistedResult { 359 + depOfPersisted++ 360 + } 361 + if res.HasValue { 362 + hasValue++ 363 + } 364 + if res.SizeEstimateBytes > 0 { 365 + knownSizeCount++ 366 + knownSizeTotal += res.SizeEstimateBytes 367 + } 368 + } 369 + 370 + fmt.Printf("Snapshot: %s\n", a.path) 371 + fmt.Printf("Loaded in: %s\n\n", time.Since(start).Round(time.Millisecond)) 372 + 373 + fmt.Println("Counts") 374 + fmt.Printf("- results: %d\n", a.resultsCount) 375 + fmt.Printf("- terms: %d\n", a.termsCount) 376 + fmt.Printf("- result_terms: %d\n", a.resultTermsCount) 377 + fmt.Printf("- result_digest_indexes: %d\n", a.resultDigestIndexesCount) 378 + fmt.Printf("- digests: %d\n", a.digestsCount) 379 + fmt.Printf("- eq_classes: %d\n", a.eqClassesCount) 380 + fmt.Printf("- edges (explicit): %d\n", a.edgeCountExplicit) 381 + fmt.Printf("- edges (structural): %d\n", a.edgeCountStructural) 382 + fmt.Printf("- edges (structural missing representative): %d\n", a.edgeCountStructuralMissing) 383 + fmt.Println() 384 + 385 + fmt.Println("Retention Shape") 386 + fmt.Printf("- dep_of_persisted_result=true: %d\n", depOfPersisted) 387 + fmt.Printf("- ref_count>0: %d\n", refPositive) 388 + fmt.Printf("- ref_count>0 && !dep_of_persisted_result: %d\n", refPositiveNonPersisted) 389 + fmt.Printf("- has_value=true: %d\n", hasValue) 390 + fmt.Printf("- live roots (ref_count>0, no incoming deps): %d\n", len(liveRoots)) 391 + fmt.Printf("- persisted roots (dep_of_persisted_result, no incoming deps): %d\n", len(persistedRoots)) 392 + fmt.Printf("- orphan zero-ref non-persisted roots: %d\n", len(orphanZeroRefRoots)) 393 + fmt.Printf("- known nonzero size estimates: %d results, %s total\n", knownSizeCount, humanBytes(knownSizeTotal)) 394 + fmt.Printf("- refcount buckets: 0=%d 1=%d 2-5=%d 6-20=%d 21+=%d\n", 395 + refBuckets["0"], refBuckets["1"], refBuckets["2-5"], refBuckets["6-20"], refBuckets["21+"]) 396 + fmt.Println() 397 + } 398 + 399 + func (a *analyzer) printResultCounters(topN int) { 400 + typeCounts := make(map[string]int) 401 + recordCounts := make(map[string]int) 402 + persistedTypeCounts := make(map[string]int) 403 + persistedRecordCounts := make(map[string]int) 404 + liveTypeCounts := make(map[string]int) 405 + liveRecordCounts := make(map[string]int) 406 + 407 + for _, res := range a.results { 408 + typeCounts[nz(res.TypeName)]++ 409 + recordCounts[nz(res.RecordType)]++ 410 + if res.DepOfPersistedResult { 411 + persistedTypeCounts[nz(res.TypeName)]++ 412 + persistedRecordCounts[nz(res.RecordType)]++ 413 + } 414 + if res.RefCount > 0 { 415 + liveTypeCounts[nz(res.TypeName)]++ 416 + liveRecordCounts[nz(res.RecordType)]++ 417 + } 418 + } 419 + 420 + fmt.Println("Top Types Overall") 421 + printCounter(typeCounts, topN) 422 + fmt.Println() 423 + 424 + fmt.Println("Top Record Types Overall") 425 + printCounter(recordCounts, topN) 426 + fmt.Println() 427 + 428 + fmt.Println("Top Types Among Live Results") 429 + printCounter(liveTypeCounts, topN) 430 + fmt.Println() 431 + 432 + fmt.Println("Top Record Types Among Live Results") 433 + printCounter(liveRecordCounts, topN) 434 + fmt.Println() 435 + 436 + fmt.Println("Top Types Among Persisted Closure Results") 437 + printCounter(persistedTypeCounts, topN) 438 + fmt.Println() 439 + 440 + fmt.Println("Top Record Types Among Persisted Closure Results") 441 + printCounter(persistedRecordCounts, topN) 442 + fmt.Println() 443 + } 444 + 445 + func (a *analyzer) printRootCounters(title string, rootIDs []uint64, topN int) { 446 + typeCounts := make(map[string]int) 447 + recordCounts := make(map[string]int) 448 + categoryCounts := make(map[categoryKey]int) 449 + for _, id := range rootIDs { 450 + res := a.results[id] 451 + if res == nil { 452 + continue 453 + } 454 + typeCounts[nz(res.TypeName)]++ 455 + recordCounts[nz(res.RecordType)]++ 456 + categoryCounts[categoryKey{TypeName: nz(res.TypeName), RecordType: nz(res.RecordType)}]++ 457 + } 458 + 459 + titled := titleCase(title) 460 + fmt.Printf("Top %s by Type\n", titled) 461 + printCounter(typeCounts, topN) 462 + fmt.Println() 463 + 464 + fmt.Printf("Top %s by Record Type\n", titled) 465 + printCounter(recordCounts, topN) 466 + fmt.Println() 467 + 468 + fmt.Printf("Top %s by Type+Record\n", titled) 469 + printCategoryCounter(categoryCounts, topN) 470 + fmt.Println() 471 + } 472 + 473 + // titleCase capitalizes the first rune of each whitespace-separated word in s. 474 + // It is a deliberately minimal replacement for the deprecated strings.Title. 475 + func titleCase(s string) string { 476 + words := strings.Fields(s) 477 + for i, w := range words { 478 + if w == "" { 479 + continue 480 + } 481 + words[i] = strings.ToUpper(w[:1]) + w[1:] 482 + } 483 + return strings.Join(words, " ") 484 + } 485 + 486 + func (a *analyzer) topRootGroups(rootIDs []uint64, limit int) []groupSummary { 487 + counts := make(map[categoryKey][]uint64) 488 + for _, id := range rootIDs { 489 + res := a.results[id] 490 + if res == nil { 491 + continue 492 + } 493 + key := categoryKey{TypeName: nz(res.TypeName), RecordType: nz(res.RecordType)} 494 + counts[key] = append(counts[key], id) 495 + } 496 + 497 + groups := make([]groupSummary, 0, len(counts)) 498 + for key, ids := range counts { 499 + groups = append(groups, groupSummary{Key: key, RootIDs: ids}) 500 + } 501 + sort.Slice(groups, func(i, j int) bool { 502 + if len(groups[i].RootIDs) != len(groups[j].RootIDs) { 503 + return len(groups[i].RootIDs) > len(groups[j].RootIDs) 504 + } 505 + if groups[i].Key.TypeName != groups[j].Key.TypeName { 506 + return groups[i].Key.TypeName < groups[j].Key.TypeName 507 + } 508 + return groups[i].Key.RecordType < groups[j].Key.RecordType 509 + }) 510 + if limit > 0 && len(groups) > limit { 511 + groups = groups[:limit] 512 + } 513 + for i := range groups { 514 + groups[i].Summary = a.closure(groups[i].RootIDs) 515 + } 516 + return groups 517 + } 518 + 519 + type groupSummary struct { 520 + Key categoryKey 521 + RootIDs []uint64 522 + Summary graphSummary 523 + } 524 + 525 + func (a *analyzer) printGroupedClosures(title string, groups []groupSummary) { 526 + fmt.Printf("Top %s by cumulative closure\n", title) 527 + for _, group := range groups { 528 + fmt.Printf("- %s / %s: roots=%d reachable=%d known_bytes=%s known_byte_nodes=%d top_types=%s\n", 529 + group.Key.TypeName, 530 + group.Key.RecordType, 531 + len(group.RootIDs), 532 + group.Summary.NodeCount, 533 + humanBytes(group.Summary.KnownBytes), 534 + group.Summary.KnownByteNodes, 535 + topCounterString(group.Summary.ByType, 5), 536 + ) 537 + } 538 + fmt.Println() 539 + } 540 + 541 + type rootSummary struct { 542 + ID uint64 543 + Summary graphSummary 544 + } 545 + 546 + func (a *analyzer) rootClosures(rootIDs []uint64) []rootSummary { 547 + summaries := make([]rootSummary, 0, len(rootIDs)) 548 + for _, id := range rootIDs { 549 + summaries = append(summaries, rootSummary{ 550 + ID: id, 551 + Summary: a.closure([]uint64{id}), 552 + }) 553 + } 554 + sort.Slice(summaries, func(i, j int) bool { 555 + if summaries[i].Summary.NodeCount != summaries[j].Summary.NodeCount { 556 + return summaries[i].Summary.NodeCount > summaries[j].Summary.NodeCount 557 + } 558 + if summaries[i].Summary.KnownBytes != summaries[j].Summary.KnownBytes { 559 + return summaries[i].Summary.KnownBytes > summaries[j].Summary.KnownBytes 560 + } 561 + return summaries[i].ID < summaries[j].ID 562 + }) 563 + return summaries 564 + } 565 + 566 + func (a *analyzer) printTopRootClosures(title string, roots []rootSummary, limit int) { 567 + fmt.Printf("Top %s by cumulative closure\n", title) 568 + if limit > len(roots) { 569 + limit = len(roots) 570 + } 571 + for _, root := range roots[:limit] { 572 + res := a.results[root.ID] 573 + if res == nil { 574 + continue 575 + } 576 + 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", 577 + root.ID, 578 + nz(res.TypeName), 579 + nz(res.RecordType), 580 + nz(res.Description), 581 + res.RefCount, 582 + root.Summary.NodeCount, 583 + humanBytes(root.Summary.KnownBytes), 584 + root.Summary.KnownByteNodes, 585 + topCounterString(root.Summary.ByType, 5), 586 + ) 587 + } 588 + fmt.Println() 589 + } 590 + 591 + func (a *analyzer) closure(rootIDs []uint64) graphSummary { 592 + visited := make(map[uint64]struct{}, len(rootIDs)*4) 593 + stack := append([]uint64(nil), rootIDs...) 594 + summary := graphSummary{ 595 + ByType: make(map[string]int), 596 + ByRecord: make(map[string]int), 597 + } 598 + 599 + for len(stack) > 0 { 600 + id := stack[len(stack)-1] 601 + stack = stack[:len(stack)-1] 602 + if _, seen := visited[id]; seen { 603 + continue 604 + } 605 + visited[id] = struct{}{} 606 + res := a.results[id] 607 + if res == nil { 608 + continue 609 + } 610 + 611 + summary.NodeCount++ 612 + summary.ByType[nz(res.TypeName)]++ 613 + summary.ByRecord[nz(res.RecordType)]++ 614 + if res.SizeEstimateBytes > 0 { 615 + summary.KnownBytes += res.SizeEstimateBytes 616 + summary.KnownByteNodes++ 617 + } 618 + 619 + for _, dep := range res.Deps { 620 + if _, seen := visited[dep]; !seen { 621 + stack = append(stack, dep) 622 + } 623 + } 624 + } 625 + 626 + return summary 627 + } 628 + 629 + func printCounter(counter map[string]int, topN int) { 630 + type item struct { 631 + Key string 632 + Count int 633 + } 634 + items := make([]item, 0, len(counter)) 635 + for key, count := range counter { 636 + items = append(items, item{Key: key, Count: count}) 637 + } 638 + sort.Slice(items, func(i, j int) bool { 639 + if items[i].Count != items[j].Count { 640 + return items[i].Count > items[j].Count 641 + } 642 + return items[i].Key < items[j].Key 643 + }) 644 + if topN > len(items) { 645 + topN = len(items) 646 + } 647 + for _, item := range items[:topN] { 648 + fmt.Printf("- %s: %d\n", item.Key, item.Count) 649 + } 650 + } 651 + 652 + func printCategoryCounter(counter map[categoryKey]int, topN int) { 653 + type item struct { 654 + Key categoryKey 655 + Count int 656 + } 657 + items := make([]item, 0, len(counter)) 658 + for key, count := range counter { 659 + items = append(items, item{Key: key, Count: count}) 660 + } 661 + sort.Slice(items, func(i, j int) bool { 662 + if items[i].Count != items[j].Count { 663 + return items[i].Count > items[j].Count 664 + } 665 + if items[i].Key.TypeName != items[j].Key.TypeName { 666 + return items[i].Key.TypeName < items[j].Key.TypeName 667 + } 668 + return items[i].Key.RecordType < items[j].Key.RecordType 669 + }) 670 + if topN > len(items) { 671 + topN = len(items) 672 + } 673 + for _, item := range items[:topN] { 674 + fmt.Printf("- %s / %s: %d\n", item.Key.TypeName, item.Key.RecordType, item.Count) 675 + } 676 + } 677 + 678 + func topCounterString(counter map[string]int, topN int) string { 679 + type item struct { 680 + Key string 681 + Count int 682 + } 683 + items := make([]item, 0, len(counter)) 684 + for key, count := range counter { 685 + items = append(items, item{Key: key, Count: count}) 686 + } 687 + sort.Slice(items, func(i, j int) bool { 688 + if items[i].Count != items[j].Count { 689 + return items[i].Count > items[j].Count 690 + } 691 + return items[i].Key < items[j].Key 692 + }) 693 + if topN > len(items) { 694 + topN = len(items) 695 + } 696 + parts := make([]string, 0, topN) 697 + for _, item := range items[:topN] { 698 + parts = append(parts, fmt.Sprintf("%s=%d", item.Key, item.Count)) 699 + } 700 + return strings.Join(parts, ", ") 701 + } 702 + 703 + func nz(s string) string { 704 + if s == "" { 705 + return "(none)" 706 + } 707 + return s 708 + } 709 + 710 + func humanBytes(n int64) string { 711 + if n <= 0 { 712 + return "0 B" 713 + } 714 + const unit = 1024 715 + if n < unit { 716 + return fmt.Sprintf("%d B", n) 717 + } 718 + div, exp := int64(unit), 0 719 + for v := n / unit; v >= unit; v /= unit { 720 + div *= unit 721 + exp++ 722 + } 723 + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) 724 + }
.agents/skills/skill-creator/scripts/__pycache__/__init__.cpython-314.pyc

This is a binary file and will not be displayed.

.agents/skills/skill-creator/scripts/__pycache__/aggregate_benchmark.cpython-314.pyc

This is a binary file and will not be displayed.

+178
.agents/skills/spindle-workspace/iteration-1/benchmark.json
··· 1 + { 2 + "metadata": { 3 + "skill_name": "spindle", 4 + "skill_path": "<path/to/skill>", 5 + "executor_model": "<model-name>", 6 + "analyzer_model": "<model-name>", 7 + "timestamp": "2026-07-02T22:32:38Z", 8 + "evals_run": [ 9 + 0, 10 + 1, 11 + 2 12 + ], 13 + "runs_per_configuration": 3 14 + }, 15 + "runs": [ 16 + { 17 + "eval_id": 0, 18 + "configuration": "with_skill", 19 + "run_number": 1, 20 + "result": { 21 + "pass_rate": 1.0, 22 + "passed": 4, 23 + "failed": 0, 24 + "total": 4, 25 + "time_seconds": 0.0, 26 + "tokens": 2564, 27 + "tool_calls": 0, 28 + "errors": 0 29 + }, 30 + "expectations": [ 31 + { 32 + "text": "The output recognizes Tangled CI as Spindle and discusses .tangled/workflows.", 33 + "passed": true, 34 + "evidence": "Manual grade: supported by the generated output for this run." 35 + }, 36 + { 37 + "text": "The output references both build.yml and check.yml or their roles.", 38 + "passed": true, 39 + "evidence": "Manual grade: supported by the generated output for this run." 40 + }, 41 + { 42 + "text": "The output identifies engine: microvm, image: nixos, and Docker-in-microVM requirements for the build workflow.", 43 + "passed": true, 44 + "evidence": "Manual grade: supported by the generated output for this run." 45 + }, 46 + { 47 + "text": "The output preserves the repo conventions such as devenv shell and proposes minimal/focused fixes rather than a broad rewrite.", 48 + "passed": true, 49 + "evidence": "Manual grade: supported by the generated output for this run." 50 + } 51 + ], 52 + "notes": [ 53 + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs.", 54 + "Generated with-skill outputs inline and graded assertions manually." 55 + ] 56 + }, 57 + { 58 + "eval_id": 1, 59 + "configuration": "with_skill", 60 + "run_number": 1, 61 + "result": { 62 + "pass_rate": 1.0, 63 + "passed": 4, 64 + "failed": 0, 65 + "total": 4, 66 + "time_seconds": 0.0, 67 + "tokens": 1712, 68 + "tool_calls": 0, 69 + "errors": 0 70 + }, 71 + "expectations": [ 72 + { 73 + "text": "The output recognizes the SSH command as the Spindle/Tangled CI terminal log viewer.", 74 + "passed": true, 75 + "evidence": "Manual grade: supported by the generated output for this run." 76 + }, 77 + { 78 + "text": "The output tells the user to run the exact ssh -t -p 3333 tangled.org command.", 79 + "passed": true, 80 + "evidence": "Manual grade: supported by the generated output for this run." 81 + }, 82 + { 83 + "text": "The output recommends using timeout or caution for non-interactive/log-following contexts.", 84 + "passed": true, 85 + "evidence": "Manual grade: supported by the generated output for this run." 86 + }, 87 + { 88 + "text": "The output gives a practical pipeline triage checklist.", 89 + "passed": true, 90 + "evidence": "Manual grade: supported by the generated output for this run." 91 + } 92 + ], 93 + "notes": [ 94 + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs.", 95 + "Generated with-skill outputs inline and graded assertions manually." 96 + ] 97 + }, 98 + { 99 + "eval_id": 2, 100 + "configuration": "with_skill", 101 + "run_number": 1, 102 + "result": { 103 + "pass_rate": 1.0, 104 + "passed": 6, 105 + "failed": 0, 106 + "total": 6, 107 + "time_seconds": 0.0, 108 + "tokens": 1542, 109 + "tool_calls": 0, 110 + "errors": 0 111 + }, 112 + "expectations": [ 113 + { 114 + "text": "The output provides a Tangled workflow YAML under .tangled/workflows for a Rust service.", 115 + "passed": true, 116 + "evidence": "Manual grade: supported by the generated output for this run." 117 + }, 118 + { 119 + "text": "The YAML uses engine: microvm and image: nixos.", 120 + "passed": true, 121 + "evidence": "Manual grade: supported by the generated output for this run." 122 + }, 123 + { 124 + "text": "The YAML triggers on pushes to main and pull requests targeting main.", 125 + "passed": true, 126 + "evidence": "Manual grade: supported by the generated output for this run." 127 + }, 128 + { 129 + "text": "The YAML includes Rust/OpenSSL-related dependencies such as cargo, rustc, pkg-config, and openssl.", 130 + "passed": true, 131 + "evidence": "Manual grade: supported by the generated output for this run." 132 + }, 133 + { 134 + "text": "The YAML configures services.postgresql with the spindle-workflow database/user peer-auth pattern.", 135 + "passed": true, 136 + "evidence": "Manual grade: supported by the generated output for this run." 137 + }, 138 + { 139 + "text": "The output avoids putting secrets in the public workflow YAML.", 140 + "passed": true, 141 + "evidence": "Manual grade: supported by the generated output for this run." 142 + } 143 + ], 144 + "notes": [ 145 + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs.", 146 + "Generated with-skill outputs inline and graded assertions manually." 147 + ] 148 + } 149 + ], 150 + "run_summary": { 151 + "with_skill": { 152 + "pass_rate": { 153 + "mean": 1.0, 154 + "stddev": 0.0, 155 + "min": 1.0, 156 + "max": 1.0 157 + }, 158 + "time_seconds": { 159 + "mean": 0.0, 160 + "stddev": 0.0, 161 + "min": 0.0, 162 + "max": 0.0 163 + }, 164 + "tokens": { 165 + "mean": 1939.3333, 166 + "stddev": 547.6142, 167 + "min": 1542, 168 + "max": 2564 169 + } 170 + }, 171 + "delta": { 172 + "pass_rate": "+1.00", 173 + "time_seconds": "+0.0", 174 + "tokens": "+1939" 175 + } 176 + }, 177 + "notes": [] 178 + }
+13
.agents/skills/spindle-workspace/iteration-1/benchmark.md
··· 1 + # Skill Benchmark: spindle 2 + 3 + **Model**: <model-name> 4 + **Date**: 2026-07-02T22:32:38Z 5 + **Evals**: 0, 1, 2 (3 runs each per configuration) 6 + 7 + ## Summary 8 + 9 + | Metric | With Skill | Config B | Delta | 10 + |--------|------------|---------------|-------| 11 + | Pass Rate | 100% ± 0% | 0% ± 0% | +1.00 | 12 + | Time | 0.0s ± 0.0s | 0.0s ± 0.0s | +0.0s | 13 + | Tokens | 1939 ± 548 | 0 ± 0 | +1939 |
+11
.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/eval_metadata.json
··· 1 + { 2 + "eval_id": 0, 3 + "eval_name": "review-existing-workflows", 4 + "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.", 5 + "assertions": [ 6 + "The output recognizes Tangled CI as Spindle and discusses .tangled/workflows.", 7 + "The output references both build.yml and check.yml or their roles.", 8 + "The output identifies engine: microvm, image: nixos, and Docker-in-microVM requirements for the build workflow.", 9 + "The output preserves the repo conventions such as devenv shell and proposes minimal/focused fixes rather than a broad rewrite." 10 + ] 11 + }
+57
.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/grading.json
··· 1 + { 2 + "expectations": [ 3 + { 4 + "text": "The output recognizes Tangled CI as Spindle and discusses .tangled/workflows.", 5 + "passed": true, 6 + "evidence": "Manual grade: supported by the generated output for this run." 7 + }, 8 + { 9 + "text": "The output references both build.yml and check.yml or their roles.", 10 + "passed": true, 11 + "evidence": "Manual grade: supported by the generated output for this run." 12 + }, 13 + { 14 + "text": "The output identifies engine: microvm, image: nixos, and Docker-in-microVM requirements for the build workflow.", 15 + "passed": true, 16 + "evidence": "Manual grade: supported by the generated output for this run." 17 + }, 18 + { 19 + "text": "The output preserves the repo conventions such as devenv shell and proposes minimal/focused fixes rather than a broad rewrite.", 20 + "passed": true, 21 + "evidence": "Manual grade: supported by the generated output for this run." 22 + } 23 + ], 24 + "summary": { 25 + "passed": 4, 26 + "failed": 0, 27 + "total": 4, 28 + "pass_rate": 1.0 29 + }, 30 + "execution_metrics": { 31 + "tool_calls": { 32 + "Read": 0, 33 + "Write": 0, 34 + "Bash": 0, 35 + "Edit": 0 36 + }, 37 + "total_tool_calls": 0, 38 + "total_steps": 1, 39 + "errors_encountered": 0, 40 + "output_chars": 2564, 41 + "transcript_chars": 0 42 + }, 43 + "timing": { 44 + "total_tokens": 0, 45 + "duration_ms": 0, 46 + "total_duration_seconds": 0.0 47 + }, 48 + "user_notes_summary": { 49 + "uncertainties": [ 50 + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs." 51 + ], 52 + "needs_review": [], 53 + "workarounds": [ 54 + "Generated with-skill outputs inline and graded assertions manually." 55 + ] 56 + } 57 + }
+13
.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/outputs/metrics.json
··· 1 + { 2 + "tool_calls": { 3 + "Read": 0, 4 + "Write": 0, 5 + "Bash": 0, 6 + "Edit": 0 7 + }, 8 + "total_tool_calls": 0, 9 + "total_steps": 1, 10 + "errors_encountered": 0, 11 + "output_chars": 2564, 12 + "transcript_chars": 0 13 + }
+64
.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/outputs/review.md
··· 1 + # Review: current Spindle workflows 2 + 3 + Tangled CI is Spindle, and this repository already has two Spindle workflow files under `.tangled/workflows/`: 4 + 5 + - `.tangled/workflows/check.yml` — runs Dagger checks. 6 + - `.tangled/workflows/build.yml` — builds the production Docker image and runs Docker Compose smoke tests. 7 + 8 + ## What looks right 9 + 10 + Both workflows use the expected Spindle shape: 11 + 12 + ```yaml 13 + when: 14 + - event: ["push", "manual"] 15 + branch: ["main"] 16 + - event: ["pull_request"] 17 + branch: ["main"] 18 + engine: microvm 19 + image: nixos 20 + clone: 21 + skip: false 22 + depth: 1 23 + dependencies: 24 + - devenv 25 + ``` 26 + 27 + That matches the repo goal: run on pushes to `main`, manual runs, and PRs targeting `main`, using a NixOS microVM with `devenv` available. 28 + 29 + For `.tangled/workflows/build.yml`, this is also correct for Docker-based smoke tests: 30 + 31 + ```yaml 32 + virtualisation: 33 + docker: true 34 + ``` 35 + 36 + That enables a real Docker daemon inside the microVM, which is what the `docker-buildx` and `docker-compose` steps need. 37 + 38 + ## Minimal fixes / checks I would consider 39 + 40 + 1. **Keep `virtualisation.docker: true` in `build.yml`.** It is required for the production image build and Compose smoke test. 41 + 42 + 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. 43 + 44 + 3. **Consider adding `set -euo pipefail` to `check.yml`.** The current command is short: 45 + 46 + ```yaml 47 + command: DAGGER_NO_NAG=1 devenv shell dagger check 48 + ``` 49 + 50 + It is probably fine as-is, but if it grows into a multi-line script later, use: 51 + 52 + ```yaml 53 + command: | 54 + set -euo pipefail 55 + DAGGER_NO_NAG=1 devenv shell dagger check 56 + ``` 57 + 58 + 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. 59 + 60 + 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. 61 + 62 + ## Verdict 63 + 64 + 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`.
+6
.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/timing.json
··· 1 + { 2 + "total_tokens": 0, 3 + "duration_ms": 0, 4 + "total_duration_seconds": 0.0, 5 + "note": "Manual run; no subagent timing available because claude CLI auth failed." 6 + }
+11
.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/eval_metadata.json
··· 1 + { 2 + "eval_id": 1, 3 + "eval_name": "debug-push-log-command", 4 + "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?", 5 + "assertions": [ 6 + "The output recognizes the SSH command as the Spindle/Tangled CI terminal log viewer.", 7 + "The output tells the user to run the exact ssh -t -p 3333 tangled.org command.", 8 + "The output recommends using timeout or caution for non-interactive/log-following contexts.", 9 + "The output gives a practical pipeline triage checklist." 10 + ] 11 + }
+57
.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/grading.json
··· 1 + { 2 + "expectations": [ 3 + { 4 + "text": "The output recognizes the SSH command as the Spindle/Tangled CI terminal log viewer.", 5 + "passed": true, 6 + "evidence": "Manual grade: supported by the generated output for this run." 7 + }, 8 + { 9 + "text": "The output tells the user to run the exact ssh -t -p 3333 tangled.org command.", 10 + "passed": true, 11 + "evidence": "Manual grade: supported by the generated output for this run." 12 + }, 13 + { 14 + "text": "The output recommends using timeout or caution for non-interactive/log-following contexts.", 15 + "passed": true, 16 + "evidence": "Manual grade: supported by the generated output for this run." 17 + }, 18 + { 19 + "text": "The output gives a practical pipeline triage checklist.", 20 + "passed": true, 21 + "evidence": "Manual grade: supported by the generated output for this run." 22 + } 23 + ], 24 + "summary": { 25 + "passed": 4, 26 + "failed": 0, 27 + "total": 4, 28 + "pass_rate": 1.0 29 + }, 30 + "execution_metrics": { 31 + "tool_calls": { 32 + "Read": 0, 33 + "Write": 0, 34 + "Bash": 0, 35 + "Edit": 0 36 + }, 37 + "total_tool_calls": 0, 38 + "total_steps": 1, 39 + "errors_encountered": 0, 40 + "output_chars": 1712, 41 + "transcript_chars": 0 42 + }, 43 + "timing": { 44 + "total_tokens": 0, 45 + "duration_ms": 0, 46 + "total_duration_seconds": 0.0 47 + }, 48 + "user_notes_summary": { 49 + "uncertainties": [ 50 + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs." 51 + ], 52 + "needs_review": [], 53 + "workarounds": [ 54 + "Generated with-skill outputs inline and graded assertions manually." 55 + ] 56 + } 57 + }
+49
.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/outputs/debugging.md
··· 1 + # Debugging the failed Spindle pipeline 2 + 3 + That `remote:` hint is Tangled's terminal log viewer for Spindle, the Tangled CI system. 4 + 5 + Run the exact command it printed: 6 + 7 + ```bash 8 + ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8 9 + ``` 10 + 11 + 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: 12 + 13 + ```bash 14 + timeout 180 ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8 15 + ``` 16 + 17 + ## What to look for in the logs 18 + 19 + 1. **Which workflow ran** 20 + - Match the failing run to `.tangled/workflows/check.yml` or `.tangled/workflows/build.yml`. 21 + - Confirm whether the trigger was `push`, `pull_request`, or `manual`. 22 + 23 + 2. **Where it failed** 24 + - Clone/setup failure 25 + - NixOS microVM activation / dependency realization 26 + - `devenv shell true` 27 + - Dagger check 28 + - Docker buildx production image build 29 + - Docker Compose smoke test 30 + 31 + 3. **If Docker fails** 32 + - Confirm the workflow has: 33 + ```yaml 34 + engine: microvm 35 + image: nixos 36 + virtualisation: 37 + docker: true 38 + ``` 39 + - Check whether the image was built with `--load` before Compose uses `--no-build`. 40 + 41 + 4. **If a tool is missing** 42 + - In this repo, tools should normally come from `devenv shell ...`. 43 + - If the missing tool is needed before `devenv` is available, add it to Spindle `dependencies`. 44 + 45 + 5. **If setup is just slow** 46 + - Look for Nix/devenv cache misses. 47 + - Prefer fixing Cachix/Nix caching rather than rewriting the Spindle workflow. 48 + 49 + After you capture the failing step and error text, paste that log excerpt back and we can make the smallest workflow or project fix.
+13
.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/outputs/metrics.json
··· 1 + { 2 + "tool_calls": { 3 + "Read": 0, 4 + "Write": 0, 5 + "Bash": 0, 6 + "Edit": 0 7 + }, 8 + "total_tool_calls": 0, 9 + "total_steps": 1, 10 + "errors_encountered": 0, 11 + "output_chars": 1712, 12 + "transcript_chars": 0 13 + }
+6
.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/timing.json
··· 1 + { 2 + "total_tokens": 0, 3 + "duration_ms": 0, 4 + "total_duration_seconds": 0.0, 5 + "note": "Manual run; no subagent timing available because claude CLI auth failed." 6 + }
+13
.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/eval_metadata.json
··· 1 + { 2 + "eval_id": 2, 3 + "eval_name": "create-rust-postgres-workflow", 4 + "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.", 5 + "assertions": [ 6 + "The output provides a Tangled workflow YAML under .tangled/workflows for a Rust service.", 7 + "The YAML uses engine: microvm and image: nixos.", 8 + "The YAML triggers on pushes to main and pull requests targeting main.", 9 + "The YAML includes Rust/OpenSSL-related dependencies such as cargo, rustc, pkg-config, and openssl.", 10 + "The YAML configures services.postgresql with the spindle-workflow database/user peer-auth pattern.", 11 + "The output avoids putting secrets in the public workflow YAML." 12 + ] 13 + }
+67
.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/grading.json
··· 1 + { 2 + "expectations": [ 3 + { 4 + "text": "The output provides a Tangled workflow YAML under .tangled/workflows for a Rust service.", 5 + "passed": true, 6 + "evidence": "Manual grade: supported by the generated output for this run." 7 + }, 8 + { 9 + "text": "The YAML uses engine: microvm and image: nixos.", 10 + "passed": true, 11 + "evidence": "Manual grade: supported by the generated output for this run." 12 + }, 13 + { 14 + "text": "The YAML triggers on pushes to main and pull requests targeting main.", 15 + "passed": true, 16 + "evidence": "Manual grade: supported by the generated output for this run." 17 + }, 18 + { 19 + "text": "The YAML includes Rust/OpenSSL-related dependencies such as cargo, rustc, pkg-config, and openssl.", 20 + "passed": true, 21 + "evidence": "Manual grade: supported by the generated output for this run." 22 + }, 23 + { 24 + "text": "The YAML configures services.postgresql with the spindle-workflow database/user peer-auth pattern.", 25 + "passed": true, 26 + "evidence": "Manual grade: supported by the generated output for this run." 27 + }, 28 + { 29 + "text": "The output avoids putting secrets in the public workflow YAML.", 30 + "passed": true, 31 + "evidence": "Manual grade: supported by the generated output for this run." 32 + } 33 + ], 34 + "summary": { 35 + "passed": 6, 36 + "failed": 0, 37 + "total": 6, 38 + "pass_rate": 1.0 39 + }, 40 + "execution_metrics": { 41 + "tool_calls": { 42 + "Read": 0, 43 + "Write": 0, 44 + "Bash": 0, 45 + "Edit": 0 46 + }, 47 + "total_tool_calls": 0, 48 + "total_steps": 1, 49 + "errors_encountered": 0, 50 + "output_chars": 1542, 51 + "transcript_chars": 0 52 + }, 53 + "timing": { 54 + "total_tokens": 0, 55 + "duration_ms": 0, 56 + "total_duration_seconds": 0.0 57 + }, 58 + "user_notes_summary": { 59 + "uncertainties": [ 60 + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs." 61 + ], 62 + "needs_review": [], 63 + "workarounds": [ 64 + "Generated with-skill outputs inline and graded assertions manually." 65 + ] 66 + } 67 + }
+13
.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/metrics.json
··· 1 + { 2 + "tool_calls": { 3 + "Read": 0, 4 + "Write": 0, 5 + "Bash": 0, 6 + "Edit": 0 7 + }, 8 + "total_tool_calls": 0, 9 + "total_steps": 1, 10 + "errors_encountered": 0, 11 + "output_chars": 1542, 12 + "transcript_chars": 0 13 + }
+7
.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/notes.md
··· 1 + # Notes 2 + 3 + Save the YAML as `.tangled/workflows/rust.yml` if this repository has a Rust service to test. 4 + 5 + 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. 6 + 7 + 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.
+52
.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/rust-postgres.yml
··· 1 + # .tangled/workflows/rust.yml 2 + when: 3 + - event: ["push"] 4 + branch: ["main"] 5 + - event: ["pull_request"] 6 + branch: ["main"] 7 + 8 + engine: microvm 9 + image: nixos 10 + 11 + clone: 12 + skip: false 13 + depth: 1 14 + 15 + # Public, non-secret configuration only. Put secrets in Tangled repository 16 + # Settings → Secrets and read them from commands when needed. 17 + environment: 18 + DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql" 19 + 20 + # Flat dependency list for the NixOS microVM image. 21 + dependencies: 22 + - gcc 23 + - cargo 24 + - rustc 25 + - clippy 26 + - rustfmt 27 + - pkg-config 28 + - openssl 29 + 30 + services: 31 + postgresql: 32 + enable: true 33 + ensureDatabases: ["spindle-workflow"] 34 + ensureUsers: 35 + - name: spindle-workflow 36 + ensureDBOwnership: true 37 + 38 + steps: 39 + - name: "Check formatting" 40 + command: | 41 + set -euo pipefail 42 + cargo fmt --check 43 + 44 + - name: "Clippy" 45 + command: | 46 + set -euo pipefail 47 + cargo clippy --all-targets -- -D warnings 48 + 49 + - name: "Test" 50 + command: | 51 + set -euo pipefail 52 + cargo test --all
+6
.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/timing.json
··· 1 + { 2 + "total_tokens": 0, 3 + "duration_ms": 0, 4 + "total_duration_seconds": 0.0, 5 + "note": "Manual run; no subagent timing available because claude CLI auth failed." 6 + }
+1325
.agents/skills/spindle-workspace/iteration-1/review.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Eval Review</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com"> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 + <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@500;600&family=Lora:wght@400;500&display=swap" rel="stylesheet"> 10 + <script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js" integrity="sha384-EnyY0/GSHQGSxSgMwaIPzSESbqoOLSexfnSMN2AP+39Ckmn92stwABZynq1JyzdT" crossorigin="anonymous"></script> 11 + <style> 12 + :root { 13 + --bg: #faf9f5; 14 + --surface: #ffffff; 15 + --border: #e8e6dc; 16 + --text: #141413; 17 + --text-muted: #b0aea5; 18 + --accent: #d97757; 19 + --accent-hover: #c4613f; 20 + --green: #788c5d; 21 + --green-bg: #eef2e8; 22 + --red: #c44; 23 + --red-bg: #fceaea; 24 + --header-bg: #141413; 25 + --header-text: #faf9f5; 26 + --radius: 6px; 27 + } 28 + 29 + * { box-sizing: border-box; margin: 0; padding: 0; } 30 + 31 + body { 32 + font-family: 'Lora', Georgia, serif; 33 + background: var(--bg); 34 + color: var(--text); 35 + height: 100vh; 36 + display: flex; 37 + flex-direction: column; 38 + } 39 + 40 + /* ---- Header ---- */ 41 + .header { 42 + background: var(--header-bg); 43 + color: var(--header-text); 44 + padding: 1rem 2rem; 45 + display: flex; 46 + justify-content: space-between; 47 + align-items: center; 48 + flex-shrink: 0; 49 + } 50 + .header h1 { 51 + font-family: 'Poppins', sans-serif; 52 + font-size: 1.25rem; 53 + font-weight: 600; 54 + } 55 + .header .instructions { 56 + font-size: 0.8rem; 57 + opacity: 0.7; 58 + margin-top: 0.25rem; 59 + } 60 + .header .progress { 61 + font-size: 0.875rem; 62 + opacity: 0.8; 63 + text-align: right; 64 + } 65 + 66 + /* ---- Main content ---- */ 67 + .main { 68 + flex: 1; 69 + overflow-y: auto; 70 + padding: 1.5rem 2rem; 71 + display: flex; 72 + flex-direction: column; 73 + gap: 1.25rem; 74 + } 75 + 76 + /* ---- Sections ---- */ 77 + .section { 78 + background: var(--surface); 79 + border: 1px solid var(--border); 80 + border-radius: var(--radius); 81 + flex-shrink: 0; 82 + } 83 + .section-header { 84 + font-family: 'Poppins', sans-serif; 85 + padding: 0.75rem 1rem; 86 + font-size: 0.75rem; 87 + font-weight: 500; 88 + text-transform: uppercase; 89 + letter-spacing: 0.05em; 90 + color: var(--text-muted); 91 + border-bottom: 1px solid var(--border); 92 + background: var(--bg); 93 + } 94 + .section-body { 95 + padding: 1rem; 96 + } 97 + 98 + /* ---- Config badge ---- */ 99 + .config-badge { 100 + display: inline-block; 101 + padding: 0.2rem 0.625rem; 102 + border-radius: 9999px; 103 + font-family: 'Poppins', sans-serif; 104 + font-size: 0.6875rem; 105 + font-weight: 600; 106 + text-transform: uppercase; 107 + letter-spacing: 0.03em; 108 + margin-left: 0.75rem; 109 + vertical-align: middle; 110 + } 111 + .config-badge.config-primary { 112 + background: rgba(33, 150, 243, 0.12); 113 + color: #1976d2; 114 + } 115 + .config-badge.config-baseline { 116 + background: rgba(255, 193, 7, 0.15); 117 + color: #f57f17; 118 + } 119 + 120 + /* ---- Prompt ---- */ 121 + .prompt-text { 122 + white-space: pre-wrap; 123 + font-size: 0.9375rem; 124 + line-height: 1.6; 125 + } 126 + 127 + /* ---- Outputs ---- */ 128 + .output-file { 129 + border: 1px solid var(--border); 130 + border-radius: var(--radius); 131 + overflow: hidden; 132 + } 133 + .output-file + .output-file { 134 + margin-top: 1rem; 135 + } 136 + .output-file-header { 137 + padding: 0.5rem 0.75rem; 138 + font-size: 0.8rem; 139 + font-weight: 600; 140 + color: var(--text-muted); 141 + background: var(--bg); 142 + border-bottom: 1px solid var(--border); 143 + font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; 144 + display: flex; 145 + justify-content: space-between; 146 + align-items: center; 147 + } 148 + .output-file-header .dl-btn { 149 + font-size: 0.7rem; 150 + color: var(--accent); 151 + text-decoration: none; 152 + cursor: pointer; 153 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 154 + font-weight: 500; 155 + opacity: 0.8; 156 + } 157 + .output-file-header .dl-btn:hover { 158 + opacity: 1; 159 + text-decoration: underline; 160 + } 161 + .output-file-content { 162 + padding: 0.75rem; 163 + overflow-x: auto; 164 + } 165 + .output-file-content pre { 166 + font-size: 0.8125rem; 167 + line-height: 1.5; 168 + white-space: pre-wrap; 169 + word-break: break-word; 170 + font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; 171 + } 172 + .output-file-content img { 173 + max-width: 100%; 174 + height: auto; 175 + border-radius: 4px; 176 + } 177 + .output-file-content iframe { 178 + width: 100%; 179 + height: 600px; 180 + border: none; 181 + } 182 + .output-file-content table { 183 + border-collapse: collapse; 184 + font-size: 0.8125rem; 185 + width: 100%; 186 + } 187 + .output-file-content table td, 188 + .output-file-content table th { 189 + border: 1px solid var(--border); 190 + padding: 0.375rem 0.5rem; 191 + text-align: left; 192 + } 193 + .output-file-content table th { 194 + background: var(--bg); 195 + font-weight: 600; 196 + } 197 + .output-file-content .download-link { 198 + display: inline-flex; 199 + align-items: center; 200 + gap: 0.5rem; 201 + padding: 0.5rem 1rem; 202 + background: var(--bg); 203 + border: 1px solid var(--border); 204 + border-radius: 4px; 205 + color: var(--accent); 206 + text-decoration: none; 207 + font-size: 0.875rem; 208 + cursor: pointer; 209 + } 210 + .output-file-content .download-link:hover { 211 + background: var(--border); 212 + } 213 + .empty-state { 214 + color: var(--text-muted); 215 + font-style: italic; 216 + padding: 2rem; 217 + text-align: center; 218 + } 219 + 220 + /* ---- Feedback ---- */ 221 + .prev-feedback { 222 + background: var(--bg); 223 + border: 1px solid var(--border); 224 + border-radius: 4px; 225 + padding: 0.625rem 0.75rem; 226 + margin-top: 0.75rem; 227 + font-size: 0.8125rem; 228 + color: var(--text-muted); 229 + line-height: 1.5; 230 + } 231 + .prev-feedback-label { 232 + font-size: 0.7rem; 233 + font-weight: 600; 234 + text-transform: uppercase; 235 + letter-spacing: 0.04em; 236 + margin-bottom: 0.25rem; 237 + color: var(--text-muted); 238 + } 239 + .feedback-textarea { 240 + width: 100%; 241 + min-height: 100px; 242 + padding: 0.75rem; 243 + border: 1px solid var(--border); 244 + border-radius: 4px; 245 + font-family: inherit; 246 + font-size: 0.9375rem; 247 + line-height: 1.5; 248 + resize: vertical; 249 + color: var(--text); 250 + } 251 + .feedback-textarea:focus { 252 + outline: none; 253 + border-color: var(--accent); 254 + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); 255 + } 256 + .feedback-status { 257 + font-size: 0.75rem; 258 + color: var(--text-muted); 259 + margin-top: 0.5rem; 260 + min-height: 1.1em; 261 + } 262 + 263 + /* ---- Grades (collapsible) ---- */ 264 + .grades-toggle { 265 + display: flex; 266 + align-items: center; 267 + cursor: pointer; 268 + user-select: none; 269 + } 270 + .grades-toggle:hover { 271 + color: var(--accent); 272 + } 273 + .grades-toggle .arrow { 274 + margin-right: 0.5rem; 275 + transition: transform 0.15s; 276 + font-size: 0.75rem; 277 + } 278 + .grades-toggle .arrow.open { 279 + transform: rotate(90deg); 280 + } 281 + .grades-content { 282 + display: none; 283 + margin-top: 0.75rem; 284 + } 285 + .grades-content.open { 286 + display: block; 287 + } 288 + .grades-summary { 289 + font-size: 0.875rem; 290 + margin-bottom: 0.75rem; 291 + display: flex; 292 + align-items: center; 293 + gap: 0.5rem; 294 + } 295 + .grade-badge { 296 + display: inline-block; 297 + padding: 0.125rem 0.5rem; 298 + border-radius: 9999px; 299 + font-size: 0.75rem; 300 + font-weight: 600; 301 + } 302 + .grade-pass { background: var(--green-bg); color: var(--green); } 303 + .grade-fail { background: var(--red-bg); color: var(--red); } 304 + .assertion-list { 305 + list-style: none; 306 + } 307 + .assertion-item { 308 + padding: 0.625rem 0; 309 + border-bottom: 1px solid var(--border); 310 + font-size: 0.8125rem; 311 + } 312 + .assertion-item:last-child { border-bottom: none; } 313 + .assertion-status { 314 + font-weight: 600; 315 + margin-right: 0.5rem; 316 + } 317 + .assertion-status.pass { color: var(--green); } 318 + .assertion-status.fail { color: var(--red); } 319 + .assertion-evidence { 320 + color: var(--text-muted); 321 + font-size: 0.75rem; 322 + margin-top: 0.25rem; 323 + padding-left: 1.5rem; 324 + } 325 + 326 + /* ---- View tabs ---- */ 327 + .view-tabs { 328 + display: flex; 329 + gap: 0; 330 + padding: 0 2rem; 331 + background: var(--bg); 332 + border-bottom: 1px solid var(--border); 333 + flex-shrink: 0; 334 + } 335 + .view-tab { 336 + font-family: 'Poppins', sans-serif; 337 + padding: 0.625rem 1.25rem; 338 + font-size: 0.8125rem; 339 + font-weight: 500; 340 + cursor: pointer; 341 + border: none; 342 + background: none; 343 + color: var(--text-muted); 344 + border-bottom: 2px solid transparent; 345 + transition: all 0.15s; 346 + } 347 + .view-tab:hover { color: var(--text); } 348 + .view-tab.active { 349 + color: var(--accent); 350 + border-bottom-color: var(--accent); 351 + } 352 + .view-panel { display: none; } 353 + .view-panel.active { display: flex; flex-direction: column; flex: 1; overflow: hidden; } 354 + 355 + /* ---- Benchmark view ---- */ 356 + .benchmark-view { 357 + padding: 1.5rem 2rem; 358 + overflow-y: auto; 359 + flex: 1; 360 + } 361 + .benchmark-table { 362 + border-collapse: collapse; 363 + background: var(--surface); 364 + border: 1px solid var(--border); 365 + border-radius: var(--radius); 366 + font-size: 0.8125rem; 367 + width: 100%; 368 + margin-bottom: 1.5rem; 369 + } 370 + .benchmark-table th, .benchmark-table td { 371 + padding: 0.625rem 0.75rem; 372 + text-align: left; 373 + border: 1px solid var(--border); 374 + } 375 + .benchmark-table th { 376 + font-family: 'Poppins', sans-serif; 377 + background: var(--header-bg); 378 + color: var(--header-text); 379 + font-weight: 500; 380 + font-size: 0.75rem; 381 + text-transform: uppercase; 382 + letter-spacing: 0.04em; 383 + } 384 + .benchmark-table tr:hover { background: var(--bg); } 385 + .benchmark-table tr.benchmark-row-with { background: rgba(33, 150, 243, 0.06); } 386 + .benchmark-table tr.benchmark-row-without { background: rgba(255, 193, 7, 0.06); } 387 + .benchmark-table tr.benchmark-row-with:hover { background: rgba(33, 150, 243, 0.12); } 388 + .benchmark-table tr.benchmark-row-without:hover { background: rgba(255, 193, 7, 0.12); } 389 + .benchmark-table tr.benchmark-row-avg { font-weight: 600; border-top: 2px solid var(--border); } 390 + .benchmark-table tr.benchmark-row-avg.benchmark-row-with { background: rgba(33, 150, 243, 0.12); } 391 + .benchmark-table tr.benchmark-row-avg.benchmark-row-without { background: rgba(255, 193, 7, 0.12); } 392 + .benchmark-delta-positive { color: var(--green); font-weight: 600; } 393 + .benchmark-delta-negative { color: var(--red); font-weight: 600; } 394 + .benchmark-notes { 395 + background: var(--surface); 396 + border: 1px solid var(--border); 397 + border-radius: var(--radius); 398 + padding: 1rem; 399 + } 400 + .benchmark-notes h3 { 401 + font-family: 'Poppins', sans-serif; 402 + font-size: 0.875rem; 403 + margin-bottom: 0.75rem; 404 + } 405 + .benchmark-notes ul { 406 + list-style: disc; 407 + padding-left: 1.25rem; 408 + } 409 + .benchmark-notes li { 410 + font-size: 0.8125rem; 411 + line-height: 1.6; 412 + margin-bottom: 0.375rem; 413 + } 414 + .benchmark-empty { 415 + color: var(--text-muted); 416 + font-style: italic; 417 + text-align: center; 418 + padding: 3rem; 419 + } 420 + 421 + /* ---- Navigation ---- */ 422 + .nav { 423 + display: flex; 424 + justify-content: space-between; 425 + align-items: center; 426 + padding: 1rem 2rem; 427 + border-top: 1px solid var(--border); 428 + background: var(--surface); 429 + flex-shrink: 0; 430 + } 431 + .nav-btn { 432 + font-family: 'Poppins', sans-serif; 433 + padding: 0.5rem 1.25rem; 434 + border: 1px solid var(--border); 435 + border-radius: var(--radius); 436 + background: var(--surface); 437 + cursor: pointer; 438 + font-size: 0.875rem; 439 + font-weight: 500; 440 + color: var(--text); 441 + transition: all 0.15s; 442 + } 443 + .nav-btn:hover:not(:disabled) { 444 + background: var(--bg); 445 + border-color: var(--text-muted); 446 + } 447 + .nav-btn:disabled { 448 + opacity: 0.4; 449 + cursor: not-allowed; 450 + } 451 + .done-btn { 452 + font-family: 'Poppins', sans-serif; 453 + padding: 0.5rem 1.5rem; 454 + border: 1px solid var(--border); 455 + border-radius: var(--radius); 456 + background: var(--surface); 457 + color: var(--text); 458 + cursor: pointer; 459 + font-size: 0.875rem; 460 + font-weight: 500; 461 + transition: all 0.15s; 462 + } 463 + .done-btn:hover { 464 + background: var(--bg); 465 + border-color: var(--text-muted); 466 + } 467 + .done-btn.ready { 468 + border: none; 469 + background: var(--accent); 470 + color: white; 471 + font-weight: 600; 472 + } 473 + .done-btn.ready:hover { 474 + background: var(--accent-hover); 475 + } 476 + /* ---- Done overlay ---- */ 477 + .done-overlay { 478 + display: none; 479 + position: fixed; 480 + inset: 0; 481 + background: rgba(0, 0, 0, 0.5); 482 + z-index: 100; 483 + justify-content: center; 484 + align-items: center; 485 + } 486 + .done-overlay.visible { 487 + display: flex; 488 + } 489 + .done-card { 490 + background: var(--surface); 491 + border-radius: 12px; 492 + padding: 2rem 3rem; 493 + text-align: center; 494 + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); 495 + max-width: 500px; 496 + } 497 + .done-card h2 { 498 + font-size: 1.5rem; 499 + margin-bottom: 0.5rem; 500 + } 501 + .done-card p { 502 + color: var(--text-muted); 503 + margin-bottom: 1.5rem; 504 + line-height: 1.5; 505 + } 506 + .done-card .btn-row { 507 + display: flex; 508 + gap: 0.5rem; 509 + justify-content: center; 510 + } 511 + .done-card button { 512 + padding: 0.5rem 1.25rem; 513 + border: 1px solid var(--border); 514 + border-radius: var(--radius); 515 + background: var(--surface); 516 + cursor: pointer; 517 + font-size: 0.875rem; 518 + } 519 + .done-card button:hover { 520 + background: var(--bg); 521 + } 522 + /* ---- Toast ---- */ 523 + .toast { 524 + position: fixed; 525 + bottom: 5rem; 526 + left: 50%; 527 + transform: translateX(-50%); 528 + background: var(--header-bg); 529 + color: var(--header-text); 530 + padding: 0.625rem 1.25rem; 531 + border-radius: var(--radius); 532 + font-size: 0.875rem; 533 + opacity: 0; 534 + transition: opacity 0.3s; 535 + pointer-events: none; 536 + z-index: 200; 537 + } 538 + .toast.visible { 539 + opacity: 1; 540 + } 541 + </style> 542 + </head> 543 + <body> 544 + <div id="app" style="height:100vh; display:flex; flex-direction:column;"> 545 + <div class="header"> 546 + <div> 547 + <h1>Eval Review: <span id="skill-name"></span></h1> 548 + <div class="instructions">Review each output and leave feedback below. Navigate with arrow keys or buttons. When done, copy feedback and paste into Claude Code.</div> 549 + </div> 550 + <div class="progress" id="progress"></div> 551 + </div> 552 + 553 + <!-- View tabs (only shown when benchmark data exists) --> 554 + <div class="view-tabs" id="view-tabs" style="display:none;"> 555 + <button class="view-tab active" onclick="switchView('outputs')">Outputs</button> 556 + <button class="view-tab" onclick="switchView('benchmark')">Benchmark</button> 557 + </div> 558 + 559 + <!-- Outputs panel (qualitative review) --> 560 + <div class="view-panel active" id="panel-outputs"> 561 + <div class="main"> 562 + <!-- Prompt --> 563 + <div class="section"> 564 + <div class="section-header">Prompt <span class="config-badge" id="config-badge" style="display:none;"></span></div> 565 + <div class="section-body"> 566 + <div class="prompt-text" id="prompt-text"></div> 567 + </div> 568 + </div> 569 + 570 + <!-- Outputs --> 571 + <div class="section"> 572 + <div class="section-header">Output</div> 573 + <div class="section-body" id="outputs-body"> 574 + <div class="empty-state">No output files found</div> 575 + </div> 576 + </div> 577 + 578 + <!-- Previous Output (collapsible) --> 579 + <div class="section" id="prev-outputs-section" style="display:none;"> 580 + <div class="section-header"> 581 + <div class="grades-toggle" onclick="togglePrevOutputs()"> 582 + <span class="arrow" id="prev-outputs-arrow">&#9654;</span> 583 + Previous Output 584 + </div> 585 + </div> 586 + <div class="grades-content" id="prev-outputs-content"></div> 587 + </div> 588 + 589 + <!-- Grades (collapsible) --> 590 + <div class="section" id="grades-section" style="display:none;"> 591 + <div class="section-header"> 592 + <div class="grades-toggle" onclick="toggleGrades()"> 593 + <span class="arrow" id="grades-arrow">&#9654;</span> 594 + Formal Grades 595 + </div> 596 + </div> 597 + <div class="grades-content" id="grades-content"></div> 598 + </div> 599 + 600 + <!-- Feedback --> 601 + <div class="section"> 602 + <div class="section-header">Your Feedback</div> 603 + <div class="section-body"> 604 + <textarea 605 + class="feedback-textarea" 606 + id="feedback" 607 + placeholder="What do you think of this output? Any issues, suggestions, or things that look great?" 608 + ></textarea> 609 + <div class="feedback-status" id="feedback-status"></div> 610 + <div class="prev-feedback" id="prev-feedback" style="display:none;"> 611 + <div class="prev-feedback-label">Previous feedback</div> 612 + <div id="prev-feedback-text"></div> 613 + </div> 614 + </div> 615 + </div> 616 + </div> 617 + 618 + <div class="nav" id="outputs-nav"> 619 + <button class="nav-btn" id="prev-btn" onclick="navigate(-1)">&#8592; Previous</button> 620 + <button class="done-btn" id="done-btn" onclick="showDoneDialog()">Submit All Reviews</button> 621 + <button class="nav-btn" id="next-btn" onclick="navigate(1)">Next &#8594;</button> 622 + </div> 623 + </div><!-- end panel-outputs --> 624 + 625 + <!-- Benchmark panel (quantitative stats) --> 626 + <div class="view-panel" id="panel-benchmark"> 627 + <div class="benchmark-view" id="benchmark-content"> 628 + <div class="benchmark-empty">No benchmark data available. Run a benchmark to see quantitative results here.</div> 629 + </div> 630 + </div> 631 + </div> 632 + 633 + <!-- Done overlay --> 634 + <div class="done-overlay" id="done-overlay"> 635 + <div class="done-card"> 636 + <h2>Review Complete</h2> 637 + <p>Your feedback has been saved. Go back to your Claude Code session and tell Claude you're done reviewing.</p> 638 + <div class="btn-row"> 639 + <button onclick="closeDoneDialog()">OK</button> 640 + </div> 641 + </div> 642 + </div> 643 + 644 + <!-- Toast --> 645 + <div class="toast" id="toast"></div> 646 + 647 + <script> 648 + // ---- Embedded data (injected by generate_review.py) ---- 649 + const EMBEDDED_DATA = {"skill_name": "spindle", "runs": [{"id": "eval-0-review-existing-workflows-with_skill-run-1", "prompt": "(No prompt found)", "eval_id": null, "outputs": [{"name": "review.md", "type": "text", "content": "# Review: current Spindle workflows\n\nTangled CI is Spindle, and this repository already has two Spindle workflow files under `.tangled/workflows/`:\n\n- `.tangled/workflows/check.yml` \u2014 runs Dagger checks.\n- `.tangled/workflows/build.yml` \u2014 builds the production Docker image and runs Docker Compose smoke tests.\n\n## What looks right\n\nBoth workflows use the expected Spindle shape:\n\n```yaml\nwhen:\n - event: [\"push\", \"manual\"]\n branch: [\"main\"]\n - event: [\"pull_request\"]\n branch: [\"main\"]\nengine: microvm\nimage: nixos\nclone:\n skip: false\n depth: 1\ndependencies:\n - devenv\n```\n\nThat matches the repo goal: run on pushes to `main`, manual runs, and PRs targeting `main`, using a NixOS microVM with `devenv` available.\n\nFor `.tangled/workflows/build.yml`, this is also correct for Docker-based smoke tests:\n\n```yaml\nvirtualisation:\n docker: true\n```\n\nThat enables a real Docker daemon inside the microVM, which is what the `docker-buildx` and `docker-compose` steps need.\n\n## Minimal fixes / checks I would consider\n\n1. **Keep `virtualisation.docker: true` in `build.yml`.** It is required for the production image build and Compose smoke test.\n\n2. **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.\n\n3. **Consider adding `set -euo pipefail` to `check.yml`.** The current command is short:\n\n ```yaml\n command: DAGGER_NO_NAG=1 devenv shell dagger check\n ```\n\n It is probably fine as-is, but if it grows into a multi-line script later, use:\n\n ```yaml\n command: |\n set -euo pipefail\n DAGGER_NO_NAG=1 devenv shell dagger check\n ```\n\n4. **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 \u201cimage not found\u201d, this tag/reference is the first thing to check.\n\n5. **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.\n\n## Verdict\n\nThe 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`.\n"}], "grading": {"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."]}}}, {"id": "eval-1-debug-push-log-command-with_skill-run-1", "prompt": "(No prompt found)", "eval_id": null, "outputs": [{"name": "debugging.md", "type": "text", "content": "# Debugging the failed Spindle pipeline\n\nThat `remote:` hint is Tangled's terminal log viewer for Spindle, the Tangled CI system.\n\nRun the exact command it printed:\n\n```bash\nssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8\n```\n\nIf you are running it from a script or another non-interactive tool, wrap it with a timeout so it does not follow logs forever:\n\n```bash\ntimeout 180 ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8\n```\n\n## What to look for in the logs\n\n1. **Which workflow ran**\n - Match the failing run to `.tangled/workflows/check.yml` or `.tangled/workflows/build.yml`.\n - Confirm whether the trigger was `push`, `pull_request`, or `manual`.\n\n2. **Where it failed**\n - Clone/setup failure\n - NixOS microVM activation / dependency realization\n - `devenv shell true`\n - Dagger check\n - Docker buildx production image build\n - Docker Compose smoke test\n\n3. **If Docker fails**\n - Confirm the workflow has:\n ```yaml\n engine: microvm\n image: nixos\n virtualisation:\n docker: true\n ```\n - Check whether the image was built with `--load` before Compose uses `--no-build`.\n\n4. **If a tool is missing**\n - In this repo, tools should normally come from `devenv shell ...`.\n - If the missing tool is needed before `devenv` is available, add it to Spindle `dependencies`.\n\n5. **If setup is just slow**\n - Look for Nix/devenv cache misses.\n - Prefer fixing Cachix/Nix caching rather than rewriting the Spindle workflow.\n\nAfter you capture the failing step and error text, paste that log excerpt back and we can make the smallest workflow or project fix.\n"}], "grading": {"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."]}}}, {"id": "eval-2-create-rust-postgres-workflow-with_skill-run-1", "prompt": "(No prompt found)", "eval_id": null, "outputs": [{"name": "notes.md", "type": "text", "content": "# Notes\n\nSave the YAML as `.tangled/workflows/rust.yml` if this repository has a Rust service to test.\n\nSecrets are intentionally not included in the workflow file. Add secrets through Tangled repository Settings \u2192 Secrets, then reference those environment variables from step commands if needed.\n\nThe 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.\n"}, {"name": "rust-postgres.yml", "type": "text", "content": "# .tangled/workflows/rust.yml\nwhen:\n - event: [\"push\"]\n branch: [\"main\"]\n - event: [\"pull_request\"]\n branch: [\"main\"]\n\nengine: microvm\nimage: nixos\n\nclone:\n skip: false\n depth: 1\n\n# Public, non-secret configuration only. Put secrets in Tangled repository\n# Settings \u2192 Secrets and read them from commands when needed.\nenvironment:\n DATABASE_URL: \"postgresql:///spindle-workflow?host=/run/postgresql\"\n\n# Flat dependency list for the NixOS microVM image.\ndependencies:\n - gcc\n - cargo\n - rustc\n - clippy\n - rustfmt\n - pkg-config\n - openssl\n\nservices:\n postgresql:\n enable: true\n ensureDatabases: [\"spindle-workflow\"]\n ensureUsers:\n - name: spindle-workflow\n ensureDBOwnership: true\n\nsteps:\n - name: \"Check formatting\"\n command: |\n set -euo pipefail\n cargo fmt --check\n\n - name: \"Clippy\"\n command: |\n set -euo pipefail\n cargo clippy --all-targets -- -D warnings\n\n - name: \"Test\"\n command: |\n set -euo pipefail\n cargo test --all\n"}], "grading": {"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."]}}}], "previous_feedback": {}, "previous_outputs": {}, "benchmark": {"metadata": {"skill_name": "spindle", "skill_path": "<path/to/skill>", "executor_model": "<model-name>", "analyzer_model": "<model-name>", "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": []}}; 650 + 651 + // ---- State ---- 652 + let feedbackMap = {}; // run_id -> feedback text 653 + let currentIndex = 0; 654 + let visitedRuns = new Set(); 655 + 656 + // ---- Init ---- 657 + async function init() { 658 + // Load saved feedback from server — but only if this isn't a fresh 659 + // iteration (indicated by previous_feedback being present). When 660 + // previous feedback exists, the feedback.json on disk is stale from 661 + // the prior iteration and should not pre-fill the textareas. 662 + const hasPrevious = Object.keys(EMBEDDED_DATA.previous_feedback || {}).length > 0 663 + || Object.keys(EMBEDDED_DATA.previous_outputs || {}).length > 0; 664 + if (!hasPrevious) { 665 + try { 666 + const resp = await fetch("/api/feedback"); 667 + const data = await resp.json(); 668 + if (data.reviews) { 669 + for (const r of data.reviews) feedbackMap[r.run_id] = r.feedback; 670 + } 671 + } catch { /* first run, no feedback yet */ } 672 + } 673 + 674 + document.getElementById("skill-name").textContent = EMBEDDED_DATA.skill_name; 675 + showRun(0); 676 + 677 + // Wire up feedback auto-save 678 + const textarea = document.getElementById("feedback"); 679 + let saveTimeout = null; 680 + textarea.addEventListener("input", () => { 681 + clearTimeout(saveTimeout); 682 + document.getElementById("feedback-status").textContent = ""; 683 + saveTimeout = setTimeout(() => saveCurrentFeedback(), 800); 684 + }); 685 + } 686 + 687 + // ---- Navigation ---- 688 + function navigate(delta) { 689 + const newIndex = currentIndex + delta; 690 + if (newIndex >= 0 && newIndex < EMBEDDED_DATA.runs.length) { 691 + saveCurrentFeedback(); 692 + showRun(newIndex); 693 + } 694 + } 695 + 696 + function updateNavButtons() { 697 + document.getElementById("prev-btn").disabled = currentIndex === 0; 698 + document.getElementById("next-btn").disabled = 699 + currentIndex === EMBEDDED_DATA.runs.length - 1; 700 + } 701 + 702 + // ---- Show a run ---- 703 + function showRun(index) { 704 + currentIndex = index; 705 + const run = EMBEDDED_DATA.runs[index]; 706 + 707 + // Progress 708 + document.getElementById("progress").textContent = 709 + `${index + 1} of ${EMBEDDED_DATA.runs.length}`; 710 + 711 + // Prompt 712 + document.getElementById("prompt-text").textContent = run.prompt; 713 + 714 + // Config badge 715 + const badge = document.getElementById("config-badge"); 716 + const configMatch = run.id.match(/(with_skill|without_skill|new_skill|old_skill)/); 717 + if (configMatch) { 718 + const config = configMatch[1]; 719 + const isBaseline = config === "without_skill" || config === "old_skill"; 720 + badge.textContent = config.replace(/_/g, " "); 721 + badge.className = "config-badge " + (isBaseline ? "config-baseline" : "config-primary"); 722 + badge.style.display = "inline-block"; 723 + } else { 724 + badge.style.display = "none"; 725 + } 726 + 727 + // Outputs 728 + renderOutputs(run); 729 + 730 + // Previous outputs 731 + renderPrevOutputs(run); 732 + 733 + // Grades 734 + renderGrades(run); 735 + 736 + // Previous feedback 737 + const prevFb = (EMBEDDED_DATA.previous_feedback || {})[run.id]; 738 + const prevEl = document.getElementById("prev-feedback"); 739 + if (prevFb) { 740 + document.getElementById("prev-feedback-text").textContent = prevFb; 741 + prevEl.style.display = "block"; 742 + } else { 743 + prevEl.style.display = "none"; 744 + } 745 + 746 + // Feedback 747 + document.getElementById("feedback").value = feedbackMap[run.id] || ""; 748 + document.getElementById("feedback-status").textContent = ""; 749 + 750 + updateNavButtons(); 751 + 752 + // Track visited runs and promote done button when all visited 753 + visitedRuns.add(index); 754 + const doneBtn = document.getElementById("done-btn"); 755 + if (visitedRuns.size >= EMBEDDED_DATA.runs.length) { 756 + doneBtn.classList.add("ready"); 757 + } 758 + 759 + // Scroll main content to top 760 + document.querySelector(".main").scrollTop = 0; 761 + } 762 + 763 + // ---- Render outputs ---- 764 + function renderOutputs(run) { 765 + const container = document.getElementById("outputs-body"); 766 + container.innerHTML = ""; 767 + 768 + const outputs = run.outputs || []; 769 + if (outputs.length === 0) { 770 + container.innerHTML = '<div class="empty-state">No output files</div>'; 771 + return; 772 + } 773 + 774 + for (const file of outputs) { 775 + const fileDiv = document.createElement("div"); 776 + fileDiv.className = "output-file"; 777 + 778 + // Always show file header with download link 779 + const header = document.createElement("div"); 780 + header.className = "output-file-header"; 781 + const nameSpan = document.createElement("span"); 782 + nameSpan.textContent = file.name; 783 + header.appendChild(nameSpan); 784 + const dlBtn = document.createElement("a"); 785 + dlBtn.className = "dl-btn"; 786 + dlBtn.textContent = "Download"; 787 + dlBtn.download = file.name; 788 + dlBtn.href = getDownloadUri(file); 789 + header.appendChild(dlBtn); 790 + fileDiv.appendChild(header); 791 + 792 + const content = document.createElement("div"); 793 + content.className = "output-file-content"; 794 + 795 + if (file.type === "text") { 796 + const pre = document.createElement("pre"); 797 + pre.textContent = file.content; 798 + content.appendChild(pre); 799 + } else if (file.type === "image") { 800 + const img = document.createElement("img"); 801 + img.src = file.data_uri; 802 + img.alt = file.name; 803 + content.appendChild(img); 804 + } else if (file.type === "pdf") { 805 + const iframe = document.createElement("iframe"); 806 + iframe.src = file.data_uri; 807 + content.appendChild(iframe); 808 + } else if (file.type === "xlsx") { 809 + renderXlsx(content, file.data_b64); 810 + } else if (file.type === "binary") { 811 + const a = document.createElement("a"); 812 + a.className = "download-link"; 813 + a.href = file.data_uri; 814 + a.download = file.name; 815 + a.textContent = "Download " + file.name; 816 + content.appendChild(a); 817 + } else if (file.type === "error") { 818 + const pre = document.createElement("pre"); 819 + pre.textContent = file.content; 820 + pre.style.color = "var(--red)"; 821 + content.appendChild(pre); 822 + } 823 + 824 + fileDiv.appendChild(content); 825 + container.appendChild(fileDiv); 826 + } 827 + } 828 + 829 + // ---- XLSX rendering via SheetJS ---- 830 + function renderXlsx(container, b64Data) { 831 + try { 832 + const raw = Uint8Array.from(atob(b64Data), c => c.charCodeAt(0)); 833 + const wb = XLSX.read(raw, { type: "array" }); 834 + 835 + for (let i = 0; i < wb.SheetNames.length; i++) { 836 + const sheetName = wb.SheetNames[i]; 837 + const ws = wb.Sheets[sheetName]; 838 + 839 + if (wb.SheetNames.length > 1) { 840 + const sheetLabel = document.createElement("div"); 841 + sheetLabel.style.cssText = 842 + "font-weight:600; font-size:0.8rem; color:#b0aea5; margin-top:0.5rem; margin-bottom:0.25rem;"; 843 + sheetLabel.textContent = "Sheet: " + sheetName; 844 + container.appendChild(sheetLabel); 845 + } 846 + 847 + const htmlStr = XLSX.utils.sheet_to_html(ws, { editable: false }); 848 + const wrapper = document.createElement("div"); 849 + wrapper.innerHTML = htmlStr; 850 + container.appendChild(wrapper); 851 + } 852 + } catch (err) { 853 + container.textContent = "Error rendering spreadsheet: " + err.message; 854 + } 855 + } 856 + 857 + // ---- Grades ---- 858 + function renderGrades(run) { 859 + const section = document.getElementById("grades-section"); 860 + const content = document.getElementById("grades-content"); 861 + 862 + if (!run.grading) { 863 + section.style.display = "none"; 864 + return; 865 + } 866 + 867 + const grading = run.grading; 868 + section.style.display = "block"; 869 + // Reset to collapsed 870 + content.classList.remove("open"); 871 + document.getElementById("grades-arrow").classList.remove("open"); 872 + 873 + const summary = grading.summary || {}; 874 + const expectations = grading.expectations || []; 875 + 876 + let html = '<div style="padding: 1rem;">'; 877 + 878 + // Summary line 879 + const passRate = summary.pass_rate != null 880 + ? Math.round(summary.pass_rate * 100) + "%" 881 + : "?"; 882 + const badgeClass = summary.pass_rate >= 0.8 ? "grade-pass" : summary.pass_rate >= 0.5 ? "" : "grade-fail"; 883 + html += '<div class="grades-summary">'; 884 + html += '<span class="grade-badge ' + badgeClass + '">' + passRate + '</span>'; 885 + html += '<span>' + (summary.passed || 0) + ' passed, ' + (summary.failed || 0) + ' failed of ' + (summary.total || 0) + '</span>'; 886 + html += '</div>'; 887 + 888 + // Assertions list 889 + html += '<ul class="assertion-list">'; 890 + for (const exp of expectations) { 891 + const statusClass = exp.passed ? "pass" : "fail"; 892 + const statusIcon = exp.passed ? "\u2713" : "\u2717"; 893 + html += '<li class="assertion-item">'; 894 + html += '<span class="assertion-status ' + statusClass + '">' + statusIcon + '</span>'; 895 + html += '<span>' + escapeHtml(exp.text) + '</span>'; 896 + if (exp.evidence) { 897 + html += '<div class="assertion-evidence">' + escapeHtml(exp.evidence) + '</div>'; 898 + } 899 + html += '</li>'; 900 + } 901 + html += '</ul>'; 902 + 903 + html += '</div>'; 904 + content.innerHTML = html; 905 + } 906 + 907 + function toggleGrades() { 908 + const content = document.getElementById("grades-content"); 909 + const arrow = document.getElementById("grades-arrow"); 910 + content.classList.toggle("open"); 911 + arrow.classList.toggle("open"); 912 + } 913 + 914 + // ---- Previous outputs (collapsible) ---- 915 + function renderPrevOutputs(run) { 916 + const section = document.getElementById("prev-outputs-section"); 917 + const content = document.getElementById("prev-outputs-content"); 918 + const prevOutputs = (EMBEDDED_DATA.previous_outputs || {})[run.id]; 919 + 920 + if (!prevOutputs || prevOutputs.length === 0) { 921 + section.style.display = "none"; 922 + return; 923 + } 924 + 925 + section.style.display = "block"; 926 + // Reset to collapsed 927 + content.classList.remove("open"); 928 + document.getElementById("prev-outputs-arrow").classList.remove("open"); 929 + 930 + // Render the files into the content area 931 + content.innerHTML = ""; 932 + const wrapper = document.createElement("div"); 933 + wrapper.style.padding = "1rem"; 934 + 935 + for (const file of prevOutputs) { 936 + const fileDiv = document.createElement("div"); 937 + fileDiv.className = "output-file"; 938 + 939 + const header = document.createElement("div"); 940 + header.className = "output-file-header"; 941 + const nameSpan = document.createElement("span"); 942 + nameSpan.textContent = file.name; 943 + header.appendChild(nameSpan); 944 + const dlBtn = document.createElement("a"); 945 + dlBtn.className = "dl-btn"; 946 + dlBtn.textContent = "Download"; 947 + dlBtn.download = file.name; 948 + dlBtn.href = getDownloadUri(file); 949 + header.appendChild(dlBtn); 950 + fileDiv.appendChild(header); 951 + 952 + const fc = document.createElement("div"); 953 + fc.className = "output-file-content"; 954 + 955 + if (file.type === "text") { 956 + const pre = document.createElement("pre"); 957 + pre.textContent = file.content; 958 + fc.appendChild(pre); 959 + } else if (file.type === "image") { 960 + const img = document.createElement("img"); 961 + img.src = file.data_uri; 962 + img.alt = file.name; 963 + fc.appendChild(img); 964 + } else if (file.type === "pdf") { 965 + const iframe = document.createElement("iframe"); 966 + iframe.src = file.data_uri; 967 + fc.appendChild(iframe); 968 + } else if (file.type === "xlsx") { 969 + renderXlsx(fc, file.data_b64); 970 + } else if (file.type === "binary") { 971 + const a = document.createElement("a"); 972 + a.className = "download-link"; 973 + a.href = file.data_uri; 974 + a.download = file.name; 975 + a.textContent = "Download " + file.name; 976 + fc.appendChild(a); 977 + } 978 + 979 + fileDiv.appendChild(fc); 980 + wrapper.appendChild(fileDiv); 981 + } 982 + 983 + content.appendChild(wrapper); 984 + } 985 + 986 + function togglePrevOutputs() { 987 + const content = document.getElementById("prev-outputs-content"); 988 + const arrow = document.getElementById("prev-outputs-arrow"); 989 + content.classList.toggle("open"); 990 + arrow.classList.toggle("open"); 991 + } 992 + 993 + // ---- Feedback (saved to server -> feedback.json) ---- 994 + function saveCurrentFeedback() { 995 + const run = EMBEDDED_DATA.runs[currentIndex]; 996 + const text = document.getElementById("feedback").value; 997 + 998 + if (text.trim() === "") { 999 + delete feedbackMap[run.id]; 1000 + } else { 1001 + feedbackMap[run.id] = text; 1002 + } 1003 + 1004 + // Build reviews array from map 1005 + const reviews = []; 1006 + for (const [run_id, feedback] of Object.entries(feedbackMap)) { 1007 + if (feedback.trim()) { 1008 + reviews.push({ run_id, feedback, timestamp: new Date().toISOString() }); 1009 + } 1010 + } 1011 + 1012 + fetch("/api/feedback", { 1013 + method: "POST", 1014 + headers: { "Content-Type": "application/json" }, 1015 + body: JSON.stringify({ reviews, status: "in_progress" }), 1016 + }).then(() => { 1017 + document.getElementById("feedback-status").textContent = "Saved"; 1018 + }).catch(() => { 1019 + // Static mode or server unavailable — no-op on auto-save, 1020 + // feedback will be downloaded on final submit 1021 + document.getElementById("feedback-status").textContent = "Will download on submit"; 1022 + }); 1023 + } 1024 + 1025 + // ---- Done ---- 1026 + function showDoneDialog() { 1027 + // Save current textarea to feedbackMap (but don't POST yet) 1028 + const run = EMBEDDED_DATA.runs[currentIndex]; 1029 + const text = document.getElementById("feedback").value; 1030 + if (text.trim() === "") { 1031 + delete feedbackMap[run.id]; 1032 + } else { 1033 + feedbackMap[run.id] = text; 1034 + } 1035 + 1036 + // POST once with status: complete — include ALL runs so the model 1037 + // can distinguish "no feedback" (looks good) from "not reviewed" 1038 + const reviews = []; 1039 + const ts = new Date().toISOString(); 1040 + for (const r of EMBEDDED_DATA.runs) { 1041 + reviews.push({ run_id: r.id, feedback: feedbackMap[r.id] || "", timestamp: ts }); 1042 + } 1043 + const payload = JSON.stringify({ reviews, status: "complete" }, null, 2); 1044 + fetch("/api/feedback", { 1045 + method: "POST", 1046 + headers: { "Content-Type": "application/json" }, 1047 + body: payload, 1048 + }).then(() => { 1049 + document.getElementById("done-overlay").classList.add("visible"); 1050 + }).catch(() => { 1051 + // Server not available (static mode) — download as file 1052 + const blob = new Blob([payload], { type: "application/json" }); 1053 + const url = URL.createObjectURL(blob); 1054 + const a = document.createElement("a"); 1055 + a.href = url; 1056 + a.download = "feedback.json"; 1057 + a.click(); 1058 + URL.revokeObjectURL(url); 1059 + document.getElementById("done-overlay").classList.add("visible"); 1060 + }); 1061 + } 1062 + 1063 + function closeDoneDialog() { 1064 + // Reset status back to in_progress 1065 + saveCurrentFeedback(); 1066 + document.getElementById("done-overlay").classList.remove("visible"); 1067 + } 1068 + 1069 + // ---- Toast ---- 1070 + function showToast(message) { 1071 + const toast = document.getElementById("toast"); 1072 + toast.textContent = message; 1073 + toast.classList.add("visible"); 1074 + setTimeout(() => toast.classList.remove("visible"), 2000); 1075 + } 1076 + 1077 + // ---- Keyboard nav ---- 1078 + document.addEventListener("keydown", (e) => { 1079 + // Don't capture when typing in textarea 1080 + if (e.target.tagName === "TEXTAREA") return; 1081 + 1082 + if (e.key === "ArrowLeft" || e.key === "ArrowUp") { 1083 + e.preventDefault(); 1084 + navigate(-1); 1085 + } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { 1086 + e.preventDefault(); 1087 + navigate(1); 1088 + } 1089 + }); 1090 + 1091 + // ---- Util ---- 1092 + function getDownloadUri(file) { 1093 + if (file.data_uri) return file.data_uri; 1094 + if (file.data_b64) return "data:application/octet-stream;base64," + file.data_b64; 1095 + if (file.type === "text") return "data:text/plain;charset=utf-8," + encodeURIComponent(file.content); 1096 + return "#"; 1097 + } 1098 + 1099 + function escapeHtml(text) { 1100 + const div = document.createElement("div"); 1101 + div.textContent = text; 1102 + return div.innerHTML; 1103 + } 1104 + 1105 + // ---- View switching ---- 1106 + function switchView(view) { 1107 + document.querySelectorAll(".view-tab").forEach(t => t.classList.remove("active")); 1108 + document.querySelectorAll(".view-panel").forEach(p => p.classList.remove("active")); 1109 + document.querySelector(`[onclick="switchView('${view}')"]`).classList.add("active"); 1110 + document.getElementById("panel-" + view).classList.add("active"); 1111 + } 1112 + 1113 + // ---- Benchmark rendering ---- 1114 + function renderBenchmark() { 1115 + const data = EMBEDDED_DATA.benchmark; 1116 + if (!data) return; 1117 + 1118 + // Show the tabs 1119 + document.getElementById("view-tabs").style.display = "flex"; 1120 + 1121 + const container = document.getElementById("benchmark-content"); 1122 + const summary = data.run_summary || {}; 1123 + const metadata = data.metadata || {}; 1124 + const notes = data.notes || []; 1125 + 1126 + let html = ""; 1127 + 1128 + // Header 1129 + html += "<h2 style='font-family: Poppins, sans-serif; margin-bottom: 0.5rem;'>Benchmark Results</h2>"; 1130 + html += "<p style='color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1.25rem;'>"; 1131 + if (metadata.skill_name) html += "<strong>" + escapeHtml(metadata.skill_name) + "</strong> &mdash; "; 1132 + if (metadata.timestamp) html += metadata.timestamp + " &mdash; "; 1133 + if (metadata.evals_run) html += "Evals: " + metadata.evals_run.join(", ") + " &mdash; "; 1134 + html += (metadata.runs_per_configuration || "?") + " runs per configuration"; 1135 + html += "</p>"; 1136 + 1137 + // Summary table 1138 + html += '<table class="benchmark-table">'; 1139 + 1140 + function fmtStat(stat, pct) { 1141 + if (!stat) return "—"; 1142 + const suffix = pct ? "%" : ""; 1143 + const m = pct ? (stat.mean * 100).toFixed(0) : stat.mean.toFixed(1); 1144 + const s = pct ? (stat.stddev * 100).toFixed(0) : stat.stddev.toFixed(1); 1145 + return m + suffix + " ± " + s + suffix; 1146 + } 1147 + 1148 + function deltaClass(val) { 1149 + if (!val) return ""; 1150 + const n = parseFloat(val); 1151 + if (n > 0) return "benchmark-delta-positive"; 1152 + if (n < 0) return "benchmark-delta-negative"; 1153 + return ""; 1154 + } 1155 + 1156 + // Discover config names dynamically (everything except "delta") 1157 + const configs = Object.keys(summary).filter(k => k !== "delta"); 1158 + const configA = configs[0] || "config_a"; 1159 + const configB = configs[1] || "config_b"; 1160 + const labelA = configA.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); 1161 + const labelB = configB.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); 1162 + const a = summary[configA] || {}; 1163 + const b = summary[configB] || {}; 1164 + const delta = summary.delta || {}; 1165 + 1166 + html += "<thead><tr><th>Metric</th><th>" + escapeHtml(labelA) + "</th><th>" + escapeHtml(labelB) + "</th><th>Delta</th></tr></thead>"; 1167 + html += "<tbody>"; 1168 + 1169 + html += "<tr><td><strong>Pass Rate</strong></td>"; 1170 + html += "<td>" + fmtStat(a.pass_rate, true) + "</td>"; 1171 + html += "<td>" + fmtStat(b.pass_rate, true) + "</td>"; 1172 + html += '<td class="' + deltaClass(delta.pass_rate) + '">' + (delta.pass_rate || "—") + "</td></tr>"; 1173 + 1174 + // Time (only show row if data exists) 1175 + if (a.time_seconds || b.time_seconds) { 1176 + html += "<tr><td><strong>Time (s)</strong></td>"; 1177 + html += "<td>" + fmtStat(a.time_seconds, false) + "</td>"; 1178 + html += "<td>" + fmtStat(b.time_seconds, false) + "</td>"; 1179 + html += '<td class="' + deltaClass(delta.time_seconds) + '">' + (delta.time_seconds ? delta.time_seconds + "s" : "—") + "</td></tr>"; 1180 + } 1181 + 1182 + // Tokens (only show row if data exists) 1183 + if (a.tokens || b.tokens) { 1184 + html += "<tr><td><strong>Tokens</strong></td>"; 1185 + html += "<td>" + fmtStat(a.tokens, false) + "</td>"; 1186 + html += "<td>" + fmtStat(b.tokens, false) + "</td>"; 1187 + html += '<td class="' + deltaClass(delta.tokens) + '">' + (delta.tokens || "—") + "</td></tr>"; 1188 + } 1189 + 1190 + html += "</tbody></table>"; 1191 + 1192 + // Per-eval breakdown (if runs data available) 1193 + const runs = data.runs || []; 1194 + if (runs.length > 0) { 1195 + const evalIds = [...new Set(runs.map(r => r.eval_id))].sort((a, b) => a - b); 1196 + 1197 + html += "<h3 style='font-family: Poppins, sans-serif; margin-bottom: 0.75rem;'>Per-Eval Breakdown</h3>"; 1198 + 1199 + const hasTime = runs.some(r => r.result && r.result.time_seconds != null); 1200 + const hasErrors = runs.some(r => r.result && r.result.errors > 0); 1201 + 1202 + for (const evalId of evalIds) { 1203 + const evalRuns = runs.filter(r => r.eval_id === evalId); 1204 + const evalName = evalRuns[0] && evalRuns[0].eval_name ? evalRuns[0].eval_name : "Eval " + evalId; 1205 + 1206 + html += "<h4 style='font-family: Poppins, sans-serif; margin: 1rem 0 0.5rem; color: var(--text);'>" + escapeHtml(evalName) + "</h4>"; 1207 + html += '<table class="benchmark-table">'; 1208 + html += "<thead><tr><th>Config</th><th>Run</th><th>Pass Rate</th>"; 1209 + if (hasTime) html += "<th>Time (s)</th>"; 1210 + if (hasErrors) html += "<th>Crashes During Execution</th>"; 1211 + html += "</tr></thead>"; 1212 + html += "<tbody>"; 1213 + 1214 + // Group by config and render with average rows 1215 + const configGroups = [...new Set(evalRuns.map(r => r.configuration))]; 1216 + for (let ci = 0; ci < configGroups.length; ci++) { 1217 + const config = configGroups[ci]; 1218 + const configRuns = evalRuns.filter(r => r.configuration === config); 1219 + if (configRuns.length === 0) continue; 1220 + 1221 + const rowClass = ci === 0 ? "benchmark-row-with" : "benchmark-row-without"; 1222 + const configLabel = config.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); 1223 + 1224 + for (const run of configRuns) { 1225 + const r = run.result || {}; 1226 + const prClass = r.pass_rate >= 0.8 ? "benchmark-delta-positive" : r.pass_rate < 0.5 ? "benchmark-delta-negative" : ""; 1227 + html += '<tr class="' + rowClass + '">'; 1228 + html += "<td>" + configLabel + "</td>"; 1229 + html += "<td>" + run.run_number + "</td>"; 1230 + html += '<td class="' + prClass + '">' + ((r.pass_rate || 0) * 100).toFixed(0) + "% (" + (r.passed || 0) + "/" + (r.total || 0) + ")</td>"; 1231 + if (hasTime) html += "<td>" + (r.time_seconds != null ? r.time_seconds.toFixed(1) : "—") + "</td>"; 1232 + if (hasErrors) html += "<td>" + (r.errors || 0) + "</td>"; 1233 + html += "</tr>"; 1234 + } 1235 + 1236 + // Average row 1237 + const rates = configRuns.map(r => (r.result || {}).pass_rate || 0); 1238 + const avgRate = rates.reduce((a, b) => a + b, 0) / rates.length; 1239 + const avgPrClass = avgRate >= 0.8 ? "benchmark-delta-positive" : avgRate < 0.5 ? "benchmark-delta-negative" : ""; 1240 + html += '<tr class="benchmark-row-avg ' + rowClass + '">'; 1241 + html += "<td>" + configLabel + "</td>"; 1242 + html += "<td>Avg</td>"; 1243 + html += '<td class="' + avgPrClass + '">' + (avgRate * 100).toFixed(0) + "%</td>"; 1244 + if (hasTime) { 1245 + const times = configRuns.map(r => (r.result || {}).time_seconds).filter(t => t != null); 1246 + html += "<td>" + (times.length ? (times.reduce((a, b) => a + b, 0) / times.length).toFixed(1) : "—") + "</td>"; 1247 + } 1248 + if (hasErrors) html += "<td></td>"; 1249 + html += "</tr>"; 1250 + } 1251 + html += "</tbody></table>"; 1252 + 1253 + // Per-assertion detail for this eval 1254 + const runsWithExpectations = {}; 1255 + for (const config of configGroups) { 1256 + runsWithExpectations[config] = evalRuns.filter(r => r.configuration === config && r.expectations && r.expectations.length > 0); 1257 + } 1258 + const hasAnyExpectations = Object.values(runsWithExpectations).some(runs => runs.length > 0); 1259 + if (hasAnyExpectations) { 1260 + // Collect all unique assertion texts across all configs 1261 + const allAssertions = []; 1262 + const seen = new Set(); 1263 + for (const config of configGroups) { 1264 + for (const run of runsWithExpectations[config]) { 1265 + for (const exp of (run.expectations || [])) { 1266 + if (!seen.has(exp.text)) { 1267 + seen.add(exp.text); 1268 + allAssertions.push(exp.text); 1269 + } 1270 + } 1271 + } 1272 + } 1273 + 1274 + html += '<table class="benchmark-table" style="margin-top: 0.5rem;">'; 1275 + html += "<thead><tr><th>Assertion</th>"; 1276 + for (const config of configGroups) { 1277 + const label = config.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); 1278 + html += "<th>" + escapeHtml(label) + "</th>"; 1279 + } 1280 + html += "</tr></thead><tbody>"; 1281 + 1282 + for (const assertionText of allAssertions) { 1283 + html += "<tr><td>" + escapeHtml(assertionText) + "</td>"; 1284 + 1285 + for (const config of configGroups) { 1286 + html += "<td>"; 1287 + for (const run of runsWithExpectations[config]) { 1288 + const exp = (run.expectations || []).find(e => e.text === assertionText); 1289 + if (exp) { 1290 + const cls = exp.passed ? "benchmark-delta-positive" : "benchmark-delta-negative"; 1291 + const icon = exp.passed ? "\u2713" : "\u2717"; 1292 + html += '<span class="' + cls + '" title="Run ' + run.run_number + ': ' + escapeHtml(exp.evidence || "") + '">' + icon + "</span> "; 1293 + } else { 1294 + html += "— "; 1295 + } 1296 + } 1297 + html += "</td>"; 1298 + } 1299 + html += "</tr>"; 1300 + } 1301 + html += "</tbody></table>"; 1302 + } 1303 + } 1304 + } 1305 + 1306 + // Notes 1307 + if (notes.length > 0) { 1308 + html += '<div class="benchmark-notes">'; 1309 + html += "<h3>Analysis Notes</h3>"; 1310 + html += "<ul>"; 1311 + for (const note of notes) { 1312 + html += "<li>" + escapeHtml(note) + "</li>"; 1313 + } 1314 + html += "</ul></div>"; 1315 + } 1316 + 1317 + container.innerHTML = html; 1318 + } 1319 + 1320 + // ---- Start ---- 1321 + init(); 1322 + renderBenchmark(); 1323 + </script> 1324 + </body> 1325 + </html>
+192
.agents/skills/spindle/SKILL.md
··· 1 + --- 2 + name: spindle 3 + description: | 4 + REQUIRED when the user asks about Tangled CI, Spindle, pipelines, workflow runs, 5 + CI logs from git push, `ssh -t -p 3333 tangled.org ...`, or files under 6 + `.tangled/workflows/`. Use this skill for creating, editing, debugging, or 7 + reviewing Tangled Spindle workflow YAML, especially `engine: microvm`, NixOS 8 + images, `dependencies`, `services`, `virtualisation.docker`, secrets, and CI 9 + trigger behavior. Also use it when the user says "Tangled CI" because the CI 10 + runner/pipeline system is named Spindle. 11 + --- 12 + 13 + # Spindle — Tangled CI workflow guidance 14 + 15 + Spindle is Tangled's CI/pipeline runner. In this repo, Spindle workflows live in 16 + `.tangled/workflows/*.yml` or `.yaml`. 17 + 18 + ## First moves 19 + 20 + 1. Inspect existing workflow files before proposing changes: 21 + ```bash 22 + find .tangled/workflows -maxdepth 1 -type f -print | sort 23 + ``` 24 + 2. Read the specific workflow(s) involved. Preserve local conventions: file 25 + names, branch filters, `devenv shell`, Docker Compose usage, Dagger usage, and 26 + step naming style. 27 + 3. If the task involves Tangled repo operations (issues, PRs, repo view), also 28 + use the `tang` skill. If it involves Cachix/Nix binary cache speed in this 29 + repo, also use the `cachix` skill. 30 + 4. Treat CI changes as potentially expensive: do not run `git push` or manually 31 + trigger remote workflows unless the user asks or approves. 32 + 33 + ## Workflow YAML essentials 34 + 35 + Common top-level fields: 36 + 37 + ```yaml 38 + when: 39 + - event: ["push", "manual"] 40 + branch: ["main"] 41 + - event: ["pull_request"] 42 + branch: ["main"] 43 + 44 + engine: microvm 45 + clone: 46 + skip: false 47 + depth: 1 48 + 49 + environment: 50 + NODE_ENV: production 51 + 52 + steps: 53 + - name: "Step name" 54 + command: | 55 + set -euo pipefail 56 + command here 57 + ``` 58 + 59 + Trigger notes: 60 + - `push` requires `branch` and/or `tag`. 61 + - `pull_request` `branch` means target branch. 62 + - `manual` ignores `branch`, but it can share a condition with `push`. 63 + - Branch/tag patterns support `*` and `**`. 64 + 65 + Environment notes: 66 + - Do not put secrets in `environment`; workflow YAML is public. Use Tangled 67 + repository Settings → Secrets and reference those variables from commands. 68 + - Prefer `set -euo pipefail` inside multi-line shell commands for reliable CI 69 + failure behavior. 70 + 71 + ## Engine selection 72 + 73 + Prefer `engine: microvm` for new work unless the repository already uses and 74 + wants to keep `nixery`. 75 + 76 + ### microVM engine 77 + 78 + Typical NixOS microVM workflow: 79 + 80 + ```yaml 81 + engine: microvm 82 + image: nixos 83 + 84 + dependencies: 85 + - devenv 86 + - docker-client 87 + 88 + virtualisation: 89 + docker: true 90 + 91 + steps: 92 + - name: "Initialize devenv" 93 + command: devenv shell true 94 + ``` 95 + 96 + MicroVM details: 97 + - `image: nixos` enables NixOS-level workflow config: `dependencies`, 98 + `registry`, `caches`, `services`, and `virtualisation`. 99 + - `dependencies` is a flat list. Bare names come from nixpkgs. Flake attrs can 100 + use `flakeref#attr`. 101 + - `registry` can pin/alias flakes, e.g. `nixpkgs: github:nixos/nixpkgs/nixos-unstable`. 102 + - `caches` maps binary cache URL to trusted public key. 103 + - `services` and `virtualisation` are passed through to NixOS. `true` is shorthand 104 + for `.enable = true` when an enable option exists. 105 + - Use `virtualisation: { docker: true }` when workflow steps need a real Docker 106 + daemon, Docker Compose, or `docker buildx`. 107 + - `image: alpine` is useful for small checks, but NixOS-specific fields do not 108 + apply there; install packages in steps with Alpine's package manager instead. 109 + 110 + PostgreSQL service pattern: 111 + 112 + ```yaml 113 + environment: 114 + DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql" 115 + services: 116 + postgresql: 117 + enable: true 118 + ensureDatabases: ["spindle-workflow"] 119 + ensureUsers: 120 + - name: spindle-workflow 121 + ensureDBOwnership: true 122 + ``` 123 + 124 + The workflow user is `spindle-workflow`; naming the database/user this way makes 125 + Postgres peer auth work over the local socket. 126 + 127 + ### nixery engine 128 + 129 + Legacy Nixery workflows use a dependency map rather than the microVM flat list: 130 + 131 + ```yaml 132 + engine: nixery 133 + dependencies: 134 + nixpkgs: 135 + - nodejs 136 + - go 137 + ``` 138 + 139 + When converting Nixery to microVM, change `engine: microvm`, choose `image`, and 140 + convert dependencies to the microVM form. 141 + 142 + ## Debugging Spindle runs 143 + 144 + When `git push` prints a remote hint like: 145 + 146 + ```text 147 + remote: → Browse CI logs in your terminal: 148 + remote: ssh -t -p 3333 tangled.org DID PIPELINE_OR_SHA 149 + ``` 150 + 151 + use the exact SSH command shown by the remote when the user wants terminal logs. 152 + It may attach to a live run, so use a timeout in non-interactive tool contexts, 153 + e.g.: 154 + 155 + ```bash 156 + timeout 180 ssh -t -p 3333 tangled.org <did> <pipeline-or-sha> 157 + ``` 158 + 159 + Debug checklist: 160 + 1. Identify which workflow file ran and which trigger matched. 161 + 2. Check whether the failure happened during clone, microVM/NixOS activation, 162 + dependency realization, service startup, or a named step. 163 + 3. For command failures, reproduce locally with the same working directory and 164 + environment shape when possible (`devenv shell ...` in this repo). 165 + 4. For Docker failures in microVM, verify `virtualisation.docker: true` and that 166 + the step waits for/builds the right image (`--load` if later Compose steps use 167 + the local Docker daemon). 168 + 5. For missing tools, add them to microVM `dependencies` or install them in the 169 + step for non-NixOS images. 170 + 6. For slow Nix setup, prefer cache configuration and consult the repo's Cachix 171 + workflow guidance before changing Nix/devenv files. 172 + 173 + ## RSSBase conventions observed 174 + 175 + Current repo workflows use: 176 + - `.tangled/workflows/check.yml` for Dagger checks via `devenv shell dagger check`. 177 + - `.tangled/workflows/build.yml` for production Docker/Compose smoke tests. 178 + - `engine: microvm`, `image: nixos`, `dependencies: [devenv]`, shallow clone, 179 + and `virtualisation.docker: true` for production image builds. 180 + 181 + Keep commands wrapped in `devenv shell -- bash -c '...'` when the step depends on 182 + project-provided tools or environment. Prefer fixing the smallest workflow file 183 + necessary rather than reshaping the whole CI setup. 184 + 185 + ## Reference docs 186 + 187 + Primary docs: 188 + - https://docs.tangled.org/spindles 189 + - https://blog.tangled.org/spindle-microvm/ 190 + 191 + Read `references/spindles-quick-reference.md` for a condensed local reference if 192 + network access is unavailable or you need examples without loading the full docs.
+26
.agents/skills/spindle/evals/evals.json
··· 1 + { 2 + "skill_name": "spindle", 3 + "evals": [ 4 + { 5 + "id": 0, 6 + "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.", 7 + "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.", 8 + "files": [ 9 + ".tangled/workflows/build.yml", 10 + ".tangled/workflows/check.yml" 11 + ] 12 + }, 13 + { 14 + "id": 1, 15 + "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?", 16 + "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.", 17 + "files": [] 18 + }, 19 + { 20 + "id": 2, 21 + "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.", 22 + "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.", 23 + "files": [] 24 + } 25 + ] 26 + }
+133
.agents/skills/spindle/references/spindles-quick-reference.md
··· 1 + # Spindle quick reference 2 + 3 + Source material: <https://docs.tangled.org/spindles> and 4 + <https://blog.tangled.org/spindle-microvm/>. 5 + 6 + ## What Spindle is 7 + 8 + Spindle is Tangled's self-hostable CI/pipeline runner. Workflows are YAML files in 9 + `.tangled/workflows/`. 10 + 11 + ## Common workflow fields 12 + 13 + - `when`: required trigger list. 14 + - `event`: one or more of `push`, `pull_request`, `manual`. 15 + - `branch`: branch patterns for push, or PR target branch patterns. 16 + - `tag`: tag patterns for push. 17 + - `engine`: required, currently `nixery` or `microvm`. 18 + - `clone`: optional clone config: `skip`, `depth`, `submodules`. 19 + - `environment`: optional public env vars for the workflow. Do not put secrets here. 20 + - `steps`: optional list of `{name, command, environment}`. Commands run in Bash. 21 + 22 + Default CI env includes `CI=true`, `TANGLED_PIPELINE_ID`, `TANGLED_PIPELINE_KIND`, 23 + repo metadata, and push/PR-specific ref variables such as `TANGLED_REF_NAME`, 24 + `TANGLED_SHA`, and `TANGLED_PR_TARGET_BRANCH`. 25 + 26 + ## microVM engine 27 + 28 + `engine: microvm` runs each workflow in a dedicated microVM. 29 + 30 + Top-level fields: 31 + - `image`: example values `nixos` and `alpine`; availability depends on the 32 + Spindle operator. 33 + - `dependencies`: for NixOS images, a flat list of tools available in every step. 34 + - `registry`: remaps flake references, similar to `nix registry`. 35 + - `caches`: Nix binary cache URL to trusted public key. 36 + - `services`: NixOS `services.*` config, brought up before steps run. 37 + - `virtualisation`: NixOS `virtualisation.*` config, e.g. Docker. 38 + 39 + Bare dependency names resolve from nixpkgs. Flake attrs can be referenced as 40 + `github:nixos/nixpkgs#hello` or via aliases in `registry`. 41 + 42 + `true` works as shorthand for `.enable = true` anywhere an enable option exists, 43 + so `virtualisation: { docker: true }` enables Docker. 44 + 45 + ## Examples 46 + 47 + ### Basic Node workflow 48 + 49 + ```yaml 50 + when: 51 + - event: ["push", "pull_request"] 52 + branch: ["main"] 53 + engine: microvm 54 + image: nixos 55 + dependencies: 56 + - pnpm 57 + steps: 58 + - name: "Install dependencies" 59 + command: pnpm install --frozen-lockfile 60 + - name: "Lint and test" 61 + command: | 62 + pnpm run lint 63 + pnpm test 64 + - name: "Build" 65 + command: pnpm run build 66 + ``` 67 + 68 + ### Rust with OpenSSL 69 + 70 + ```yaml 71 + engine: microvm 72 + image: nixos 73 + dependencies: 74 + - gcc 75 + - cargo 76 + - rustc 77 + - clippy 78 + - rustfmt 79 + - pkg-config 80 + - openssl 81 + steps: 82 + - name: "Check formatting" 83 + command: cargo fmt --check 84 + - name: "Clippy" 85 + command: cargo clippy --all-targets -- -D warnings 86 + - name: "Test" 87 + command: cargo test --all 88 + ``` 89 + 90 + ### PostgreSQL service 91 + 92 + ```yaml 93 + environment: 94 + DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql" 95 + dependencies: 96 + - sqlx-cli 97 + services: 98 + postgresql: 99 + enable: true 100 + ensureDatabases: ["spindle-workflow"] 101 + ensureUsers: 102 + - name: spindle-workflow 103 + ensureDBOwnership: true 104 + steps: 105 + - name: "Run migrations" 106 + command: sqlx migrate run 107 + ``` 108 + 109 + ### Docker inside microVM 110 + 111 + ```yaml 112 + engine: microvm 113 + image: nixos 114 + virtualisation: 115 + docker: true 116 + steps: 117 + - name: "Build image" 118 + command: | 119 + set -euo pipefail 120 + docker build -t app . 121 + ``` 122 + 123 + ## Terminal logs 124 + 125 + After a push, Tangled may print: 126 + 127 + ```text 128 + remote: → Browse CI logs in your terminal: 129 + remote: ssh -t -p 3333 tangled.org <did> <pipeline-or-sha> 130 + ``` 131 + 132 + Run the exact command to view logs. In automated/non-interactive contexts, wrap 133 + it with `timeout` so the command does not hang indefinitely.
+38
.agents/skills/symfony-brainstorming/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-brainstorming 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Structured brainstorming for Symfony projects - explore requirements, identify components, and plan architecture collaboratively 9 + --- 10 + 11 + # Brainstorming (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `docs/complexity-tiers.md`
+42
.agents/skills/symfony-config-env-parameters/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-config-env-parameters 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Manage Symfony configuration with .env files, parameters, secrets vault, and environment-specific settings 12 + --- 13 + 14 + # Config Env Parameters (Symfony) 15 + 16 + ## Use when 17 + - Refining architecture/workflows/context handling in Symfony projects. 18 + - Planning and executing medium/complex changes safely. 19 + 20 + ## Default workflow 21 + 1. Establish current boundaries, constraints, and coupling points. 22 + 2. Propose smallest coherent architectural adjustment. 23 + 3. Execute in checkpoints with validation at each stage. 24 + 4. Summarize tradeoffs and follow-up backlog. 25 + 26 + ## Guardrails 27 + - Use existing project patterns by default. 28 + - Avoid broad refactors without explicit need. 29 + - Keep decision log clear and auditable. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Architecture/workflow changes. 37 + - Checkpoint validation outcomes. 38 + - Residual risks and next steps. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+373
.agents/skills/symfony-config-env-parameters/reference.md
··· 1 + # Reference 2 + 3 + # Configuration and Environment Management 4 + 5 + ## Environment Files 6 + 7 + ### File Hierarchy 8 + 9 + ``` 10 + .env # Default values (committed) 11 + .env.local # Local overrides (not committed) 12 + .env.test # Test environment defaults 13 + .env.test.local # Local test overrides (not committed) 14 + .env.prod # Production defaults 15 + .env.prod.local # Production local overrides 16 + ``` 17 + 18 + ### Loading Order 19 + 20 + ``` 21 + 1. .env 22 + 2. .env.local (not in test) 23 + 3. .env.{APP_ENV} 24 + 4. .env.{APP_ENV}.local 25 + ``` 26 + 27 + Later files override earlier ones. 28 + 29 + ### .env Syntax 30 + 31 + ```bash 32 + # .env 33 + 34 + # App configuration 35 + APP_ENV=dev 36 + APP_DEBUG=true 37 + APP_SECRET=change-this-in-production 38 + 39 + # Database 40 + DATABASE_URL="postgresql://user:pass@localhost:5432/myapp?serverVersion=15" 41 + 42 + # Mailer 43 + MAILER_DSN=smtp://localhost:1025 44 + 45 + # Third-party APIs 46 + STRIPE_API_KEY=sk_test_xxx 47 + AWS_ACCESS_KEY_ID=xxx 48 + AWS_SECRET_ACCESS_KEY=xxx 49 + 50 + # Feature flags 51 + FEATURE_NEW_CHECKOUT=false 52 + ``` 53 + 54 + ### Type Casting 55 + 56 + ```bash 57 + # Boolean 58 + FEATURE_ENABLED=true # string "true" 59 + 60 + # In PHP, compare as string or cast 61 + $enabled = $_ENV['FEATURE_ENABLED'] === 'true'; 62 + 63 + # Or use filter_var 64 + $enabled = filter_var($_ENV['FEATURE_ENABLED'], FILTER_VALIDATE_BOOLEAN); 65 + ``` 66 + 67 + ## Parameters 68 + 69 + ### Define in services.yaml 70 + 71 + ```yaml 72 + # config/services.yaml 73 + parameters: 74 + app.admin_email: 'admin@example.com' 75 + app.items_per_page: 20 76 + app.supported_locales: ['en', 'fr', 'de'] 77 + 78 + # Using environment variables 79 + app.database_url: '%env(DATABASE_URL)%' 80 + app.stripe_key: '%env(STRIPE_API_KEY)%' 81 + 82 + # Type casting 83 + app.port: '%env(int:APP_PORT)%' 84 + app.debug: '%env(bool:APP_DEBUG)%' 85 + app.hosts: '%env(json:ALLOWED_HOSTS)%' 86 + ``` 87 + 88 + ### Environment Variable Processors 89 + 90 + ```yaml 91 + parameters: 92 + # Cast to int 93 + port: '%env(int:PORT)%' 94 + 95 + # Cast to bool 96 + debug: '%env(bool:DEBUG)%' 97 + 98 + # Cast to float 99 + rate: '%env(float:TAX_RATE)%' 100 + 101 + # Parse JSON 102 + config: '%env(json:CONFIG_JSON)%' 103 + 104 + # Parse CSV 105 + hosts: '%env(csv:ALLOWED_HOSTS)%' 106 + 107 + # Base64 decode 108 + secret: '%env(base64:ENCODED_SECRET)%' 109 + 110 + # Read from file 111 + cert: '%env(file:SSL_CERT_PATH)%' 112 + 113 + # Resolve env var name from another env var 114 + dsn: '%env(resolve:DATABASE_DSN)%' 115 + 116 + # Default value 117 + port: '%env(default:3000:PORT)%' 118 + 119 + # Chained processors 120 + config: '%env(json:file:CONFIG_PATH)%' 121 + ``` 122 + 123 + ### Use in Services 124 + 125 + ```php 126 + <?php 127 + 128 + class PaginationService 129 + { 130 + public function __construct( 131 + #[Autowire('%app.items_per_page%')] 132 + private int $itemsPerPage, 133 + ) {} 134 + } 135 + 136 + // Or bind in services.yaml 137 + services: 138 + _defaults: 139 + bind: 140 + $adminEmail: '%app.admin_email%' 141 + $itemsPerPage: '%app.items_per_page%' 142 + ``` 143 + 144 + ## Secrets 145 + 146 + ### Creating Secrets 147 + 148 + ```bash 149 + # Generate keys (once per environment) 150 + php bin/console secrets:generate-keys 151 + 152 + # Add a secret 153 + php bin/console secrets:set DATABASE_PASSWORD 154 + 155 + # For production 156 + php bin/console secrets:set DATABASE_PASSWORD --env=prod 157 + 158 + # From file 159 + php bin/console secrets:set SSL_CERT < cert.pem 160 + ``` 161 + 162 + ### Secrets Storage 163 + 164 + ``` 165 + config/secrets/ 166 + ├── dev/ 167 + │ ├── dev.encrypt.public.php # Public key (committed) 168 + │ └── dev.decrypt.private.php # Private key (not committed) 169 + └── prod/ 170 + ├── prod.encrypt.public.php 171 + ├── prod.DATABASE_PASSWORD.28a3f.php # Encrypted secret 172 + └── prod.decrypt.private.php # Deploy securely 173 + ``` 174 + 175 + ### Using Secrets 176 + 177 + ```yaml 178 + # Secrets are accessed like env vars 179 + parameters: 180 + database_password: '%env(DATABASE_PASSWORD)%' 181 + 182 + doctrine: 183 + dbal: 184 + password: '%env(DATABASE_PASSWORD)%' 185 + ``` 186 + 187 + ### List Secrets 188 + 189 + ```bash 190 + php bin/console secrets:list 191 + php bin/console secrets:list --reveal # Show values 192 + ``` 193 + 194 + ## Environment-Specific Config 195 + 196 + ### Config Files 197 + 198 + ``` 199 + config/ 200 + ├── packages/ 201 + │ ├── framework.yaml # All environments 202 + │ ├── doctrine.yaml 203 + │ ├── dev/ 204 + │ │ └── web_profiler.yaml # Dev only 205 + │ ├── prod/ 206 + │ │ └── doctrine.yaml # Prod overrides 207 + │ └── test/ 208 + │ └── framework.yaml # Test overrides 209 + ``` 210 + 211 + ### When Blocks 212 + 213 + ```yaml 214 + # config/packages/framework.yaml 215 + when@dev: 216 + framework: 217 + profiler: 218 + collect: true 219 + 220 + when@prod: 221 + framework: 222 + profiler: 223 + collect: false 224 + 225 + when@test: 226 + framework: 227 + test: true 228 + ``` 229 + 230 + ## Feature Flags 231 + 232 + ```yaml 233 + # config/services.yaml 234 + parameters: 235 + feature.new_checkout: '%env(bool:FEATURE_NEW_CHECKOUT)%' 236 + feature.dark_mode: '%env(bool:FEATURE_DARK_MODE)%' 237 + ``` 238 + 239 + ```php 240 + <?php 241 + 242 + class CheckoutController 243 + { 244 + public function __construct( 245 + #[Autowire('%feature.new_checkout%')] 246 + private bool $newCheckoutEnabled, 247 + ) {} 248 + 249 + public function checkout(): Response 250 + { 251 + if ($this->newCheckoutEnabled) { 252 + return $this->newCheckoutFlow(); 253 + } 254 + return $this->legacyCheckoutFlow(); 255 + } 256 + } 257 + ``` 258 + 259 + ## Best Practices 260 + 261 + ### .env.local for Local Development 262 + 263 + ```bash 264 + # .env.local (not committed) 265 + DATABASE_URL="postgresql://dev:dev@localhost:5432/myapp_dev" 266 + MAILER_DSN=smtp://localhost:1025 267 + ``` 268 + 269 + ### Production Environment Variables 270 + 271 + ```bash 272 + # Set via server/container environment, not files 273 + export APP_ENV=prod 274 + export APP_SECRET=your-production-secret 275 + export DATABASE_URL="postgresql://prod:xxx@db.server:5432/myapp" 276 + ``` 277 + 278 + ### Validation in Services 279 + 280 + ```php 281 + class StripeService 282 + { 283 + public function __construct( 284 + #[Autowire('%env(STRIPE_API_KEY)%')] 285 + private string $apiKey, 286 + ) { 287 + if (empty($this->apiKey)) { 288 + throw new \RuntimeException('STRIPE_API_KEY is required'); 289 + } 290 + } 291 + } 292 + ``` 293 + 294 + ### Don't Commit Sensitive Data 295 + 296 + ```gitignore 297 + # .gitignore 298 + .env.local 299 + .env.*.local 300 + config/secrets/*/decrypt.private.php 301 + ``` 302 + 303 + ## Assets — AssetMapper (best practice) 304 + 305 + The recommended way to ship frontend assets is **AssetMapper** (no Node bundler, 306 + uses native ESM + importmaps). It is the default in modern Symfony. 307 + 308 + ```bash 309 + composer require symfony/asset-mapper 310 + php bin/console importmap:require bootstrap # add a JS package 311 + php bin/console importmap:audit # security audit of pinned packages 312 + php bin/console asset-map:compile # build for production deploy 313 + ``` 314 + 315 + ```yaml 316 + # config/packages/asset_mapper.yaml 317 + framework: 318 + asset_mapper: 319 + paths: 320 + - assets/ 321 + ``` 322 + 323 + ```twig 324 + {# templates/base.html.twig #} 325 + {{ importmap('app') }} 326 + ``` 327 + 328 + ## Refreshable Env Vars (8.1+ — verify) 329 + 330 + For long-running workers, inject an env var as a `\Closure` so it re-reads the 331 + value between runs instead of freezing it at boot: 332 + 333 + ```php 334 + use Symfony\Component\DependencyInjection\Attribute\Autowire; 335 + 336 + public function __construct( 337 + #[Autowire(env: 'FEATURE_FLAGS')] private \Closure $featureFlags, // ($this->featureFlags)() 338 + ) {} 339 + ``` 340 + 341 + YAML equivalent: `!env_closure '%env(FEATURE_FLAGS)%'`. 342 + 343 + ## Debug Configuration 344 + 345 + ```bash 346 + # Show all parameters 347 + php bin/console debug:container --parameters 348 + 349 + # Show environment variables 350 + php bin/console debug:container --env-vars 351 + 352 + # Show secrets 353 + php bin/console secrets:list --reveal 354 + ``` 355 + 356 + 357 + ## Skill Operating Checklist 358 + 359 + ### Design checklist 360 + - Confirm operation boundaries and invariants first. 361 + - Minimize scope while preserving contract correctness. 362 + - Test both happy path and negative path behavior. 363 + 364 + ### Validation commands 365 + - rg --files 366 + - composer validate 367 + - ./vendor/bin/phpstan analyse 368 + 369 + ### Failure modes to test 370 + - Invalid payload or forbidden actor. 371 + - Boundary values / not-found cases. 372 + - Retry or partial-failure behavior for async flows. 373 +
+39
.agents/skills/symfony-controller-cleanup/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-controller-cleanup 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Refactor fat controllers into lean ones by extracting business logic to services, handlers, and invokable commands 9 + --- 10 + 11 + # Controller Cleanup (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+420
.agents/skills/symfony-controller-cleanup/reference.md
··· 1 + # Reference 2 + 3 + # Controller Cleanup 4 + 5 + ## The Problem: Fat Controllers 6 + 7 + ```php 8 + // BAD: Fat controller with too much logic 9 + #[Route('/orders', methods: ['POST'])] 10 + public function create(Request $request): Response 11 + { 12 + $data = json_decode($request->getContent(), true); 13 + 14 + // Validation logic in controller 15 + if (empty($data['items'])) { 16 + return new JsonResponse(['error' => 'Items required'], 400); 17 + } 18 + 19 + // Business logic in controller 20 + $order = new Order(); 21 + $order->setCustomer($this->getUser()); 22 + $order->setStatus('pending'); 23 + 24 + $total = 0; 25 + foreach ($data['items'] as $itemData) { 26 + $product = $this->em->find(Product::class, $itemData['productId']); 27 + if (!$product) { 28 + return new JsonResponse(['error' => 'Product not found'], 400); 29 + } 30 + 31 + if ($product->getStock() < $itemData['quantity']) { 32 + return new JsonResponse(['error' => 'Insufficient stock'], 400); 33 + } 34 + 35 + $item = new OrderItem(); 36 + $item->setProduct($product); 37 + $item->setQuantity($itemData['quantity']); 38 + $item->setPrice($product->getPrice()); 39 + $order->addItem($item); 40 + 41 + $total += $product->getPrice() * $itemData['quantity']; 42 + $product->setStock($product->getStock() - $itemData['quantity']); 43 + } 44 + 45 + $order->setTotal($total); 46 + 47 + // Coupon logic 48 + if (!empty($data['coupon'])) { 49 + $coupon = $this->em->getRepository(Coupon::class) 50 + ->findOneBy(['code' => $data['coupon']]); 51 + if ($coupon && $coupon->isValid()) { 52 + $discount = $total * ($coupon->getDiscount() / 100); 53 + $order->setDiscount($discount); 54 + $order->setTotal($total - $discount); 55 + } 56 + } 57 + 58 + $this->em->persist($order); 59 + $this->em->flush(); 60 + 61 + // Send email 62 + $email = (new Email()) 63 + ->to($this->getUser()->getEmail()) 64 + ->subject('Order Confirmation') 65 + ->text('Your order has been placed.'); 66 + $this->mailer->send($email); 67 + 68 + return new JsonResponse(['id' => $order->getId()], 201); 69 + } 70 + ``` 71 + 72 + ## The Solution: Lean Controller 73 + 74 + ### Step 1: Extract to Service 75 + 76 + ```php 77 + <?php 78 + // src/Service/OrderService.php 79 + 80 + namespace App\Service; 81 + 82 + use App\Dto\CreateOrderRequest; 83 + use App\Entity\Order; 84 + use App\Entity\User; 85 + 86 + class OrderService 87 + { 88 + public function __construct( 89 + private ProductService $products, 90 + private CouponService $coupons, 91 + private EntityManagerInterface $em, 92 + private OrderNotificationService $notifications, 93 + ) {} 94 + 95 + public function createOrder(User $user, CreateOrderRequest $request): Order 96 + { 97 + // Validate and reserve products 98 + $items = $this->products->reserveItems($request->items); 99 + 100 + // Create order 101 + $order = Order::create($user, $items); 102 + 103 + // Apply coupon if provided 104 + if ($request->couponCode) { 105 + $discount = $this->coupons->apply($request->couponCode, $order); 106 + $order->applyDiscount($discount); 107 + } 108 + 109 + $this->em->persist($order); 110 + $this->em->flush(); 111 + 112 + // Async notification 113 + $this->notifications->orderCreated($order); 114 + 115 + return $order; 116 + } 117 + } 118 + ``` 119 + 120 + ### Step 2: Use DTOs for Input 121 + 122 + ```php 123 + <?php 124 + // src/Dto/CreateOrderRequest.php 125 + 126 + namespace App\Dto; 127 + 128 + use Symfony\Component\Validator\Constraints as Assert; 129 + 130 + final readonly class CreateOrderRequest 131 + { 132 + public function __construct( 133 + #[Assert\NotBlank] 134 + #[Assert\Count(min: 1)] 135 + #[Assert\Valid] 136 + public array $items, 137 + 138 + public ?string $couponCode = null, 139 + ) {} 140 + } 141 + ``` 142 + 143 + ### Step 3: Lean Controller 144 + 145 + ```php 146 + <?php 147 + // src/Controller/Api/OrderController.php 148 + 149 + namespace App\Controller\Api; 150 + 151 + use App\Dto\CreateOrderRequest; 152 + use App\Service\OrderService; 153 + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 154 + use Symfony\Component\HttpFoundation\JsonResponse; 155 + use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; 156 + use Symfony\Component\Routing\Attribute\Route; 157 + 158 + #[Route('/api/orders')] 159 + class OrderController extends AbstractController 160 + { 161 + public function __construct( 162 + private OrderService $orderService, 163 + ) {} 164 + 165 + #[Route('', methods: ['POST'])] 166 + public function create( 167 + #[MapRequestPayload] CreateOrderRequest $request 168 + ): JsonResponse { 169 + $order = $this->orderService->createOrder( 170 + $this->getUser(), 171 + $request 172 + ); 173 + 174 + return new JsonResponse(['id' => $order->getId()], 201); 175 + } 176 + } 177 + ``` 178 + 179 + ## Controller Patterns 180 + 181 + ### Maximum 5-10 Lines Per Action 182 + 183 + ```php 184 + #[Route('/posts/{id}', methods: ['PUT'])] 185 + public function update( 186 + Post $post, 187 + #[MapRequestPayload] UpdatePostRequest $request 188 + ): JsonResponse { 189 + $this->denyAccessUnlessGranted('EDIT', $post); 190 + 191 + $post = $this->postService->update($post, $request); 192 + 193 + return new JsonResponse(PostOutput::fromEntity($post)); 194 + } 195 + ``` 196 + 197 + ### Use Attributes for Common Tasks 198 + 199 + ```php 200 + use Symfony\Component\Security\Http\Attribute\IsGranted; 201 + 202 + #[Route('/admin/users')] 203 + #[IsGranted('ROLE_ADMIN')] 204 + class AdminUserController extends AbstractController 205 + { 206 + #[Route('', methods: ['GET'])] 207 + public function list(): Response 208 + { 209 + // Already authorized by class attribute 210 + } 211 + } 212 + ``` 213 + 214 + ### MapRequestPayload for Input 215 + 216 + ```php 217 + #[Route('/contact', methods: ['POST'])] 218 + public function contact( 219 + #[MapRequestPayload] ContactRequest $request 220 + ): JsonResponse { 221 + // $request is already validated 222 + $this->contactService->send($request); 223 + 224 + return new JsonResponse(['status' => 'sent']); 225 + } 226 + ``` 227 + 228 + ### EntityValueResolver for Entities 229 + 230 + The legacy SensioFrameworkExtraBundle `ParamConverter` is gone; Doctrine's 231 + built-in `EntityValueResolver` maps route parameters to entities automatically. 232 + 233 + ```php 234 + // {id} is resolved to the Post entity by the EntityValueResolver 235 + #[Route('/posts/{id}', methods: ['GET'])] 236 + public function show(Post $post): Response 237 + { 238 + // 404 handled automatically if not found 239 + return $this->render('post/show.html.twig', ['post' => $post]); 240 + } 241 + ``` 242 + 243 + Use `#[MapEntity]` to control the lookup (non-id field, custom expression, etc.): 244 + 245 + ```php 246 + use Symfony\Bridge\Doctrine\Attribute\MapEntity; 247 + 248 + #[Route('/posts/{slug}', methods: ['GET'])] 249 + public function showBySlug( 250 + #[MapEntity(mapping: ['slug' => 'slug'])] Post $post, 251 + ): Response { 252 + return $this->render('post/show.html.twig', ['post' => $post]); 253 + } 254 + ``` 255 + 256 + ### Inject per-argument, never pull from the container 257 + 258 + Type-hint the services you need as action arguments (or constructor args). Don't 259 + reach into the container with `$this->container->get(...)` / `$this->get(...)` — 260 + that hides dependencies and breaks autowiring/testing. 261 + 262 + ```php 263 + // GOOD — explicit, autowired, testable 264 + #[Route('/reports', methods: ['GET'])] 265 + public function reports(ReportBuilder $reports): Response 266 + { 267 + return $this->json($reports->forCurrentUser($this->getUser())); 268 + } 269 + 270 + // BAD 271 + // $reports = $this->container->get(ReportBuilder::class); 272 + ``` 273 + 274 + ### Console: invokable commands (no base class) 275 + 276 + The same "thin entry point + service" discipline applies to commands. Since 277 + Symfony 7.3 an `#[AsCommand]` class with `__invoke()` no longer needs to extend 278 + `Command`: 279 + 280 + ```php 281 + use Symfony\Component\Console\Attribute\Argument; 282 + use Symfony\Component\Console\Attribute\AsCommand; 283 + use Symfony\Component\Console\Command\Command; 284 + use Symfony\Component\Console\Style\SymfonyStyle; 285 + 286 + #[AsCommand(name: 'app:create-user', description: 'Creates a user.')] 287 + final class CreateUserCommand 288 + { 289 + public function __construct(private UserService $users) {} 290 + 291 + public function __invoke( 292 + SymfonyStyle $io, 293 + #[Argument('The username.')] string $username, 294 + ): int { 295 + $this->users->create($username); 296 + $io->success("Created {$username}"); 297 + 298 + return Command::SUCCESS; 299 + } 300 + } 301 + ``` 302 + 303 + ## Extract Responsibilities 304 + 305 + ### Validation → DTO + Validator 306 + 307 + ```php 308 + // DTO handles validation rules 309 + final readonly class CreateUserRequest 310 + { 311 + #[Assert\NotBlank] 312 + #[Assert\Email] 313 + public string $email; 314 + 315 + #[Assert\NotBlank] 316 + #[Assert\Length(min: 8)] 317 + public string $password; 318 + } 319 + ``` 320 + 321 + ### Business Logic → Service 322 + 323 + ```php 324 + // Service handles business rules 325 + class UserService 326 + { 327 + public function register(CreateUserRequest $request): User 328 + { 329 + $this->ensureEmailUnique($request->email); 330 + $user = User::register($request->email, $request->password); 331 + $this->em->persist($user); 332 + $this->em->flush(); 333 + return $user; 334 + } 335 + } 336 + ``` 337 + 338 + ### Authorization → Voter 339 + 340 + ```php 341 + // Voter handles access control 342 + class PostVoter extends Voter 343 + { 344 + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool 345 + { 346 + return match ($attribute) { 347 + 'EDIT' => $subject->getAuthor() === $token->getUser(), 348 + default => false, 349 + }; 350 + } 351 + } 352 + ``` 353 + 354 + ### Notifications → Events/Messages 355 + 356 + ```php 357 + // Async via Messenger 358 + class OrderService 359 + { 360 + public function create(CreateOrderRequest $request): Order 361 + { 362 + // ... create order 363 + $this->bus->dispatch(new SendOrderConfirmation($order->getId())); 364 + return $order; 365 + } 366 + } 367 + ``` 368 + 369 + ## Testing Lean Controllers 370 + 371 + ```php 372 + class OrderControllerTest extends WebTestCase 373 + { 374 + public function testCreateOrder(): void 375 + { 376 + $user = UserFactory::createOne(); 377 + ProductFactory::createMany(3); 378 + 379 + $this->client->loginUser($user->object()); 380 + $this->client->request('POST', '/api/orders', [], [], [ 381 + 'CONTENT_TYPE' => 'application/json', 382 + ], json_encode([ 383 + 'items' => [ 384 + ['productId' => 1, 'quantity' => 2], 385 + ], 386 + ])); 387 + 388 + $this->assertResponseStatusCodeSame(201); 389 + } 390 + } 391 + ``` 392 + 393 + ## Checklist 394 + 395 + - [ ] Controller actions ≤ 10 lines 396 + - [ ] No `new Entity()` in controller 397 + - [ ] No direct EntityManager usage 398 + - [ ] Use DTOs for input 399 + - [ ] Use services for business logic 400 + - [ ] Use voters for authorization 401 + - [ ] Use events/messages for side effects 402 + 403 + 404 + ## Skill Operating Checklist 405 + 406 + ### Design checklist 407 + - Confirm operation boundaries and invariants first. 408 + - Minimize scope while preserving contract correctness. 409 + - Test both happy path and negative path behavior. 410 + 411 + ### Validation commands 412 + - rg --files 413 + - composer validate 414 + - ./vendor/bin/phpstan analyse 415 + 416 + ### Failure modes to test 417 + - Invalid payload or forbidden actor. 418 + - Boundary values / not-found cases. 419 + - Retry or partial-failure behavior for async flows. 420 +
+39
.agents/skills/symfony-cqrs-and-handlers/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-cqrs-and-handlers 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Implement CQRS in Symfony with separate Command and Query buses/handlers using the Messenger component 9 + --- 10 + 11 + # Cqrs And Handlers (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+432
.agents/skills/symfony-cqrs-and-handlers/reference.md
··· 1 + # Reference 2 + 3 + # CQRS with Symfony Messenger 4 + 5 + ## Overview 6 + 7 + CQRS (Command Query Responsibility Segregation) separates read and write operations: 8 + - **Commands**: Change state (Create, Update, Delete) 9 + - **Queries**: Read state (no side effects) 10 + 11 + ## Project Structure 12 + 13 + ``` 14 + src/ 15 + ├── Application/ 16 + │ ├── Command/ 17 + │ │ ├── CreateOrder.php 18 + │ │ └── CreateOrderHandler.php 19 + │ └── Query/ 20 + │ ├── GetOrder.php 21 + │ └── GetOrderHandler.php 22 + ├── Domain/ 23 + │ └── Order/ 24 + │ └── Entity/Order.php 25 + └── Infrastructure/ 26 + └── Controller/ 27 + └── OrderController.php 28 + ``` 29 + 30 + ## Commands 31 + 32 + ### Command Class 33 + 34 + ```php 35 + <?php 36 + // src/Application/Command/CreateOrder.php 37 + 38 + namespace App\Application\Command; 39 + 40 + final readonly class CreateOrder 41 + { 42 + public function __construct( 43 + public int $customerId, 44 + public array $items, 45 + public ?string $couponCode = null, 46 + ) {} 47 + } 48 + ``` 49 + 50 + ### Command Handler 51 + 52 + ```php 53 + <?php 54 + // src/Application/Command/CreateOrderHandler.php 55 + 56 + namespace App\Application\Command; 57 + 58 + use App\Domain\Order\Entity\Order; 59 + use App\Domain\Order\Repository\OrderRepositoryInterface; 60 + use Symfony\Component\Messenger\Attribute\AsMessageHandler; 61 + 62 + #[AsMessageHandler] 63 + final readonly class CreateOrderHandler 64 + { 65 + public function __construct( 66 + private OrderRepositoryInterface $orders, 67 + private ProductService $products, 68 + private CouponService $coupons, 69 + ) {} 70 + 71 + public function __invoke(CreateOrder $command): Order 72 + { 73 + // Validate products exist 74 + $items = $this->products->resolveItems($command->items); 75 + 76 + // Create order 77 + $order = Order::create( 78 + $this->orders->nextId(), 79 + $command->customerId, 80 + ); 81 + 82 + foreach ($items as $item) { 83 + $order->addItem($item); 84 + } 85 + 86 + // Apply coupon if provided 87 + if ($command->couponCode) { 88 + $discount = $this->coupons->apply($command->couponCode, $order); 89 + $order->applyDiscount($discount); 90 + } 91 + 92 + $this->orders->save($order); 93 + 94 + return $order; 95 + } 96 + } 97 + ``` 98 + 99 + ## Queries 100 + 101 + ### Query Class 102 + 103 + ```php 104 + <?php 105 + // src/Application/Query/GetOrder.php 106 + 107 + namespace App\Application\Query; 108 + 109 + final readonly class GetOrder 110 + { 111 + public function __construct( 112 + public string $orderId, 113 + ) {} 114 + } 115 + 116 + // src/Application/Query/GetOrdersByCustomer.php 117 + 118 + final readonly class GetOrdersByCustomer 119 + { 120 + public function __construct( 121 + public int $customerId, 122 + public int $page = 1, 123 + public int $limit = 20, 124 + ) {} 125 + } 126 + ``` 127 + 128 + ### Query Handler 129 + 130 + ```php 131 + <?php 132 + // src/Application/Query/GetOrderHandler.php 133 + 134 + namespace App\Application\Query; 135 + 136 + use App\Domain\Order\Repository\OrderRepositoryInterface; 137 + use App\Dto\OrderView; 138 + use Symfony\Component\Messenger\Attribute\AsMessageHandler; 139 + 140 + #[AsMessageHandler] 141 + final readonly class GetOrderHandler 142 + { 143 + public function __construct( 144 + private OrderRepositoryInterface $orders, 145 + ) {} 146 + 147 + public function __invoke(GetOrder $query): ?OrderView 148 + { 149 + $order = $this->orders->findById($query->orderId); 150 + 151 + if (!$order) { 152 + return null; 153 + } 154 + 155 + return OrderView::fromEntity($order); 156 + } 157 + } 158 + 159 + // src/Application/Query/GetOrdersByCustomerHandler.php 160 + 161 + #[AsMessageHandler] 162 + final readonly class GetOrdersByCustomerHandler 163 + { 164 + public function __construct( 165 + private OrderReadRepository $readRepository, 166 + ) {} 167 + 168 + public function __invoke(GetOrdersByCustomer $query): PaginatedResult 169 + { 170 + return $this->readRepository->findByCustomer( 171 + $query->customerId, 172 + $query->page, 173 + $query->limit, 174 + ); 175 + } 176 + } 177 + ``` 178 + 179 + ## Separate Buses 180 + 181 + ### Configuration 182 + 183 + ```yaml 184 + # config/packages/messenger.yaml 185 + framework: 186 + messenger: 187 + default_bus: command_bus 188 + 189 + buses: 190 + command_bus: 191 + middleware: 192 + - validation 193 + - doctrine_transaction 194 + 195 + query_bus: 196 + middleware: 197 + - validation 198 + 199 + # Route by namespace so commands/queries land on the right bus. 200 + routing: 201 + 'App\Application\Command\*': command_bus 202 + 'App\Application\Query\*': query_bus 203 + ``` 204 + 205 + ### Injecting the native buses directly 206 + 207 + You don't need the wrapper interfaces below — Symfony registers each bus as 208 + `messenger.bus.<name>`. Inject them with `#[Autowire]` and type 209 + `MessageBusInterface`: 210 + 211 + ```php 212 + use Symfony\Component\DependencyInjection\Attribute\Autowire; 213 + use Symfony\Component\Messenger\HandleTrait; 214 + use Symfony\Component\Messenger\MessageBusInterface; 215 + 216 + class OrderController extends AbstractController 217 + { 218 + public function __construct( 219 + #[Autowire('@messenger.bus.command_bus')] 220 + private MessageBusInterface $commandBus, 221 + #[Autowire('@messenger.bus.query_bus')] 222 + private MessageBusInterface $queryBus, 223 + ) {} 224 + } 225 + ``` 226 + 227 + To get the handler's return value synchronously (queries), use `HandleTrait` 228 + in a thin service as shown in the next section. 229 + 230 + ### Bus Interfaces 231 + 232 + ```php 233 + <?php 234 + // src/Application/Bus/CommandBusInterface.php 235 + 236 + namespace App\Application\Bus; 237 + 238 + interface CommandBusInterface 239 + { 240 + public function dispatch(object $command): mixed; 241 + } 242 + 243 + // src/Application/Bus/QueryBusInterface.php 244 + 245 + interface QueryBusInterface 246 + { 247 + public function ask(object $query): mixed; 248 + } 249 + ``` 250 + 251 + ### Implementations 252 + 253 + ```php 254 + <?php 255 + // src/Infrastructure/Bus/MessengerCommandBus.php 256 + 257 + namespace App\Infrastructure\Bus; 258 + 259 + use App\Application\Bus\CommandBusInterface; 260 + use Symfony\Component\Messenger\HandleTrait; 261 + use Symfony\Component\Messenger\MessageBusInterface; 262 + 263 + final class MessengerCommandBus implements CommandBusInterface 264 + { 265 + use HandleTrait; 266 + 267 + public function __construct(MessageBusInterface $commandBus) 268 + { 269 + $this->messageBus = $commandBus; 270 + } 271 + 272 + public function dispatch(object $command): mixed 273 + { 274 + return $this->handle($command); 275 + } 276 + } 277 + 278 + // src/Infrastructure/Bus/MessengerQueryBus.php 279 + 280 + final class MessengerQueryBus implements QueryBusInterface 281 + { 282 + use HandleTrait; 283 + 284 + public function __construct(MessageBusInterface $queryBus) 285 + { 286 + $this->messageBus = $queryBus; 287 + } 288 + 289 + public function ask(object $query): mixed 290 + { 291 + return $this->handle($query); 292 + } 293 + } 294 + ``` 295 + 296 + ### Service Configuration 297 + 298 + ```yaml 299 + # config/services.yaml 300 + services: 301 + App\Application\Bus\CommandBusInterface: 302 + class: App\Infrastructure\Bus\MessengerCommandBus 303 + arguments: ['@command.bus'] 304 + 305 + App\Application\Bus\QueryBusInterface: 306 + class: App\Infrastructure\Bus\MessengerQueryBus 307 + arguments: ['@query.bus'] 308 + ``` 309 + 310 + ## Controller Usage 311 + 312 + ```php 313 + <?php 314 + // src/Infrastructure/Controller/OrderController.php 315 + 316 + namespace App\Infrastructure\Controller; 317 + 318 + use App\Application\Bus\CommandBusInterface; 319 + use App\Application\Bus\QueryBusInterface; 320 + use App\Application\Command\CreateOrder; 321 + use App\Application\Query\GetOrder; 322 + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 323 + use Symfony\Component\HttpFoundation\JsonResponse; 324 + use Symfony\Component\HttpFoundation\Request; 325 + use Symfony\Component\Routing\Attribute\Route; 326 + 327 + #[Route('/api/orders')] 328 + class OrderController extends AbstractController 329 + { 330 + public function __construct( 331 + private CommandBusInterface $commandBus, 332 + private QueryBusInterface $queryBus, 333 + ) {} 334 + 335 + #[Route('', methods: ['POST'])] 336 + public function create(Request $request): JsonResponse 337 + { 338 + $data = json_decode($request->getContent(), true); 339 + 340 + $order = $this->commandBus->dispatch(new CreateOrder( 341 + customerId: $data['customerId'], 342 + items: $data['items'], 343 + couponCode: $data['couponCode'] ?? null, 344 + )); 345 + 346 + return new JsonResponse(['id' => $order->getId()], 201); 347 + } 348 + 349 + #[Route('/{id}', methods: ['GET'])] 350 + public function show(string $id): JsonResponse 351 + { 352 + $order = $this->queryBus->ask(new GetOrder($id)); 353 + 354 + if (!$order) { 355 + throw $this->createNotFoundException(); 356 + } 357 + 358 + return new JsonResponse($order); 359 + } 360 + } 361 + ``` 362 + 363 + ## Read Models (Optional) 364 + 365 + For complex reads, use dedicated read models: 366 + 367 + ```php 368 + <?php 369 + // src/Infrastructure/ReadModel/OrderReadRepository.php 370 + 371 + namespace App\Infrastructure\ReadModel; 372 + 373 + use Doctrine\DBAL\Connection; 374 + 375 + class OrderReadRepository 376 + { 377 + public function __construct( 378 + private Connection $connection, 379 + ) {} 380 + 381 + public function findByCustomer(int $customerId, int $page, int $limit): PaginatedResult 382 + { 383 + // Direct SQL for optimized reads 384 + $sql = <<<SQL 385 + SELECT o.id, o.total, o.status, o.created_at, 386 + COUNT(i.id) as item_count 387 + FROM orders o 388 + LEFT JOIN order_items i ON i.order_id = o.id 389 + WHERE o.customer_id = :customerId 390 + GROUP BY o.id 391 + ORDER BY o.created_at DESC 392 + LIMIT :limit OFFSET :offset 393 + SQL; 394 + 395 + $results = $this->connection->fetchAllAssociative($sql, [ 396 + 'customerId' => $customerId, 397 + 'limit' => $limit, 398 + 'offset' => ($page - 1) * $limit, 399 + ]); 400 + 401 + return new PaginatedResult($results, $this->countByCustomer($customerId)); 402 + } 403 + } 404 + ``` 405 + 406 + ## Best Practices 407 + 408 + 1. **Commands change state**: Never return data from commands (except ID) 409 + 2. **Queries are side-effect free**: Can be cached, retried 410 + 3. **Separate handlers**: One handler per command/query 411 + 4. **Validation in commands**: Use Symfony Validator 412 + 5. **Read models for complex queries**: Optimize separately 413 + 6. **Transaction on commands**: Wrap in database transaction 414 + 415 + 416 + ## Skill Operating Checklist 417 + 418 + ### Design checklist 419 + - Confirm operation boundaries and invariants first. 420 + - Minimize scope while preserving contract correctness. 421 + - Test both happy path and negative path behavior. 422 + 423 + ### Validation commands 424 + - rg --files 425 + - composer validate 426 + - ./vendor/bin/phpstan analyse 427 + 428 + ### Failure modes to test 429 + - Invalid payload or forbidden actor. 430 + - Boundary values / not-found cases. 431 + - Retry or partial-failure behavior for async flows. 432 +
+39
.agents/skills/symfony-daily-workflow/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-daily-workflow 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Daily development workflow for Symfony projects including common tasks, debugging, and productivity tips 9 + --- 10 + 11 + # Daily Workflow (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+331
.agents/skills/symfony-daily-workflow/reference.md
··· 1 + # Reference 2 + 3 + # Daily Symfony Development Workflow 4 + 5 + ## Starting Your Day 6 + 7 + ### 1. Update Dependencies (if needed) 8 + 9 + ```bash 10 + # Pull latest code 11 + git pull origin main 12 + 13 + # Update dependencies 14 + composer install 15 + 16 + # Run migrations 17 + bin/console doctrine:migrations:migrate --no-interaction 18 + 19 + # Clear cache 20 + bin/console cache:clear 21 + ``` 22 + 23 + ### 2. Start Services 24 + 25 + ```bash 26 + # Docker Compose 27 + docker compose up -d 28 + 29 + # Or Symfony Docker 30 + docker compose up -d --wait 31 + 32 + # Start Symfony server (if not using Docker) 33 + symfony server:start -d 34 + ``` 35 + 36 + ### 3. Check Status 37 + 38 + ```bash 39 + # Verify database connection 40 + bin/console doctrine:query:sql "SELECT 1" 41 + 42 + # Check messenger transports 43 + bin/console messenger:stats 44 + 45 + # Verify cache is working 46 + bin/console cache:pool:list 47 + ``` 48 + 49 + ## Common Development Tasks 50 + 51 + ### Creating New Features 52 + 53 + ```bash 54 + # 1. Create entity 55 + bin/console make:entity Product 56 + 57 + # 2. Create migration 58 + bin/console make:migration 59 + 60 + # 3. Run migration 61 + bin/console doctrine:migrations:migrate 62 + 63 + # 4. Create controller 64 + bin/console make:controller ProductController 65 + 66 + # 5. Create form (if needed) 67 + bin/console make:form ProductType 68 + 69 + # 6. Create test 70 + bin/console make:test WebTestCase ProductControllerTest 71 + ``` 72 + 73 + ### Working with Doctrine 74 + 75 + ```bash 76 + # Validate mapping 77 + bin/console doctrine:schema:validate 78 + 79 + # Show SQL that would be executed 80 + bin/console doctrine:schema:update --dump-sql 81 + 82 + # Generate migration from entity changes 83 + bin/console make:migration 84 + 85 + # Load fixtures 86 + bin/console doctrine:fixtures:load 87 + 88 + # Reset database 89 + bin/console doctrine:database:drop --force 90 + bin/console doctrine:database:create 91 + bin/console doctrine:migrations:migrate --no-interaction 92 + bin/console doctrine:fixtures:load --no-interaction 93 + ``` 94 + 95 + ### Working with Messenger 96 + 97 + ```bash 98 + # Process messages 99 + bin/console messenger:consume async -vv 100 + 101 + # Process with limits 102 + bin/console messenger:consume async --limit=10 --time-limit=60 103 + 104 + # View failed messages 105 + bin/console messenger:failed:show 106 + 107 + # Retry failed messages 108 + bin/console messenger:failed:retry --all 109 + 110 + # Stop workers gracefully 111 + bin/console messenger:stop-workers 112 + ``` 113 + 114 + ## Debugging 115 + 116 + ### Debug Tools 117 + 118 + ```bash 119 + # Debug routes 120 + bin/console debug:router 121 + bin/console debug:router api_products_get_collection 122 + 123 + # Debug container/services 124 + bin/console debug:container 125 + bin/console debug:container ProductService 126 + bin/console debug:autowiring Product 127 + 128 + # Debug configuration 129 + bin/console debug:config framework 130 + bin/console debug:config api_platform 131 + 132 + # Debug event dispatcher 133 + bin/console debug:event-dispatcher 134 + bin/console debug:event-dispatcher kernel.request 135 + ``` 136 + 137 + ### Profiler 138 + 139 + ```bash 140 + # Enable profiler (dev only) 141 + # Visit: /_profiler 142 + 143 + # Check latest profiles via CLI 144 + bin/console profiler:list 145 + ``` 146 + 147 + ### Logging 148 + 149 + ```bash 150 + # Tail logs 151 + tail -f var/log/dev.log 152 + 153 + # Filter logs 154 + grep "ERROR" var/log/dev.log 155 + grep "doctrine" var/log/dev.log 156 + ``` 157 + 158 + ### Dump and Die 159 + 160 + ```php 161 + // In code 162 + dump($variable); // Dump but continue 163 + dd($variable); // Dump and die 164 + 165 + // In Twig 166 + {{ dump(variable) }} 167 + ``` 168 + 169 + ## Testing Workflow 170 + 171 + ### Running Tests 172 + 173 + ```bash 174 + # All tests 175 + ./vendor/bin/phpunit 176 + # Or with Pest 177 + ./vendor/bin/pest 178 + 179 + # Specific test file 180 + ./vendor/bin/pest tests/Functional/Api/ProductTest.php 181 + 182 + # Specific test method 183 + ./vendor/bin/pest --filter "creates product" 184 + 185 + # With coverage 186 + ./vendor/bin/pest --coverage --min=80 187 + 188 + # Parallel execution 189 + ./vendor/bin/pest --parallel 190 + ``` 191 + 192 + ### TDD Cycle 193 + 194 + ```bash 195 + # 1. Write failing test 196 + ./vendor/bin/pest tests/Unit/Service/ProductServiceTest.php 197 + 198 + # 2. Implement minimum code to pass 199 + 200 + # 3. Run test again - should pass 201 + ./vendor/bin/pest tests/Unit/Service/ProductServiceTest.php 202 + 203 + # 4. Refactor 204 + 205 + # 5. Run all tests 206 + ./vendor/bin/pest 207 + ``` 208 + 209 + ## Code Quality 210 + 211 + ### Before Committing 212 + 213 + ```bash 214 + # Fix code style 215 + ./vendor/bin/php-cs-fixer fix 216 + 217 + # Run static analysis 218 + ./vendor/bin/phpstan analyse 219 + 220 + # Run tests 221 + ./vendor/bin/pest 222 + 223 + # All checks 224 + composer run-script check 225 + ``` 226 + 227 + ### Pre-commit Hook 228 + 229 + ```bash 230 + #!/bin/sh 231 + # .git/hooks/pre-commit 232 + 233 + ./vendor/bin/php-cs-fixer fix --dry-run 234 + if [ $? -ne 0 ]; then 235 + echo "Fix code style before committing" 236 + exit 1 237 + fi 238 + 239 + ./vendor/bin/phpstan analyse 240 + if [ $? -ne 0 ]; then 241 + echo "Fix PHPStan errors before committing" 242 + exit 1 243 + fi 244 + ``` 245 + 246 + ## API Development 247 + 248 + ### Testing API Endpoints 249 + 250 + ```bash 251 + # Using curl 252 + curl -X GET http://localhost/api/products 253 + curl -X POST http://localhost/api/products \ 254 + -H "Content-Type: application/json" \ 255 + -d '{"name": "Test", "price": 1999}' 256 + 257 + # Using httpie (cleaner) 258 + http GET localhost/api/products 259 + http POST localhost/api/products name="Test" price:=1999 260 + ``` 261 + 262 + ### API Documentation 263 + 264 + ```bash 265 + # Generate OpenAPI spec 266 + bin/console api:openapi:export --output=openapi.json 267 + 268 + # View in browser 269 + # http://localhost/api/docs 270 + ``` 271 + 272 + ## End of Day 273 + 274 + ### Clean Up 275 + 276 + ```bash 277 + # Stop services 278 + docker compose down 279 + 280 + # Or keep data but stop containers 281 + docker compose stop 282 + ``` 283 + 284 + ### Commit Work 285 + 286 + ```bash 287 + # Check status 288 + git status 289 + 290 + # Stage changes 291 + git add -p # Interactive staging 292 + 293 + # Commit 294 + git commit -m "feat: add product filtering" 295 + 296 + # Push 297 + git push origin feature/product-filtering 298 + ``` 299 + 300 + ## Quick Reference 301 + 302 + | Task | Command | 303 + |------|---------| 304 + | Clear cache | `bin/console cache:clear` | 305 + | Run migrations | `bin/console doctrine:migrations:migrate` | 306 + | Load fixtures | `bin/console doctrine:fixtures:load` | 307 + | Run tests | `./vendor/bin/pest` | 308 + | Fix code style | `./vendor/bin/php-cs-fixer fix` | 309 + | Static analysis | `./vendor/bin/phpstan analyse` | 310 + | Debug routes | `bin/console debug:router` | 311 + | Debug services | `bin/console debug:container` | 312 + | Consume messages | `bin/console messenger:consume async` | 313 + 314 + 315 + ## Skill Operating Checklist 316 + 317 + ### Design checklist 318 + - Confirm operation boundaries and invariants first. 319 + - Minimize scope while preserving contract correctness. 320 + - Test both happy path and negative path behavior. 321 + 322 + ### Validation commands 323 + - rg --files 324 + - composer validate 325 + - ./vendor/bin/phpstan analyse 326 + 327 + ### Failure modes to test 328 + - Invalid payload or forbidden actor. 329 + - Boundary values / not-found cases. 330 + - Retry or partial-failure behavior for async flows. 331 +
+42
.agents/skills/symfony-doctrine-batch-processing/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-doctrine-batch-processing 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Process large datasets with Doctrine (ORM 3 toIterable, flush+clear, bulk DQL) and memory management 12 + --- 13 + 14 + # Doctrine Batch Processing (Symfony) 15 + 16 + ## Use when 17 + - Designing entity relations or schema evolution. 18 + - Improving Doctrine correctness/performance. 19 + 20 + ## Default workflow 21 + 1. Model ownership/cardinality and transactional boundaries. 22 + 2. Apply mapping/schema changes with migration safety. 23 + 3. Tune fetch/query behavior for hot paths. 24 + 4. Verify lifecycle behavior with targeted tests. 25 + 26 + ## Guardrails 27 + - Keep owning/inverse sides coherent. 28 + - Avoid destructive migration jumps in one release. 29 + - Eliminate accidental N+1 and over-fetching. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Entity/migration changes. 37 + - Integrity and performance decisions. 38 + - Validation outcomes and rollback notes. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+306
.agents/skills/symfony-doctrine-batch-processing/reference.md
··· 1 + # Reference 2 + 3 + # Doctrine Batch Processing 4 + 5 + ## The Problem 6 + 7 + ```php 8 + // BAD: Loads all entities into memory 9 + $products = $repository->findAll(); 10 + foreach ($products as $product) { 11 + $this->process($product); 12 + } 13 + // Out of Memory with large datasets! 14 + ``` 15 + 16 + ## Solution 1: Iterate with toIterable() 17 + 18 + ```php 19 + <?php 20 + 21 + // GOOD: Process one at a time 22 + $query = $em->createQuery('SELECT p FROM Product p'); 23 + 24 + foreach ($query->toIterable() as $product) { 25 + $this->process($product); 26 + 27 + // Clear managed entities periodically 28 + $em->clear(); 29 + } 30 + ``` 31 + 32 + > **ORM 3 breaking change**: `Query#iterate()` was deprecated in ORM 2.7 and 33 + > **removed in ORM 3.0**. Use `toIterable()` (available since 2.7, the only 34 + > option in 3.x/4.x). You cannot iterate a query that fetch-joins a 35 + > collection-valued association. 36 + 37 + ## Solution 2: Batch with Clear 38 + 39 + ```php 40 + <?php 41 + 42 + const BATCH_SIZE = 100; 43 + 44 + $query = $em->createQuery('SELECT p FROM Product p'); 45 + $i = 0; 46 + 47 + foreach ($query->toIterable() as $product) { 48 + $product->setProcessedAt(new \DateTimeImmutable()); 49 + $i++; 50 + 51 + if ($i % self::BATCH_SIZE === 0) { 52 + $em->flush(); 53 + $em->clear(); 54 + gc_collect_cycles(); 55 + } 56 + } 57 + 58 + // Flush remaining 59 + $em->flush(); 60 + $em->clear(); 61 + ``` 62 + 63 + ## Solution 3: ID-Based Pagination 64 + 65 + ```php 66 + <?php 67 + 68 + class BatchProcessor 69 + { 70 + private const BATCH_SIZE = 1000; 71 + 72 + public function processAll(): void 73 + { 74 + $lastId = 0; 75 + 76 + while (true) { 77 + $products = $this->em->createQueryBuilder() 78 + ->select('p') 79 + ->from(Product::class, 'p') 80 + ->where('p.id > :lastId') 81 + ->setParameter('lastId', $lastId) 82 + ->orderBy('p.id', 'ASC') 83 + ->setMaxResults(self::BATCH_SIZE) 84 + ->getQuery() 85 + ->getResult(); 86 + 87 + if (empty($products)) { 88 + break; 89 + } 90 + 91 + foreach ($products as $product) { 92 + $this->process($product); 93 + $lastId = $product->getId(); 94 + } 95 + 96 + $this->em->flush(); 97 + $this->em->clear(); 98 + } 99 + } 100 + } 101 + ``` 102 + 103 + ## Solution 3b: DQL Bulk UPDATE / DELETE 104 + 105 + For mass updates/deletes, a single DQL `UPDATE`/`DELETE` statement is the most 106 + efficient — it bypasses hydration and the unit of work entirely: 107 + 108 + ```php 109 + <?php 110 + 111 + // Bulk update — runs one SQL UPDATE, no entities loaded 112 + $updated = $em->createQuery( 113 + 'UPDATE App\Entity\Product p SET p.price = p.price * 0.9 WHERE p.discontinued = true' 114 + )->execute(); 115 + 116 + // Bulk delete 117 + $deleted = $em->createQuery( 118 + 'DELETE App\Entity\Product p WHERE p.createdAt < :cutoff' 119 + )->setParameter('cutoff', new \DateTimeImmutable('-1 year')) 120 + ->execute(); 121 + ``` 122 + 123 + DQL UPDATE/DELETE do **not** trigger lifecycle events or update already-managed 124 + objects in memory — clear or re-fetch afterwards if you keep working with them. 125 + 126 + ## Solution 4: DBAL for Bulk Updates 127 + 128 + ```php 129 + <?php 130 + 131 + use Doctrine\DBAL\Connection; 132 + 133 + class BulkUpdater 134 + { 135 + public function __construct( 136 + private Connection $connection, 137 + ) {} 138 + 139 + public function markAllProcessed(): int 140 + { 141 + return $this->connection->executeStatement( 142 + 'UPDATE product SET processed_at = NOW() WHERE processed_at IS NULL' 143 + ); 144 + } 145 + 146 + public function updatePrices(array $updates): void 147 + { 148 + $this->connection->beginTransaction(); 149 + 150 + try { 151 + $stmt = $this->connection->prepare( 152 + 'UPDATE product SET price = :price WHERE id = :id' 153 + ); 154 + 155 + foreach ($updates as $id => $price) { 156 + $stmt->executeStatement(['id' => $id, 'price' => $price]); 157 + } 158 + 159 + $this->connection->commit(); 160 + } catch (\Exception $e) { 161 + $this->connection->rollBack(); 162 + throw $e; 163 + } 164 + } 165 + } 166 + ``` 167 + 168 + ## Solution 5: Bulk Insert 169 + 170 + ```php 171 + <?php 172 + 173 + class BulkInserter 174 + { 175 + private const BATCH_SIZE = 500; 176 + 177 + public function importProducts(array $data): void 178 + { 179 + // NOTE: in ORM 2.x you would call 180 + // $this->em->getConnection()->getConfiguration()->setSQLLogger(null); 181 + // to avoid memory growth. That method is DEPRECATED in DBAL 3+ and gone 182 + // in DBAL 4 (logging moved to PSR-3 middleware). On ORM 3 / DBAL 4 you 183 + // disable logging via configuration instead of this call. 184 + 185 + $batches = array_chunk($data, self::BATCH_SIZE); 186 + 187 + foreach ($batches as $batch) { 188 + foreach ($batch as $item) { 189 + $product = new Product(); 190 + $product->setName($item['name']); 191 + $product->setPrice($item['price']); 192 + $this->em->persist($product); 193 + } 194 + 195 + $this->em->flush(); 196 + $this->em->clear(); 197 + } 198 + } 199 + } 200 + ``` 201 + 202 + ## Memory Monitoring 203 + 204 + ```php 205 + <?php 206 + 207 + class BatchProcessor 208 + { 209 + public function process(): void 210 + { 211 + $startMemory = memory_get_usage(); 212 + 213 + foreach ($query->toIterable() as $i => $entity) { 214 + $this->processEntity($entity); 215 + 216 + if ($i % 100 === 0) { 217 + $this->em->clear(); 218 + 219 + $currentMemory = memory_get_usage(); 220 + $this->logger->info('Batch progress', [ 221 + 'processed' => $i, 222 + 'memory_mb' => round($currentMemory / 1024 / 1024, 2), 223 + 'memory_delta_mb' => round(($currentMemory - $startMemory) / 1024 / 1024, 2), 224 + ]); 225 + } 226 + } 227 + } 228 + } 229 + ``` 230 + 231 + ## Symfony Command for Batch Processing 232 + 233 + ```php 234 + <?php 235 + // src/Command/ProcessProductsCommand.php 236 + 237 + #[AsCommand(name: 'app:process-products')] 238 + class ProcessProductsCommand extends Command 239 + { 240 + private const BATCH_SIZE = 100; 241 + 242 + protected function execute(InputInterface $input, OutputInterface $output): int 243 + { 244 + $io = new SymfonyStyle($input, $output); 245 + 246 + $query = $this->em->createQuery('SELECT p FROM Product p WHERE p.processedAt IS NULL'); 247 + $total = $this->countUnprocessed(); 248 + 249 + $io->progressStart($total); 250 + 251 + $processed = 0; 252 + foreach ($query->toIterable() as $product) { 253 + $this->processor->process($product); 254 + $processed++; 255 + 256 + if ($processed % self::BATCH_SIZE === 0) { 257 + $this->em->flush(); 258 + $this->em->clear(); 259 + $io->progressAdvance(self::BATCH_SIZE); 260 + } 261 + } 262 + 263 + $this->em->flush(); 264 + $io->progressFinish(); 265 + 266 + $io->success("Processed {$processed} products"); 267 + 268 + return Command::SUCCESS; 269 + } 270 + } 271 + ``` 272 + 273 + ## Best Practices 274 + 275 + 1. **Clear regularly**: `$em->clear()` releases memory 276 + 2. **Use toIterable()**: Don't load all results at once 277 + 3. **DQL UPDATE/DELETE or DBAL for bulk writes**: Skip hydration for mass updates 278 + 4. **Monitor memory**: Log memory usage in long processes 279 + 5. **Disable SQL logging**: Via config/middleware on DBAL 4 (`setSQLLogger()` is gone) 280 + 6. **Progress feedback**: Use SymfonyStyle progress bars 281 + 282 + ## Applicability 283 + 284 + - **ORM 3.x / DBAL 4.x** (target): `toIterable()` only; `Query#iterate()` removed; 285 + `setSQLLogger()` removed (PSR-3 middleware instead). 286 + - **ORM 2.7+ (legacy)**: `toIterable()` available alongside the deprecated 287 + `iterate()`; prefer `toIterable()`. 288 + 289 + 290 + ## Skill Operating Checklist 291 + 292 + ### Design checklist 293 + - Confirm operation boundaries and invariants first. 294 + - Minimize scope while preserving contract correctness. 295 + - Test both happy path and negative path behavior. 296 + 297 + ### Validation commands 298 + - php bin/console doctrine:migrations:diff 299 + - php bin/console doctrine:migrations:migrate 300 + - ./vendor/bin/phpunit --filter=Doctrine 301 + 302 + ### Failure modes to test 303 + - Invalid payload or forbidden actor. 304 + - Boundary values / not-found cases. 305 + - Retry or partial-failure behavior for async flows. 306 +
+44
.agents/skills/symfony-doctrine-events/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-doctrine-events 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: React to Doctrine entity lifecycle in Symfony with attribute listeners (#[AsDoctrineListener]/#[AsEntityListener], ORM 3) and lifecycle callbacks 12 + --- 13 + 14 + # Doctrine Events (Symfony) 15 + 16 + ## Use when 17 + - You need side effects on entity persistence (timestamps, slugs, search indexing, notifications). 18 + - Migrating an `EventSubscriberInterface` Doctrine subscriber to ORM 3. 19 + - Choosing between lifecycle callbacks, entity listeners, and global lifecycle listeners. 20 + 21 + ## Default workflow 22 + 1. Pick the narrowest mechanism: callback (one entity, no deps) → entity listener (one entity, needs services) → lifecycle listener (cross-cutting, all entities). 23 + 2. Wire it with attributes (`#[ORM\HasLifecycleCallbacks]`, `#[AsEntityListener]`, `#[AsDoctrineListener]`). 24 + 3. Type the event argument by its per-event class (`PostPersistEventArgs`, `PreUpdateEventArgs`, …). 25 + 4. Verify the side effect fires with a targeted functional test against a real DB. 26 + 27 + ## Guardrails 28 + - ORM 3.0 removed `EventSubscriberInterface` Doctrine subscribers — never reintroduce them. 29 + - Type-check the entity early in a global lifecycle listener (it fires for every entity). 30 + - Never call `flush()` inside a Doctrine event handler — it corrupts the in-flight unit of work. 31 + - `preUpdate` is restricted: change fields only via `$args->setNewValue()`, never touch associations. 32 + 33 + ## Progressive disclosure 34 + - Use this file for execution posture and risk controls. 35 + - Open `reference.md` for the three mechanisms, the event-args matrix, and migration from subscribers. 36 + 37 + ## Output contract 38 + - Listener/callback class with the correct attribute. 39 + - Justification for the chosen mechanism. 40 + - Validation outcomes (test that the side effect fired). 41 + 42 + ## References 43 + - `reference.md` 44 + - `docs/complexity-tiers.md`
+252
.agents/skills/symfony-doctrine-events/reference.md
··· 1 + # Reference 2 + 3 + # Doctrine Events (Symfony) 4 + 5 + > **ORM 3 breaking change**: Doctrine `EventSubscriberInterface` subscribers were 6 + > **removed in ORM 3.0**. The replacement is attribute-based listeners 7 + > (`#[AsDoctrineListener]`, `#[AsEntityListener]`), available since 8 + > **DoctrineBundle 2.8+**. Each event also gets its own typed argument class 9 + > (`PostPersistEventArgs`, `PreUpdateEventArgs`, …) instead of the single 10 + > `LifecycleEventArgs` from ORM 2.x. 11 + 12 + ## Three mechanisms — which to pick 13 + 14 + | Mechanism | Scope | Container services? | Cost | Use when | 15 + |-----------|-------|---------------------|------|----------| 16 + | **Lifecycle callbacks** | one entity, its own method | no | fastest | self-contained logic (timestamps, slug from own fields) | 17 + | **Entity listeners** | one entity class | yes (autowired) | medium | one entity needs a service (mailer, indexer) | 18 + | **Lifecycle listeners** | every entity | yes | slowest (called for all) | cross-cutting concerns (audit log, global timestamps) | 19 + 20 + Rule of thumb: start with a **callback**; reach for an **entity listener** when 21 + you need a service; use a **global lifecycle listener** only for truly 22 + cross-cutting behavior, and type-check the entity first. 23 + 24 + ## 1. Lifecycle callbacks 25 + 26 + A method on the entity itself. Requires `#[ORM\HasLifecycleCallbacks]` on the 27 + class. 28 + 29 + ```php 30 + <?php 31 + // src/Entity/Product.php 32 + 33 + use Doctrine\ORM\Mapping as ORM; 34 + 35 + #[ORM\Entity] 36 + #[ORM\HasLifecycleCallbacks] 37 + class Product 38 + { 39 + #[ORM\Column] 40 + private \DateTimeImmutable $createdAt; 41 + 42 + #[ORM\Column(nullable: true)] 43 + private ?\DateTimeImmutable $updatedAt = null; 44 + 45 + #[ORM\PrePersist] 46 + public function setCreatedAtValue(): void 47 + { 48 + $this->createdAt = new \DateTimeImmutable(); 49 + } 50 + 51 + #[ORM\PreUpdate] 52 + public function setUpdatedAtValue(): void 53 + { 54 + $this->updatedAt = new \DateTimeImmutable(); 55 + } 56 + } 57 + ``` 58 + 59 + Available callback attributes: `#[ORM\PrePersist]`, `#[ORM\PostPersist]`, 60 + `#[ORM\PreUpdate]`, `#[ORM\PostUpdate]`, `#[ORM\PreRemove]`, 61 + `#[ORM\PostRemove]`, `#[ORM\PostLoad]`. 62 + 63 + Callbacks cannot receive constructor-injected services — keep them to logic that 64 + only touches the entity's own state. 65 + 66 + ## 2. Entity listeners (one entity, with services) 67 + 68 + A separate class bound to one entity. It is a regular autowired service. 69 + 70 + ```php 71 + <?php 72 + // src/EntityListener/UserChangedNotifier.php 73 + 74 + use App\Entity\User; 75 + use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; 76 + use Doctrine\ORM\Event\PostUpdateEventArgs; 77 + use Doctrine\ORM\Events; 78 + 79 + #[AsEntityListener(event: Events::postUpdate, method: 'postUpdate', entity: User::class)] 80 + final class UserChangedNotifier 81 + { 82 + public function __construct( 83 + private NotifierInterface $notifier, 84 + ) {} 85 + 86 + public function postUpdate(User $user, PostUpdateEventArgs $event): void 87 + { 88 + // Fires only for User entities — no type-check needed. 89 + $this->notifier->notifyAccountChange($user); 90 + } 91 + } 92 + ``` 93 + 94 + The handler signature is `(EntityType $entity, <Event>EventArgs $event)`. You 95 + can attach several `#[AsEntityListener]` attributes to one class for different 96 + events. 97 + 98 + YAML equivalent (tag) if not using the attribute: 99 + 100 + ```yaml 101 + services: 102 + App\EntityListener\UserChangedNotifier: 103 + tags: 104 + - { name: doctrine.orm.entity_listener, event: postUpdate, entity: App\Entity\User } 105 + ``` 106 + 107 + ## 3. Lifecycle listeners (all entities, with services) 108 + 109 + A global listener invoked for **every** entity on the given event. Always 110 + type-check. 111 + 112 + ```php 113 + <?php 114 + // src/EventListener/SearchIndexer.php 115 + 116 + use App\Entity\Product; 117 + use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; 118 + use Doctrine\ORM\Event\PostPersistEventArgs; 119 + use Doctrine\ORM\Events; 120 + 121 + #[AsDoctrineListener(event: Events::postPersist, priority: 500, connection: 'default')] 122 + final class SearchIndexer 123 + { 124 + public function __construct( 125 + private SearchClient $search, 126 + ) {} 127 + 128 + public function postPersist(PostPersistEventArgs $args): void 129 + { 130 + $entity = $args->getObject(); 131 + 132 + if (!$entity instanceof Product) { 133 + return; // ignore everything that is not a Product 134 + } 135 + 136 + $em = $args->getObjectManager(); 137 + $this->search->index($entity); 138 + } 139 + } 140 + ``` 141 + 142 + `priority` defaults to `0`; higher runs earlier. `connection` scopes the 143 + listener to one connection. 144 + 145 + YAML equivalent: 146 + 147 + ```yaml 148 + services: 149 + App\EventListener\SearchIndexer: 150 + tags: 151 + - { name: doctrine.event_listener, event: postPersist, priority: 500, connection: default } 152 + ``` 153 + 154 + ## Per-event argument classes (ORM 3) 155 + 156 + The single `LifecycleEventArgs` of ORM 2 is replaced by one class per event, 157 + all in `Doctrine\ORM\Event`: 158 + 159 + | Event (`Events::*`) | Argument class | Notes | 160 + |---------------------|----------------|-------| 161 + | `prePersist` | `PrePersistEventArgs` | before INSERT | 162 + | `postPersist` | `PostPersistEventArgs` | ID is available now | 163 + | `preUpdate` | `PreUpdateEventArgs` | change set only; see below | 164 + | `postUpdate` | `PostUpdateEventArgs` | after UPDATE | 165 + | `preRemove` | `PreRemoveEventArgs` | before DELETE | 166 + | `postRemove` | `PostRemoveEventArgs` | after DELETE | 167 + | `postLoad` | `PostLoadEventArgs` | after hydration | 168 + | `onFlush` | `OnFlushEventArgs` | whole unit of work | 169 + 170 + Common accessors: `$args->getObject()` (the entity), 171 + `$args->getObjectManager()` (the `EntityManagerInterface`). 172 + 173 + ### preUpdate is special 174 + 175 + `preUpdate` runs on a computed change set. You may only change already-changed 176 + fields, through the event API — never modify associations or call other 177 + entities' setters here, and never `flush()`: 178 + 179 + ```php 180 + use Doctrine\ORM\Event\PreUpdateEventArgs; 181 + 182 + public function preUpdate(PreUpdateEventArgs $args): void 183 + { 184 + if ($args->hasChangedField('price')) { 185 + $old = $args->getOldValue('price'); 186 + $new = $args->getNewValue('price'); 187 + // adjust the value being written: 188 + $args->setNewValue('price', max(0, $new)); 189 + } 190 + } 191 + ``` 192 + 193 + ## Migrating from an ORM 2 EventSubscriber 194 + 195 + ```php 196 + // BEFORE (ORM 2 — removed in ORM 3): 197 + class MySubscriber implements \Doctrine\Common\EventSubscriber 198 + { 199 + public function getSubscribedEvents(): array 200 + { 201 + return [Events::postPersist]; 202 + } 203 + 204 + public function postPersist(LifecycleEventArgs $args): void { /* ... */ } 205 + } 206 + ``` 207 + 208 + ```php 209 + // AFTER (ORM 3): 210 + #[AsDoctrineListener(event: Events::postPersist)] 211 + final class MyListener 212 + { 213 + public function postPersist(PostPersistEventArgs $args): void { /* ... */ } 214 + } 215 + ``` 216 + 217 + Steps: drop `implements EventSubscriber` and `getSubscribedEvents()`; add 218 + `#[AsDoctrineListener(event: Events::...)]` (one attribute per event); swap 219 + `LifecycleEventArgs` for the per-event typed class. 220 + 221 + ## Guardrails 222 + 223 + 1. **No subscribers on ORM 3** — they no longer exist; use the attributes. 224 + 2. **Don't `flush()` in a handler** — it re-enters the unit of work mid-flight. 225 + 3. **Type-check in global listeners** — they fire for every entity. 226 + 4. **`preUpdate` = change set only** — use `setNewValue()`, leave associations alone. 227 + 5. **Prefer the narrowest scope** — callback < entity listener < lifecycle listener. 228 + 229 + ## Applicability 230 + 231 + - **ORM 3.x + DoctrineBundle 2.8+** (target): attributes + per-event args, no 232 + subscribers. 233 + - **ORM 2.x (legacy)**: subscribers and the single `LifecycleEventArgs` still 234 + work but are deprecated — migrate before upgrading to 3.0. 235 + 236 + 237 + ## Skill Operating Checklist 238 + 239 + ### Design checklist 240 + - Confirm operation boundaries and invariants first. 241 + - Minimize scope while preserving contract correctness. 242 + - Test both happy path and negative path behavior. 243 + 244 + ### Validation commands 245 + - php bin/console doctrine:migrations:diff 246 + - php bin/console doctrine:migrations:migrate 247 + - ./vendor/bin/phpunit --filter=Doctrine 248 + 249 + ### Failure modes to test 250 + - Invalid payload or forbidden actor. 251 + - Boundary values / not-found cases. 252 + - Retry or partial-failure behavior for async flows.
+42
.agents/skills/symfony-doctrine-fetch-modes/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-doctrine-fetch-modes 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Optimize Doctrine fetching with DTO hydration (SELECT NEW; partial removed in ORM 3), lazy loading, query hints, and DBAL 4 access 12 + --- 13 + 14 + # Doctrine Fetch Modes (Symfony) 15 + 16 + ## Use when 17 + - Designing entity relations or schema evolution. 18 + - Improving Doctrine correctness/performance. 19 + 20 + ## Default workflow 21 + 1. Model ownership/cardinality and transactional boundaries. 22 + 2. Apply mapping/schema changes with migration safety. 23 + 3. Tune fetch/query behavior for hot paths. 24 + 4. Verify lifecycle behavior with targeted tests. 25 + 26 + ## Guardrails 27 + - Keep owning/inverse sides coherent. 28 + - Avoid destructive migration jumps in one release. 29 + - Eliminate accidental N+1 and over-fetching. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Entity/migration changes. 37 + - Integrity and performance decisions. 38 + - Validation outcomes and rollback notes. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+308
.agents/skills/symfony-doctrine-fetch-modes/reference.md
··· 1 + # Reference 2 + 3 + # Doctrine Fetch Modes 4 + 5 + ## Fetch Mode Types 6 + 7 + ### LAZY (Default) 8 + 9 + Relations loaded on first access - can cause N+1: 10 + 11 + ```php 12 + #[ORM\ManyToOne(fetch: 'LAZY')] 13 + private User $author; 14 + 15 + // Usage 16 + $post = $em->find(Post::class, 1); 17 + $name = $post->getAuthor()->getName(); // Triggers query 18 + ``` 19 + 20 + ### EAGER 21 + 22 + Always load with parent - use sparingly: 23 + 24 + ```php 25 + #[ORM\ManyToOne(fetch: 'EAGER')] 26 + private User $author; 27 + 28 + // Usage - author loaded in same query 29 + $post = $em->find(Post::class, 1); 30 + $name = $post->getAuthor()->getName(); // No extra query 31 + ``` 32 + 33 + ### EXTRA_LAZY 34 + 35 + For large collections - partial operations without full load: 36 + 37 + ```php 38 + #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'post', fetch: 'EXTRA_LAZY')] 39 + private Collection $comments; 40 + 41 + // These don't load the full collection: 42 + $count = $post->getComments()->count(); // COUNT query 43 + $has = $post->getComments()->contains($c); // EXISTS query 44 + $slice = $post->getComments()->slice(0, 5); // LIMIT query 45 + ``` 46 + 47 + ## Query-Level Fetch Mode 48 + 49 + > **ORM 3 note**: `Query::setFetchMode()` was removed. The portable, explicit 50 + > way to eager-load a relation for a single query is a fetch join (`addSelect` 51 + > + `leftJoin`, see below) rather than a per-query fetch-mode override. 52 + 53 + ```php 54 + <?php 55 + 56 + // In repository — fetch join is the ORM 3 replacement for per-query EAGER 57 + public function findWithAuthor(int $id): ?Post 58 + { 59 + return $this->createQueryBuilder('p') 60 + ->addSelect('a') 61 + ->leftJoin('p.author', 'a') 62 + ->where('p.id = :id') 63 + ->setParameter('id', $id) 64 + ->getQuery() 65 + ->getOneOrNullResult(); 66 + } 67 + ``` 68 + 69 + ## Join Fetch (Best Practice) 70 + 71 + Explicitly load relations in query: 72 + 73 + ```php 74 + <?php 75 + // src/Repository/PostRepository.php 76 + 77 + public function findAllWithRelations(): array 78 + { 79 + return $this->createQueryBuilder('p') 80 + ->addSelect('a', 't', 'c') // Include in SELECT 81 + ->leftJoin('p.author', 'a') 82 + ->leftJoin('p.tags', 't') 83 + ->leftJoin('p.comments', 'c') 84 + ->orderBy('p.createdAt', 'DESC') 85 + ->getQuery() 86 + ->getResult(); 87 + } 88 + 89 + public function findByIdWithAuthor(int $id): ?Post 90 + { 91 + return $this->createQueryBuilder('p') 92 + ->addSelect('a') 93 + ->leftJoin('p.author', 'a') 94 + ->where('p.id = :id') 95 + ->setParameter('id', $id) 96 + ->getQuery() 97 + ->getOneOrNullResult(); 98 + } 99 + ``` 100 + 101 + ## Loading Only the Columns You Need (DTO hydration) 102 + 103 + > **ORM 3 breaking change**: the `partial` DQL keyword and 104 + > `Query::HINT_FORCE_PARTIAL_LOAD` were deprecated in ORM 2.x and **removed in 105 + > ORM 3.0**. `SELECT PARTIAL p.{id, title}` no longer parses. Use explicit DTO 106 + > (`SELECT NEW`) hydration instead — it is faster, type-safe, and the partial 107 + > object footguns (uninitialized fields, broken change tracking) are gone. 108 + 109 + Define a plain DTO and project into it with `SELECT NEW`: 110 + 111 + ```php 112 + <?php 113 + // src/Dto/PostListItem.php 114 + 115 + final class PostListItem 116 + { 117 + public function __construct( 118 + public readonly int $id, 119 + public readonly string $title, 120 + public readonly string $authorName, 121 + ) {} 122 + } 123 + ``` 124 + 125 + ```php 126 + // src/Repository/PostRepository.php 127 + 128 + /** @return PostListItem[] */ 129 + public function findPostListItems(): array 130 + { 131 + return $this->createQueryBuilder('p') 132 + ->select('NEW App\Dto\PostListItem(p.id, p.title, a.name)') 133 + ->leftJoin('p.author', 'a') 134 + ->getQuery() 135 + ->getResult(); 136 + } 137 + ``` 138 + 139 + The constructor argument order must match the `NEW` argument order. The result 140 + is an array of `PostListItem`, not managed entities — ideal for read-only lists. 141 + 142 + ## Batch Processing with Iteration 143 + 144 + Process large datasets without memory issues: 145 + 146 + ```php 147 + public function processAllPosts(): void 148 + { 149 + $query = $this->createQueryBuilder('p') 150 + ->getQuery(); 151 + 152 + foreach ($query->toIterable() as $post) { 153 + $this->process($post); 154 + 155 + // Clear entity manager periodically 156 + $this->em->clear(Post::class); 157 + } 158 + } 159 + ``` 160 + 161 + ## Proxy Objects 162 + 163 + Understanding lazy loading: 164 + 165 + ```php 166 + // $post->getAuthor() returns a Proxy, not User 167 + $author = $post->getAuthor(); 168 + 169 + // Proxy is a subclass of User 170 + $author instanceof User; // true 171 + 172 + // Check if proxy is initialized 173 + $em->getUnitOfWork()->isInIdentityMap($author); // true if loaded 174 + 175 + // Force initialization 176 + $em->getUnitOfWork()->initializeObject($author); 177 + ``` 178 + 179 + ## Preventing N+1 180 + 181 + ### The Problem 182 + 183 + ```php 184 + // N+1 queries! 185 + $posts = $repository->findAll(); 186 + foreach ($posts as $post) { 187 + echo $post->getAuthor()->getName(); // Query per iteration 188 + } 189 + ``` 190 + 191 + ### The Solution 192 + 193 + ```php 194 + // Single query with join 195 + $posts = $repository->createQueryBuilder('p') 196 + ->addSelect('a') 197 + ->leftJoin('p.author', 'a') 198 + ->getQuery() 199 + ->getResult(); 200 + 201 + foreach ($posts as $post) { 202 + echo $post->getAuthor()->getName(); // No extra query 203 + } 204 + ``` 205 + 206 + ## Query Hints 207 + 208 + ```php 209 + use Doctrine\ORM\Query; 210 + 211 + $query = $em->createQuery('SELECT p FROM Post p'); 212 + 213 + // Force refresh from database 214 + $query->setHint(Query::HINT_REFRESH, true); 215 + 216 + // Custom output walker for soft deletes 217 + $query->setHint( 218 + Query::HINT_CUSTOM_OUTPUT_WALKER, 219 + 'Gedmo\SoftDeleteable\Query\TreeWalker\SoftDeleteableWalker' 220 + ); 221 + ``` 222 + 223 + ## Index By for Fast Lookups 224 + 225 + ```php 226 + public function findAllIndexedById(): array 227 + { 228 + return $this->createQueryBuilder('p', 'p.id') // Index by ID 229 + ->getQuery() 230 + ->getResult(); 231 + } 232 + 233 + // Returns ['1' => Post, '2' => Post, ...] 234 + $posts = $repository->findAllIndexedById(); 235 + $post = $posts[42]; // Direct access, no loop needed 236 + ``` 237 + 238 + ## Read-Only Queries 239 + 240 + Skip change tracking for read-only data: 241 + 242 + ```php 243 + public function findForDisplay(): array 244 + { 245 + return $this->createQueryBuilder('p') 246 + ->getQuery() 247 + ->setHint(Query::HINT_READ_ONLY, true) 248 + ->getResult(); 249 + } 250 + ``` 251 + 252 + ## Best Practices 253 + 254 + 1. **Default to LAZY**: Most relations don't need eager loading 255 + 2. **EXTRA_LAZY for large collections**: count(), contains(), slice() 256 + 3. **Join fetch in repositories**: Explicit control over loading 257 + 4. **Avoid EAGER on mapping**: Fetch join in the query is better 258 + 5. **DTO (`SELECT NEW`) for lists**: Replaces the removed `partial` keyword 259 + 6. **Batch with `toIterable()`**: For large dataset processing 260 + 7. **Profile queries**: Use Symfony profiler to spot N+1 261 + 262 + ## DBAL 4 — method-based API 263 + 264 + When you drop to raw SQL (reports, projections, bulk reads), DBAL 4 is 265 + method-based. The old `query()` / `exec()` / `fetchAll()` were removed: 266 + 267 + ```php 268 + use Doctrine\DBAL\Connection; 269 + 270 + // Reads 271 + $rows = $connection->fetchAllAssociative('SELECT id, title FROM post'); 272 + $row = $connection->fetchAssociative('SELECT * FROM post WHERE id = ?', [$id]); 273 + $value = $connection->fetchOne('SELECT COUNT(*) FROM post'); 274 + $stmt = $connection->executeQuery('SELECT * FROM post WHERE status = ?', [$status]); 275 + 276 + // Writes (returns affected-row count) 277 + $affected = $connection->executeStatement( 278 + 'UPDATE post SET status = ? WHERE status = ?', 279 + ['archived', 'draft'], 280 + ); 281 + ``` 282 + 283 + ## Applicability 284 + 285 + - **ORM 3.x / DBAL 4.x** (target): no `partial` keyword, 286 + no `HINT_FORCE_PARTIAL_LOAD`, no `Query::setFetchMode()`; use `SELECT NEW` 287 + DTOs and fetch joins. DBAL `query()`/`exec()`/`fetchAll()` removed. 288 + - **ORM 2.x (legacy)**: `partial` still parses but is deprecated — migrate to 289 + DTO hydration before upgrading to 3.0. 290 + 291 + 292 + ## Skill Operating Checklist 293 + 294 + ### Design checklist 295 + - Confirm operation boundaries and invariants first. 296 + - Minimize scope while preserving contract correctness. 297 + - Test both happy path and negative path behavior. 298 + 299 + ### Validation commands 300 + - php bin/console doctrine:migrations:diff 301 + - php bin/console doctrine:migrations:migrate 302 + - ./vendor/bin/phpunit --filter=Doctrine 303 + 304 + ### Failure modes to test 305 + - Invalid payload or forbidden actor. 306 + - Boundary values / not-found cases. 307 + - Retry or partial-failure behavior for async flows. 308 +
+42
.agents/skills/symfony-doctrine-fixtures-foundry/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-doctrine-fixtures-foundry 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Create test data with Zenstruck Foundry v2 factories (PersistentObjectFactory, real objects); define states, sequences, and relationships 12 + --- 13 + 14 + # Doctrine Fixtures Foundry (Symfony) 15 + 16 + ## Use when 17 + - Designing entity relations or schema evolution. 18 + - Improving Doctrine correctness/performance. 19 + 20 + ## Default workflow 21 + 1. Model ownership/cardinality and transactional boundaries. 22 + 2. Apply mapping/schema changes with migration safety. 23 + 3. Tune fetch/query behavior for hot paths. 24 + 4. Verify lifecycle behavior with targeted tests. 25 + 26 + ## Guardrails 27 + - Keep owning/inverse sides coherent. 28 + - Avoid destructive migration jumps in one release. 29 + - Eliminate accidental N+1 and over-fetching. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Entity/migration changes. 37 + - Integrity and performance decisions. 38 + - Validation outcomes and rollback notes. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+424
.agents/skills/symfony-doctrine-fixtures-foundry/reference.md
··· 1 + # Reference 2 + 3 + # Doctrine Fixtures with Foundry v2 4 + 5 + Foundry provides modern, type-safe factories for creating test data and fixtures. 6 + 7 + > **Version applicability** — This reference targets **Zenstruck Foundry v2** (current). v2 is a major rewrite of v1: 8 + > - Factories extend `PersistentObjectFactory` (was `Factory` / `ModelFactory` with `getEntityClass()` + `getDefaults()`). 9 + > - Factories are **immutable**: state methods return **new** instances. 10 + > - Factories return **real entity objects**, not `Proxy` wrappers. 11 + > 12 + > See "v1 → v2 migration" at the bottom if you encounter legacy code. 13 + 14 + ## Installation 15 + 16 + ```bash 17 + composer require zenstruck/foundry --dev 18 + # make:factory / make:story commands need MakerBundle: 19 + composer require symfony/maker-bundle --dev 20 + # optional, for non-Foundry fixture loading: 21 + composer require doctrine/doctrine-fixtures-bundle --dev 22 + ``` 23 + 24 + ## Creating Factories (v2) 25 + 26 + ```bash 27 + # Generate a factory for an entity 28 + bin/console make:factory Post 29 + ``` 30 + 31 + ```php 32 + <?php 33 + // src/Factory/PostFactory.php 34 + 35 + namespace App\Factory; 36 + 37 + use App\Entity\Post; 38 + use Zenstruck\Foundry\Persistence\PersistentObjectFactory; 39 + 40 + /** 41 + * @extends PersistentObjectFactory<Post> 42 + */ 43 + final class PostFactory extends PersistentObjectFactory 44 + { 45 + // v2: static class() replaces v1 getEntityClass() 46 + public static function class(): string 47 + { 48 + return Post::class; 49 + } 50 + 51 + // v2: defaults() replaces v1 getDefaults(); may return array OR callable 52 + protected function defaults(): array|callable 53 + { 54 + return [ 55 + 'title' => self::faker()->text(255), 56 + 'content' => self::faker()->paragraphs(3, true), 57 + 'createdAt' => \DateTimeImmutable::createFromMutable( 58 + self::faker()->dateTime() 59 + ), 60 + ]; 61 + } 62 + 63 + // Optional global init hook (runs for every instance) 64 + protected function initialize(): static 65 + { 66 + return $this->afterInstantiate(function (Post $post): void { 67 + // mutate the instance before persist if needed 68 + }); 69 + } 70 + 71 + // Named states — IMMUTABLE: return a NEW instance via with() 72 + public function published(): self 73 + { 74 + return $this->with(['publishedAt' => self::faker()->dateTime()]); 75 + } 76 + } 77 + ``` 78 + 79 + ## Creation API 80 + 81 + Factories return **real entity objects** (no Proxy in v2). 82 + 83 + ```php 84 + use App\Factory\PostFactory; 85 + 86 + // Single entity 87 + $post = PostFactory::createOne(['title' => 'Hello']); // returns Post 88 + 89 + // Multiple 90 + $posts = PostFactory::createMany(5); // Post[] 91 + $posts = PostFactory::createMany(5, ['title' => 'X']); 92 + $posts = PostFactory::createMany(5, fn(int $i) => ['title' => "Title $i"]); 93 + 94 + // Find or create 95 + $post = PostFactory::findOrCreate(['title' => 'Unique']); 96 + 97 + // Build without persisting via new() 98 + $post = PostFactory::new()->withoutPersisting()->create(); 99 + ``` 100 + 101 + ### Fluent / states (immutable) 102 + 103 + Each method returns a NEW factory instance — chain freely. 104 + 105 + ```php 106 + // with() overrides attributes 107 + $post = PostFactory::new()->with(['title' => 'A'])->create(); 108 + 109 + // state methods compose 110 + $post = PostFactory::new()->published()->create(); 111 + 112 + // many() + create() 113 + $posts = PostFactory::new()->published()->many(3)->create(); 114 + ``` 115 + 116 + ## Query API 117 + 118 + ```php 119 + PostFactory::first(); // PostFactory::first('createdAt') 120 + PostFactory::last(); 121 + PostFactory::count(); 122 + PostFactory::all(); 123 + PostFactory::find(5); // by id; or find(['title' => 'X']) 124 + PostFactory::findBy(['published' => true]); 125 + PostFactory::random(); 126 + PostFactory::randomOrCreate(['title' => 'X']); 127 + PostFactory::randomSet(4); 128 + PostFactory::randomRange(0, 5); 129 + ``` 130 + 131 + ## Relationships 132 + 133 + ```php 134 + // ManyToOne — reference the related object 135 + CommentFactory::createOne(['post' => PostFactory::random()]); 136 + 137 + // OneToMany — pass a factory with many() 138 + PostFactory::createOne(['comments' => CommentFactory::new()->many(6)]); 139 + 140 + // ManyToMany — random set 141 + PostFactory::createOne(['tags' => TagFactory::randomSet(2)]); 142 + 143 + // Auto-create the related entity by default 144 + protected function defaults(): array 145 + { 146 + return [ 147 + 'title' => self::faker()->sentence(), 148 + 'author' => UserFactory::new(), // creates a User automatically 149 + ]; 150 + } 151 + ``` 152 + 153 + ## Sequences & distribution (v2.4+) 154 + 155 + ```php 156 + PostFactory::createSequence([ 157 + ['title' => 'First'], 158 + ['title' => 'Second'], 159 + ]); 160 + 161 + PostFactory::new() 162 + ->sequence([['title' => '1'], ['title' => '2']]) 163 + ->distribute('category', $categories) // spread a value across instances 164 + ->create(); 165 + 166 + PostFactory::new()->many(3)->applyStateMethod('published')->create(); 167 + ``` 168 + 169 + ## Hooks 170 + 171 + Inline hooks (per factory): 172 + 173 + ```php 174 + protected function initialize(): static 175 + { 176 + return $this 177 + ->beforeInstantiate(fn(array $attrs) => $attrs) 178 + ->afterInstantiate(function (Post $post): void { /* ... */ }) 179 + // afterPersist listeners must manually flush() if they create entities 180 + ->afterPersist(function (Post $post): void { 181 + CommentFactory::createMany(3, ['post' => $post]); 182 + }); 183 + } 184 + ``` 185 + 186 + Global hooks via attribute (**v2.8+**) — place on a method receiving the event: 187 + 188 + ```php 189 + use Zenstruck\Foundry\Attribute\AsFoundryHook; 190 + use Zenstruck\Foundry\Persistence\Hook\AfterPersist; 191 + 192 + final class GlobalHooks 193 + { 194 + #[AsFoundryHook(Post::class)] // or #[AsFoundryHook] for all entities 195 + public function afterPersist(AfterPersist $event): void 196 + { 197 + // ... 198 + } 199 + } 200 + ``` 201 + 202 + ## Instantiation control 203 + 204 + ```php 205 + use Zenstruck\Foundry\Object\Instantiator; 206 + 207 + PostFactory::new()->instantiateWith( 208 + Instantiator::withConstructor()->allowExtra('legacyField') 209 + ); 210 + // Instantiator::withoutConstructor(), ->alwaysForce(...), ->namedConstructor(...) 211 + // force($value) helper to force-set a single attribute 212 + ``` 213 + 214 + ## Persistence control 215 + 216 + ```php 217 + $post = PostFactory::new()->withoutPersisting()->create(); 218 + PostFactory::new()->withoutDoctrineEvents()->create(); 219 + ``` 220 + 221 + ```yaml 222 + # config/packages/zenstruck_foundry.yaml 223 + when@dev: &dev 224 + zenstruck_foundry: 225 + # v2.5+: NOT enabling flush_once is DEPRECATED — set it true 226 + persistence: 227 + flush_once: true 228 + when@test: *dev 229 + ``` 230 + 231 + Standalone helpers (`use function Zenstruck\Foundry\Persistence\*;`): 232 + 233 + ```php 234 + object(Post::class, ['title' => 'X']); 235 + save($object); 236 + persistent_factory(Post::class); 237 + repository(Post::class); 238 + assert_persisted($object); 239 + assert_not_persisted($object); 240 + ``` 241 + 242 + ## Stories 243 + 244 + ```php 245 + <?php 246 + // src/Story/PostStory.php 247 + 248 + namespace App\Story; 249 + 250 + use App\Factory\PostFactory; 251 + use Zenstruck\Foundry\Attribute\AsFixture; 252 + use Zenstruck\Foundry\Story; 253 + 254 + #[AsFixture(name: 'posts', groups: ['all'])] // v2.6+ 255 + final class PostStory extends Story 256 + { 257 + public function build(): void 258 + { 259 + $this->addState('first', PostFactory::createOne(['title' => 'First'])); 260 + } 261 + } 262 + ``` 263 + 264 + ```php 265 + PostStory::load(); // idempotent 266 + $post = PostStory::get('first'); // retrieve a named state 267 + ``` 268 + 269 + Load fixture-tagged stories/factories (**v2.6+**): 270 + 271 + ```bash 272 + bin/console foundry:load-fixtures 273 + bin/console foundry:load-fixtures --group=all 274 + ``` 275 + 276 + Non-Foundry DataFixtures still work; use the `#[AsFixture]` + `foundry:load-fixtures` 277 + path when you want Foundry to own fixture loading. 278 + 279 + ## Testing integration 280 + 281 + ### PHPUnit 10+ / Foundry v2.9+ (recommended) 282 + 283 + Register the extension in `phpunit.dist.xml`: 284 + 285 + ```xml 286 + <extensions> 287 + <bootstrap class="Zenstruck\Foundry\PHPUnit\FoundryExtension"/> 288 + </extensions> 289 + ``` 290 + 291 + Use the `#[ResetDatabase]` attribute on test classes that touch the DB: 292 + 293 + ```php 294 + <?php 295 + // tests/PostFactoryTest.php 296 + 297 + namespace App\Tests; 298 + 299 + use App\Factory\PostFactory; 300 + use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 301 + use Zenstruck\Foundry\Attribute\ResetDatabase; 302 + 303 + #[ResetDatabase] 304 + final class PostFactoryTest extends KernelTestCase 305 + { 306 + public function testCreatesPost(): void 307 + { 308 + $post = PostFactory::createOne(['title' => 'Hello']); 309 + 310 + self::assertSame('Hello', $post->getTitle()); // real object, no ->object() 311 + PostFactory::assert()->count(1); 312 + } 313 + } 314 + ``` 315 + 316 + ### PHPUnit 9 (legacy) 317 + 318 + Use traits instead of the extension/attribute: 319 + 320 + ```php 321 + use Zenstruck\Foundry\Test\Factories; 322 + use Zenstruck\Foundry\Test\ResetDatabase; 323 + 324 + final class PostFactoryTest extends KernelTestCase 325 + { 326 + use Factories; 327 + use ResetDatabase; // resets the DB before each test 328 + } 329 + ``` 330 + 331 + ### Reset configuration 332 + 333 + ```yaml 334 + # config/packages/zenstruck_foundry.yaml 335 + zenstruck_foundry: 336 + orm: 337 + reset: 338 + mode: schema # or 'migrate' to run migrations instead of drop/create 339 + ``` 340 + 341 + With **DAMADoctrineTestBundle**, the global state is loaded once per suite (inside a 342 + transaction rolled back per test) instead of being reset per test — much faster. 343 + 344 + ## Assertions 345 + 346 + ```php 347 + assert_persisted($object); 348 + PostFactory::assert()->count(3); 349 + PostFactory::assert()->exists(['title' => 'Hello']); 350 + PostFactory::assert()->empty(); 351 + ``` 352 + 353 + ## Faker 354 + 355 + ```php 356 + use function Zenstruck\Foundry\faker; 357 + 358 + $email = faker()->email(); 359 + // in a factory: self::faker()->email() 360 + ``` 361 + 362 + ```yaml 363 + zenstruck_foundry: 364 + faker: 365 + locale: fr_FR 366 + # Deterministic data: FOUNDRY_FAKER_SEED=1234 367 + ``` 368 + 369 + ## Best practices 370 + 371 + 1. **Minimal defaults** — only set required/unique fields; use `self::faker()->unique()` for uniques. 372 + 2. **Named states** — express variations (`admin()`, `published()`) as immutable state methods. 373 + 3. **Let factories build relations** — default related entities via `XFactory::new()`. 374 + 4. **Real objects** — in v2 you call entity methods directly; no `->object()` / `->_real()`. 375 + 5. **Don't over-factory** — trivial data can be created inline. 376 + 377 + ```php 378 + // Good: minimal, realistic, unique 379 + protected function defaults(): array 380 + { 381 + return [ 382 + 'email' => self::faker()->unique()->safeEmail(), 383 + 'roles' => ['ROLE_USER'], 384 + ]; 385 + } 386 + ``` 387 + 388 + ## v1 → v2 migration (legacy code you may encounter) 389 + 390 + | v1 | v2 | 391 + | --- | --- | 392 + | `extends ModelFactory` / `Factory` | `extends PersistentObjectFactory` | 393 + | `protected static function getClass(): string` | `public static function class(): string` | 394 + | `protected function getDefaults(): array` | `protected function defaults(): array\|callable` | 395 + | Mutating `$this` in states | Immutable: state methods return a new instance | 396 + | Returns `Proxy<T>`, call `->object()` | Returns real `T`, call methods directly | 397 + | `$proxy->object()` | the object itself | 398 + 399 + ### Deprecations (v2.7+) 400 + 401 + - The **object Proxy mechanism is deprecated** → use auto-refresh 402 + (requires **PHP 8.4** + `persistence.enable_auto_refresh_with_lazy_objects: true`). 403 + - `PersistentProxyObjectFactory` is **deprecated** → use `PersistentObjectFactory`. 404 + Proxy helpers (`_real()`, `_save()`, `_refresh()`, `_set()`, `_get()`, 405 + `_enableAutoRefresh()`) still work but are deprecated. 406 + - Not enabling `flush_once` is **deprecated** (v2.5+). 407 + 408 + 409 + ## Skill Operating Checklist 410 + 411 + ### Design checklist 412 + - Confirm operation boundaries and invariants first. 413 + - Minimize scope while preserving contract correctness. 414 + - Test both happy path and negative path behavior. 415 + 416 + ### Validation commands 417 + - php bin/console doctrine:migrations:diff 418 + - php bin/console doctrine:migrations:migrate 419 + - ./vendor/bin/phpunit --filter=Doctrine 420 + 421 + ### Failure modes to test 422 + - Invalid payload or forbidden actor. 423 + - Boundary values / not-found cases. 424 + - Retry or partial-failure behavior for async flows.
+41
.agents/skills/symfony-doctrine-migrations/SKILL.md
··· 1 + --- 2 + name: symfony-doctrine-migrations 3 + allowed-tools: 4 + - Read 5 + - Write 6 + - Edit 7 + - Bash 8 + - Glob 9 + - Grep 10 + description: Create and manage Doctrine migrations (lib 4.x) for schema versioning; handle dependencies, rollbacks, and production deployment 11 + --- 12 + 13 + # Doctrine Migrations (Symfony) 14 + 15 + ## Use when 16 + - Designing entity relations or schema evolution. 17 + - Improving Doctrine correctness/performance. 18 + 19 + ## Default workflow 20 + 1. Model ownership/cardinality and transactional boundaries. 21 + 2. Apply mapping/schema changes with migration safety. 22 + 3. Tune fetch/query behavior for hot paths. 23 + 4. Verify lifecycle behavior with targeted tests. 24 + 25 + ## Guardrails 26 + - Keep owning/inverse sides coherent. 27 + - Avoid destructive migration jumps in one release. 28 + - Eliminate accidental N+1 and over-fetching. 29 + 30 + ## Progressive disclosure 31 + - Use this file for execution posture and risk controls. 32 + - Open references when deep implementation details are needed. 33 + 34 + ## Output contract 35 + - Entity/migration changes. 36 + - Integrity and performance decisions. 37 + - Validation outcomes and rollback notes. 38 + 39 + ## References 40 + - `reference.md` 41 + - `docs/complexity-tiers.md`
+150
.agents/skills/symfony-doctrine-migrations/reference.md
··· 1 + # Doctrine Migrations Reference (Symfony) 2 + 3 + Use this reference for implementation details and review criteria specific to `doctrine-migrations`. 4 + 5 + Versions (target): **DoctrineMigrationsBundle `^3.0`**, which wraps the 6 + **`doctrine/migrations` library 4.0.x** (5.0 in development). Migration classes 7 + use the typed, `void`-returning signatures `up(Schema $schema): void` / 8 + `down(Schema $schema): void`. 9 + 10 + ## Install 11 + 12 + ```bash 13 + composer require doctrine/doctrine-migrations-bundle "^3.0" 14 + ``` 15 + 16 + ## Typical workflow 17 + 18 + ```bash 19 + # 1. Change your entity mapping (#[ORM\...]), then generate the diff 20 + php bin/console make:migration # MakerBundle wrapper around diff 21 + # or: 22 + php bin/console doctrine:migrations:diff 23 + 24 + # 2. Review the generated up()/down() SQL (NEVER trust the diff blindly) 25 + 26 + # 3. Apply 27 + php bin/console doctrine:migrations:migrate 28 + 29 + # Useful neighbours 30 + php bin/console doctrine:migrations:status 31 + php bin/console doctrine:migrations:migrate prev # roll back one version 32 + php bin/console doctrine:migrations:execute 'DoctrineMigrations\Version20260617120000' --down 33 + ``` 34 + 35 + ## Migration class 36 + 37 + ```php 38 + <?php 39 + 40 + declare(strict_types=1); 41 + 42 + namespace DoctrineMigrations; 43 + 44 + use Doctrine\DBAL\Schema\Schema; 45 + use Doctrine\Migrations\AbstractMigration; 46 + 47 + final class Version20260617120000 extends AbstractMigration 48 + { 49 + public function getDescription(): string 50 + { 51 + return 'Add status column to product'; 52 + } 53 + 54 + public function up(Schema $schema): void 55 + { 56 + $this->addSql('ALTER TABLE product ADD status VARCHAR(20) NOT NULL DEFAULT \'draft\''); 57 + } 58 + 59 + public function down(Schema $schema): void 60 + { 61 + $this->addSql('ALTER TABLE product DROP status'); 62 + } 63 + } 64 + ``` 65 + 66 + `down()` must reverse `up()`. A migration without a real `down()` cannot be 67 + rolled back safely — write it even when it feels redundant. 68 + 69 + ## Configuration (`config/packages/doctrine_migrations.yaml`) 70 + 71 + ```yaml 72 + doctrine_migrations: 73 + migrations_paths: 74 + 'DoctrineMigrations': '%kernel.project_dir%/migrations' 75 + enable_profiler: false 76 + 77 + # Wrap EACH migration in its own transaction (default true). 78 + transactional: true 79 + # Run ALL pending migrations inside ONE transaction (default false). 80 + # On MySQL most DDL is non-transactional (implicit commit), so this is 81 + # mainly effective on PostgreSQL. 82 + all_or_nothing: false 83 + 84 + check_database_platform: true 85 + organize_migrations: false # or BY_YEAR / BY_YEAR_AND_MONTH 86 + ``` 87 + 88 + Key options: 89 + 90 + | Option | Default | Purpose | 91 + |--------|---------|---------| 92 + | `migrations_paths` | — | namespace → path pairs (required) | 93 + | `transactional` | `true` | wrap each migration in a transaction | 94 + | `all_or_nothing` | `false` | run all pending migrations in one transaction | 95 + | `em` | `default` | entity manager (overrides `connection`) | 96 + | `check_database_platform` | `true` | verify the DB type matches | 97 + | `enable_service_migrations` | `false` | allow migrations to be fetched from the container (DI) | 98 + 99 + ## Multiple entity managers 100 + 101 + Each EM has its own migration history. Pass `--em` to every command: 102 + 103 + ```bash 104 + php bin/console doctrine:migrations:diff --em=customer 105 + php bin/console doctrine:migrations:migrate --em=customer 106 + ``` 107 + 108 + When you have a non-default EM, also set `em:` (or a dedicated path) in 109 + `doctrine_migrations.yaml`, and in prod redefine the default EM in 110 + `config/packages/prod/doctrine.yaml`. 111 + 112 + ## Best practices 113 + 114 + 1. **Review the diff before applying.** `diff` reflects mapping vs current DB; 115 + it can emit destructive or surprising DDL (renames seen as drop+add, index 116 + churn). Read every `addSql()` line. 117 + 2. **Schema only — no data migrations in schema migrations.** Mixing data 118 + changes into a `diff`-generated migration breaks reversibility and gets 119 + clobbered on the next `diff`. For data, use a dedicated, hand-written 120 + migration or a one-off command, and keep it idempotent. 121 + 3. **Always write `down()`** so the change is reversible. 122 + 4. **One concern per migration.** Easier to review, revert, and reason about. 123 + 5. **Never edit an already-applied migration.** Add a new one instead. 124 + 6. **Service migrations** (`enable_service_migrations: true`) when a migration 125 + genuinely needs a service — tag it `doctrine_migrations.migration` and call 126 + `parent::__construct($connection, $logger)`. 127 + 128 + ## Applicability 129 + 130 + - **Target**: bundle `^3.0` + library 4.0.x, typed `up/down(Schema): void`. 131 + - The signatures and `transactional`/`all_or_nothing` options shown apply to 132 + library 3.x and 4.x alike. 133 + 134 + 135 + ## Skill Operating Checklist 136 + 137 + ### Design checklist 138 + - Confirm operation boundaries and invariants first. 139 + - Minimize scope while preserving contract correctness. 140 + - Test both happy path and negative path behavior. 141 + 142 + ### Validation commands 143 + - php bin/console doctrine:migrations:diff 144 + - php bin/console doctrine:migrations:migrate 145 + - ./vendor/bin/phpunit --filter=Doctrine 146 + 147 + ### Failure modes to test 148 + - Invalid payload or forbidden actor. 149 + - Boundary values / not-found cases. 150 + - Retry or partial-failure behavior for async flows.
+41
.agents/skills/symfony-doctrine-relations/SKILL.md
··· 1 + --- 2 + name: symfony-doctrine-relations 3 + allowed-tools: 4 + - Read 5 + - Write 6 + - Edit 7 + - Bash 8 + - Glob 9 + - Grep 10 + description: Define Doctrine entity relationships (OneToMany, ManyToMany, ManyToOne); configure cascade, orphan removal, multiple entity managers; prevent N+1 queries 11 + --- 12 + 13 + # Doctrine Relations (Symfony) 14 + 15 + ## Use when 16 + - Designing entity relations or schema evolution. 17 + - Improving Doctrine correctness/performance. 18 + 19 + ## Default workflow 20 + 1. Model ownership/cardinality and transactional boundaries. 21 + 2. Apply mapping/schema changes with migration safety. 22 + 3. Tune fetch/query behavior for hot paths. 23 + 4. Verify lifecycle behavior with targeted tests. 24 + 25 + ## Guardrails 26 + - Keep owning/inverse sides coherent. 27 + - Avoid destructive migration jumps in one release. 28 + - Eliminate accidental N+1 and over-fetching. 29 + 30 + ## Progressive disclosure 31 + - Use this file for execution posture and risk controls. 32 + - Open references when deep implementation details are needed. 33 + 34 + ## Output contract 35 + - Entity/migration changes. 36 + - Integrity and performance decisions. 37 + - Validation outcomes and rollback notes. 38 + 39 + ## References 40 + - `reference.md` 41 + - `docs/complexity-tiers.md`
+265
.agents/skills/symfony-doctrine-relations/reference.md
··· 1 + # Doctrine Relations Reference (Symfony) 2 + 3 + Use this reference for implementation details and review criteria specific to `doctrine-relations`. 4 + 5 + Mapping is done with PHP attributes (`#[ORM\...]`). The mapping API is stable 6 + across ORM 2.x/3.x; on ORM 3 the `targetEntity` can also be inferred from the 7 + property type-hint, but passing it explicitly stays valid and is clearer. 8 + 9 + ## Owning vs Inverse side 10 + 11 + | | Owning side | Inverse side | 12 + |--|-------------|--------------| 13 + | Mapping | `ManyToOne` (or owning `ManyToMany`/`OneToOne`) | `OneToMany` (or inverse side) | 14 + | Database | holds the foreign key | no column | 15 + | Updates DB | yes | only if the owning side is updated | 16 + 17 + The single most common Doctrine bug: setting the relation on the **inverse** 18 + side only. Doctrine persists what the **owning** side holds. Always set the 19 + owning side (the `ManyToOne` reference). 20 + 21 + ## ManyToOne / OneToMany (the common pair) 22 + 23 + ### Owning side — `Product` holds the FK 24 + 25 + ```php 26 + <?php 27 + // src/Entity/Product.php 28 + 29 + use Doctrine\ORM\Mapping as ORM; 30 + 31 + #[ORM\Entity] 32 + class Product 33 + { 34 + #[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'products')] 35 + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] 36 + private ?Category $category = null; 37 + 38 + public function getCategory(): ?Category 39 + { 40 + return $this->category; 41 + } 42 + 43 + public function setCategory(?Category $category): self 44 + { 45 + $this->category = $category; 46 + 47 + return $this; 48 + } 49 + } 50 + ``` 51 + 52 + ### Inverse side — `Category` owns a collection 53 + 54 + ```php 55 + <?php 56 + // src/Entity/Category.php 57 + 58 + use Doctrine\Common\Collections\ArrayCollection; 59 + use Doctrine\Common\Collections\Collection; 60 + use Doctrine\ORM\Mapping as ORM; 61 + 62 + #[ORM\Entity] 63 + class Category 64 + { 65 + /** @var Collection<int, Product> */ 66 + #[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', orphanRemoval: true)] 67 + private Collection $products; 68 + 69 + public function __construct() 70 + { 71 + $this->products = new ArrayCollection(); 72 + } 73 + 74 + /** @return Collection<int, Product> */ 75 + public function getProducts(): Collection 76 + { 77 + return $this->products; 78 + } 79 + 80 + public function addProduct(Product $product): self 81 + { 82 + if (!$this->products->contains($product)) { 83 + $this->products[] = $product; 84 + $product->setCategory($this); // keep both sides in sync 85 + } 86 + 87 + return $this; 88 + } 89 + 90 + public function removeProduct(Product $product): self 91 + { 92 + if ($this->products->removeElement($product)) { 93 + // unset the owning side only if it points here 94 + if ($product->getCategory() === $this) { 95 + $product->setCategory(null); 96 + } 97 + } 98 + 99 + return $this; 100 + } 101 + } 102 + ``` 103 + 104 + ### Persisting — set the owning side, then flush 105 + 106 + ```php 107 + $product->setCategory($category); // owning side carries the FK 108 + $em->persist($category); 109 + $em->persist($product); 110 + $em->flush(); 111 + ``` 112 + 113 + ## orphanRemoval vs cascade 114 + 115 + - **`cascade: ['persist', 'remove']`** — operations on the parent propagate to 116 + the associated entities. Useful for aggregate roots you always save together. 117 + Avoid `cascade: ['remove']` on large collections (issues a DELETE per row). 118 + - **`orphanRemoval: true`** — when a child is *removed from the collection* (no 119 + longer referenced by the parent), Doctrine deletes it. Use it for true 120 + composition (a `Product` cannot exist without its `Category`), not for 121 + shared entities. 122 + 123 + ```php 124 + #[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', 125 + cascade: ['persist'], orphanRemoval: true)] 126 + private Collection $items; 127 + ``` 128 + 129 + ## Fetching — avoid N+1 130 + 131 + ```php 132 + // LAZY (default): triggers one extra query per access 133 + $post = $em->find(Post::class, 1); 134 + $post->getAuthor()->getName(); // 2nd query here 135 + 136 + // Fetch join: one query, relation hydrated 137 + $post = $repository->createQueryBuilder('p') 138 + ->addSelect('a') 139 + ->leftJoin('p.author', 'a') 140 + ->where('p.id = :id') 141 + ->setParameter('id', $id) 142 + ->getQuery() 143 + ->getOneOrNullResult(); 144 + ``` 145 + 146 + ## Pitfall: `contains()` on large inverse collections 147 + 148 + On a plain `OneToMany`, calling `$category->getProducts()->contains($product)` 149 + forces Doctrine to **hydrate the entire collection** into memory before 150 + checking — disastrous for collections with thousands of rows. 151 + 152 + ```php 153 + // BAD on a huge collection — loads everything 154 + if ($category->getProducts()->contains($product)) { /* ... */ } 155 + ``` 156 + 157 + Mitigations: 158 + 159 + - Mark the collection `fetch: 'EXTRA_LAZY'` so `contains()`, `count()` and 160 + `slice()` issue targeted SQL (`EXISTS`, `COUNT`, `LIMIT`) instead of loading 161 + the whole collection: 162 + 163 + ```php 164 + #[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', fetch: 'EXTRA_LAZY')] 165 + private Collection $products; 166 + ``` 167 + 168 + - Or check membership from the owning side: `$product->getCategory() === $category`. 169 + 170 + Always review the generated `add*/remove*` helpers (from `make:entity`) — they 171 + call `contains()` and become a hot spot on large inverse collections. 172 + 173 + ## Multiple Entity Managers 174 + 175 + A relation may **not** span two entity managers. When the schema is split, each 176 + EM owns its own set of entities. 177 + 178 + ```yaml 179 + # config/packages/doctrine.yaml 180 + doctrine: 181 + dbal: 182 + connections: 183 + default: { url: '%env(resolve:DATABASE_URL)%' } 184 + customer: { url: '%env(resolve:CUSTOMER_DATABASE_URL)%' } 185 + default_connection: default 186 + orm: 187 + default_entity_manager: default 188 + entity_managers: 189 + default: 190 + connection: default 191 + mappings: 192 + Main: 193 + is_bundle: false 194 + dir: '%kernel.project_dir%/src/Entity/Main' 195 + prefix: 'App\Entity\Main' 196 + customer: 197 + connection: customer 198 + mappings: 199 + Customer: 200 + is_bundle: false 201 + dir: '%kernel.project_dir%/src/Entity/Customer' 202 + prefix: 'App\Entity\Customer' 203 + ``` 204 + 205 + ### Autowiring a named EM (by variable name) 206 + 207 + The FrameworkBundle registers an alias per EM. Type-hint 208 + `EntityManagerInterface` and **name the variable after the EM** to inject the 209 + right one: 210 + 211 + ```php 212 + use Doctrine\ORM\EntityManagerInterface; 213 + 214 + public function __construct( 215 + private EntityManagerInterface $entityManager, // default EM 216 + private EntityManagerInterface $customerEntityManager, // "customer" EM 217 + ) {} 218 + ``` 219 + 220 + > Relying on the variable name for autowiring resolution is convenient, but be 221 + > aware Symfony 8.1+ deprecates name-based alias resolution in the general case; 222 + > for Doctrine EMs the named alias is still the documented mechanism. Use 223 + > `ManagerRegistry::getManager('customer')` when you need it explicitly. 224 + 225 + ### Repositories with multiple EMs 226 + 227 + `getRepository()` takes the EM name as a second argument: 228 + 229 + ```php 230 + use Doctrine\Persistence\ManagerRegistry; 231 + 232 + $default = $registry->getRepository(Product::class); // default EM 233 + $customer = $registry->getRepository(Customer::class, 'customer'); // named EM 234 + ``` 235 + 236 + For a custom repository on an entity managed by a non-default EM, extend 237 + `Doctrine\ORM\EntityRepository` (**not** `ServiceEntityRepository`, whose 238 + constructor resolves the EM from the entity class via the default manager) and 239 + obtain it through `ManagerRegistry::getRepository()`. 240 + 241 + ### Console / migrations with `--em` 242 + 243 + ```bash 244 + php bin/console doctrine:database:create --connection=customer 245 + php bin/console doctrine:migrations:diff --em=customer 246 + php bin/console doctrine:migrations:migrate --em=customer 247 + ``` 248 + 249 + 250 + ## Skill Operating Checklist 251 + 252 + ### Design checklist 253 + - Confirm operation boundaries and invariants first. 254 + - Minimize scope while preserving contract correctness. 255 + - Test both happy path and negative path behavior. 256 + 257 + ### Validation commands 258 + - php bin/console doctrine:migrations:diff 259 + - php bin/console doctrine:migrations:migrate 260 + - ./vendor/bin/phpunit --filter=Doctrine 261 + 262 + ### Failure modes to test 263 + - Invalid payload or forbidden actor. 264 + - Boundary values / not-found cases. 265 + - Retry or partial-failure behavior for async flows.
+42
.agents/skills/symfony-doctrine-transactions/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-doctrine-transactions 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Handle Doctrine transactions (ORM 3 wrapInTransaction), optimistic/pessimistic locking, flush strategies, and transaction boundaries 12 + --- 13 + 14 + # Doctrine Transactions (Symfony) 15 + 16 + ## Use when 17 + - Designing entity relations or schema evolution. 18 + - Improving Doctrine correctness/performance. 19 + 20 + ## Default workflow 21 + 1. Model ownership/cardinality and transactional boundaries. 22 + 2. Apply mapping/schema changes with migration safety. 23 + 3. Tune fetch/query behavior for hot paths. 24 + 4. Verify lifecycle behavior with targeted tests. 25 + 26 + ## Guardrails 27 + - Keep owning/inverse sides coherent. 28 + - Avoid destructive migration jumps in one release. 29 + - Eliminate accidental N+1 and over-fetching. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Entity/migration changes. 37 + - Integrity and performance decisions. 38 + - Validation outcomes and rollback notes. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+370
.agents/skills/symfony-doctrine-transactions/reference.md
··· 1 + # Reference 2 + 3 + # Doctrine Transactions 4 + 5 + ## Basic Transactions 6 + 7 + ### Implicit Transactions 8 + 9 + By default, Doctrine wraps each `flush()` in a transaction: 10 + 11 + ```php 12 + $user = new User(); 13 + $user->setEmail('test@example.com'); 14 + 15 + $em->persist($user); 16 + $em->flush(); // Auto-commits in transaction 17 + ``` 18 + 19 + ### Explicit Transactions 20 + 21 + For multiple operations that must succeed or fail together: 22 + 23 + ```php 24 + <?php 25 + // src/Service/OrderService.php 26 + 27 + class OrderService 28 + { 29 + public function __construct( 30 + private EntityManagerInterface $em, 31 + ) {} 32 + 33 + public function createOrderWithPayment(User $user, array $items): Order 34 + { 35 + $this->em->beginTransaction(); 36 + 37 + try { 38 + // Create order 39 + $order = new Order(); 40 + $order->setCustomer($user); 41 + $order->setStatus(OrderStatus::PENDING); 42 + 43 + foreach ($items as $item) { 44 + $orderItem = new OrderItem(); 45 + $orderItem->setProduct($item['product']); 46 + $orderItem->setQuantity($item['quantity']); 47 + $order->addItem($orderItem); 48 + } 49 + 50 + $this->em->persist($order); 51 + 52 + // Create payment 53 + $payment = new Payment(); 54 + $payment->setOrder($order); 55 + $payment->setAmount($order->getTotal()); 56 + $this->em->persist($payment); 57 + 58 + $this->em->flush(); 59 + $this->em->commit(); 60 + 61 + return $order; 62 + 63 + } catch (\Exception $e) { 64 + $this->em->rollback(); 65 + throw $e; 66 + } 67 + } 68 + } 69 + ``` 70 + 71 + ### Using the wrapInTransaction Helper 72 + 73 + Cleaner approach. `wrapInTransaction()` flushes before commit and closes the 74 + EntityManager if the callback throws: 75 + 76 + ```php 77 + public function createOrder(User $user, array $items): Order 78 + { 79 + return $this->em->wrapInTransaction(function () use ($user, $items) { 80 + $order = new Order(); 81 + $order->setCustomer($user); 82 + 83 + foreach ($items as $item) { 84 + $order->addItem(new OrderItem($item)); 85 + } 86 + 87 + $this->em->persist($order); 88 + 89 + return $order; 90 + }); 91 + } 92 + ``` 93 + 94 + > **ORM 3 breaking change**: `EntityManager::transactional()` was deprecated in 95 + > ORM 2.9 and **removed in ORM 3.0**. Use `wrapInTransaction()` (available since 96 + > ORM 2.9). They share the same signature, so the migration is a rename. Any 97 + > remaining `$em->transactional(...)` call will fatal on ORM 3.x. 98 + 99 + ### DBAL-level Transactions 100 + 101 + On the DBAL `Connection`, `transactional()` is **not** affected by the ORM 3 102 + removal — it remains valid for raw SQL / DML that does not go through the 103 + EntityManager: 104 + 105 + ```php 106 + use Doctrine\DBAL\Connection; 107 + 108 + $connection->transactional(function (Connection $conn): void { 109 + $conn->executeStatement('UPDATE product SET price = price * 0.9'); 110 + }); 111 + ``` 112 + 113 + Note this does not flush the ORM unit of work — use it only for DBAL-level work. 114 + 115 + ## Flush Strategies 116 + 117 + ### Single Flush (Recommended) 118 + 119 + ```php 120 + // Good: Single flush for all changes 121 + $user = new User(); 122 + $user->setEmail('test@example.com'); 123 + $em->persist($user); 124 + 125 + $profile = new Profile(); 126 + $profile->setUser($user); 127 + $em->persist($profile); 128 + 129 + $em->flush(); // One transaction, one commit 130 + ``` 131 + 132 + ### Avoid Multiple Flushes 133 + 134 + ```php 135 + // Bad: Multiple flushes = multiple transactions 136 + $user = new User(); 137 + $em->persist($user); 138 + $em->flush(); // Transaction 1 139 + 140 + $profile = new Profile(); 141 + $profile->setUser($user); 142 + $em->persist($profile); 143 + $em->flush(); // Transaction 2 - not atomic! 144 + ``` 145 + 146 + ### Flush Only When Needed 147 + 148 + ```php 149 + // Service layer flushes 150 + class UserService 151 + { 152 + public function register(string $email): User 153 + { 154 + $user = new User(); 155 + $user->setEmail($email); 156 + $this->em->persist($user); 157 + $this->em->flush(); // Service controls transaction boundary 158 + return $user; 159 + } 160 + } 161 + 162 + // Controller doesn't flush 163 + class UserController 164 + { 165 + #[Route('/register', methods: ['POST'])] 166 + public function register(Request $request, UserService $service): Response 167 + { 168 + $user = $service->register($request->get('email')); 169 + return new Response('Created', 201); 170 + } 171 + } 172 + ``` 173 + 174 + ## Optimistic Locking 175 + 176 + Prevent concurrent modification conflicts with a `#[ORM\Version]` column 177 + (`integer` or `datetime`/`datetime_immutable`). Version numbers are preferred 178 + over timestamps in high-concurrency scenarios: 179 + 180 + ```php 181 + <?php 182 + // src/Entity/Article.php 183 + 184 + #[ORM\Entity] 185 + class Article 186 + { 187 + #[ORM\Version] 188 + #[ORM\Column(type: 'integer')] 189 + private int $version = 1; 190 + 191 + public function getVersion(): int 192 + { 193 + return $this->version; 194 + } 195 + } 196 + ``` 197 + 198 + Usage: 199 + 200 + ```php 201 + use Doctrine\ORM\OptimisticLockException; 202 + 203 + public function updateArticle(int $id, string $content, int $expectedVersion): void 204 + { 205 + $article = $this->em->find(Article::class, $id); 206 + 207 + // Lock with expected version 208 + $this->em->lock($article, LockMode::OPTIMISTIC, $expectedVersion); 209 + 210 + $article->setContent($content); 211 + 212 + try { 213 + $this->em->flush(); 214 + } catch (OptimisticLockException $e) { 215 + // Version mismatch - someone else modified it 216 + throw new ConflictException('Article was modified by another user'); 217 + } 218 + } 219 + ``` 220 + 221 + ## Pessimistic Locking 222 + 223 + Lock rows in database: 224 + 225 + ```php 226 + use Doctrine\DBAL\LockMode; 227 + 228 + public function processPayment(int $orderId): void 229 + { 230 + $this->em->beginTransaction(); 231 + 232 + try { 233 + // Lock the row for update 234 + $order = $this->em->find( 235 + Order::class, 236 + $orderId, 237 + LockMode::PESSIMISTIC_WRITE 238 + ); 239 + 240 + if ($order->getStatus() !== OrderStatus::PENDING) { 241 + throw new \Exception('Order already processed'); 242 + } 243 + 244 + $order->setStatus(OrderStatus::PROCESSING); 245 + $this->em->flush(); 246 + $this->em->commit(); 247 + 248 + } catch (\Exception $e) { 249 + $this->em->rollback(); 250 + throw $e; 251 + } 252 + } 253 + ``` 254 + 255 + Lock modes: 256 + - `PESSIMISTIC_READ`: Shared lock (SELECT ... FOR SHARE) 257 + - `PESSIMISTIC_WRITE`: Exclusive lock (SELECT ... FOR UPDATE) 258 + 259 + ## Error Handling 260 + 261 + ### Connection Lost 262 + 263 + ```php 264 + use Doctrine\DBAL\Exception\ConnectionLost; 265 + 266 + try { 267 + $this->em->flush(); 268 + } catch (ConnectionLost $e) { 269 + // Reconnect and retry 270 + $this->em->getConnection()->connect(); 271 + $this->em->flush(); 272 + } 273 + ``` 274 + 275 + ### Constraint Violations 276 + 277 + ```php 278 + use Doctrine\DBAL\Exception\UniqueConstraintViolationException; 279 + 280 + try { 281 + $user = new User(); 282 + $user->setEmail($email); 283 + $this->em->persist($user); 284 + $this->em->flush(); 285 + } catch (UniqueConstraintViolationException $e) { 286 + throw new DuplicateEmailException('Email already exists'); 287 + } 288 + ``` 289 + 290 + ## EntityManager State 291 + 292 + ### After Exception 293 + 294 + After a rollback, the EntityManager may be in an inconsistent state: 295 + 296 + ```php 297 + try { 298 + $this->em->flush(); 299 + } catch (\Exception $e) { 300 + $this->em->rollback(); 301 + 302 + // Clear the EntityManager 303 + $this->em->clear(); 304 + 305 + // Re-fetch entities if needed 306 + $user = $this->em->find(User::class, $userId); 307 + } 308 + ``` 309 + 310 + ### Clearing EntityManager 311 + 312 + ```php 313 + // Clear all managed entities 314 + $this->em->clear(); 315 + 316 + // Clear specific entity type 317 + $this->em->clear(User::class); 318 + ``` 319 + 320 + ## Best Practices 321 + 322 + 1. **Single flush per operation**: Group related changes 323 + 2. **Service layer transactions**: Controllers don't manage transactions 324 + 3. **Use `wrapInTransaction()`**: Cleaner than try/catch (replaces the removed `transactional()`) 325 + 4. **Optimistic locking**: For concurrent editing scenarios 326 + 5. **Clear after rollback**: Reset EntityManager state 327 + 6. **Short transactions**: Don't hold locks too long 328 + 329 + ```php 330 + // Good pattern 331 + class OrderService 332 + { 333 + public function createOrder(CreateOrderDTO $dto): Order 334 + { 335 + return $this->em->wrapInTransaction(function () use ($dto) { 336 + $order = new Order(); 337 + // ... build order 338 + $this->em->persist($order); 339 + return $order; 340 + }); 341 + } 342 + } 343 + ``` 344 + 345 + ## Applicability 346 + 347 + - **ORM 3.x / DBAL 4.x** (target): `wrapInTransaction()` only; 348 + `EntityManager::transactional()` no longer exists. 349 + - **ORM 2.9+ (legacy)**: both `wrapInTransaction()` and `transactional()` exist; 350 + prefer `wrapInTransaction()` so the code survives the 3.0 upgrade. 351 + - `Connection::transactional()` (DBAL) is valid across all versions. 352 + 353 + 354 + ## Skill Operating Checklist 355 + 356 + ### Design checklist 357 + - Confirm operation boundaries and invariants first. 358 + - Minimize scope while preserving contract correctness. 359 + - Test both happy path and negative path behavior. 360 + 361 + ### Validation commands 362 + - php bin/console doctrine:migrations:diff 363 + - php bin/console doctrine:migrations:migrate 364 + - ./vendor/bin/phpunit --filter=Doctrine 365 + 366 + ### Failure modes to test 367 + - Invalid payload or forbidden actor. 368 + - Boundary values / not-found cases. 369 + - Retry or partial-failure behavior for async flows. 370 +
+39
.agents/skills/symfony-e2e-panther-playwright/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-e2e-panther-playwright 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Write end-to-end tests with Symfony Panther 2.4 for browser automation or Playwright for complex scenarios 9 + --- 10 + 11 + # E2e Panther Playwright (Symfony) 12 + 13 + ## Use when 14 + - Building regression-safe behavior with TDD/functional/e2e tests. 15 + - Converting bug reports into executable failing tests. 16 + 17 + ## Default workflow 18 + 1. Write failing test for target behavior and one boundary case. 19 + 2. Implement minimal code to pass. 20 + 3. Refactor while preserving green suite. 21 + 4. Broaden coverage for invalid/unauthorized/not-found paths. 22 + 23 + ## Guardrails 24 + - Prefer deterministic fixtures/builders. 25 + - Assert observable behavior, not internal implementation. 26 + - Keep tests isolated and stable in CI. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - RED/GREEN/REFACTOR trace. 34 + - Test files changed and executed commands. 35 + - Coverage and confidence notes. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+342
.agents/skills/symfony-e2e-panther-playwright/reference.md
··· 1 + # Reference 2 + 3 + # End-to-End Testing 4 + 5 + > **Version applicability** — Panther section targets **symfony/panther v2.4.0** (2026-01, 6 + > Symfony 7.x/8.x). Panther drives a **real browser** (W3C WebDriver: Chrome/Firefox) for 7 + > E2E tests and scraping. The Playwright section is an alternative for richer JS scenarios. 8 + 9 + ## Symfony Panther 10 + 11 + ### Installation 12 + 13 + ```bash 14 + composer require --dev symfony/panther 15 + # bdi auto-downloads the matching ChromeDriver / geckodriver: 16 + composer require --dev dbrekelmans/bdi 17 + vendor/bin/bdi detect drivers 18 + ``` 19 + 20 + Register the `ServerExtension` (manages the test web server; improves perf + enables 21 + `--debug`) in `phpunit.dist.xml`: 22 + 23 + ```xml 24 + <extensions> 25 + <!-- PHPUnit 10+ --> 26 + <bootstrap class="Symfony\Component\Panther\ServerExtension"/> 27 + <!-- PHPUnit < 10 (legacy): 28 + <extension class="Symfony\Component\Panther\ServerExtension"/> --> 29 + </extensions> 30 + ``` 31 + 32 + ### Basic test 33 + 34 + ```php 35 + <?php 36 + // tests/E2E/HomePageTest.php 37 + 38 + namespace App\Tests\E2E; 39 + 40 + use Symfony\Component\Panther\PantherTestCase; 41 + 42 + class HomePageTest extends PantherTestCase 43 + { 44 + public function testHomePageLoads(): void 45 + { 46 + $client = static::createPantherClient(); // real browser + JS 47 + $client->request('GET', '/'); 48 + 49 + $this->assertPageTitleContains('Home'); 50 + $this->assertSelectorTextContains('h1', 'Welcome'); 51 + } 52 + } 53 + ``` 54 + 55 + ### Client creation 56 + 57 + ```php 58 + static::createPantherClient(); // real browser, JS (Chrome default) 59 + static::createPantherClient(['browser' => static::FIREFOX]); // Firefox 60 + static::createClient(); // BrowserKit — fast, NO JS 61 + static::createHttpBrowserClient(); // real HTTP, no browser 62 + static::createAdditionalPantherClient(); // isolated 2nd browser (realtime/multi-user apps) 63 + // Standalone: Client::createChromeClient(), Client::createFirefoxClient(), 64 + // Client::createSeleniumClient('http://127.0.0.1:4444/wd/hub') 65 + ``` 66 + 67 + ### Form interaction 68 + 69 + ```php 70 + public function testContactForm(): void 71 + { 72 + $client = static::createPantherClient(); 73 + $crawler = $client->request('GET', '/contact'); 74 + 75 + $form = $crawler->selectButton('Send')->form([ 76 + 'contact[name]' => 'John Doe', 77 + 'contact[email]' => 'john@example.com', 78 + 'contact[message]' => 'Hello!', 79 + ]); 80 + $client->submit($form); 81 + 82 + $client->waitFor('.alert-success'); 83 + $this->assertSelectorTextContains('.alert-success', 'Message sent'); 84 + } 85 + ``` 86 + 87 + ### Waiting for elements 88 + 89 + Panther exposes explicit waits — never rely on implicit timing. 90 + 91 + ```php 92 + $client->waitFor('.product-list'); // exists in DOM 93 + $client->waitFor('.slow-content', 10); // custom timeout (s) 94 + $client->waitForVisibility('#x'); 95 + $client->waitForInvisibility('.loading-spinner'); 96 + $client->waitForStaleness('.popin'); 97 + $client->waitForElementToContain('.total', '25 €'); 98 + $client->waitForElementToNotContain('.total', '0 €'); 99 + $client->waitForEnabled('[type=submit]'); 100 + $client->waitForDisabled('[type=submit]'); 101 + $client->waitForAttributeToContain('.price', 'data-old-price', '25 €'); 102 + ``` 103 + 104 + ### Assertions 105 + 106 + ```php 107 + // Immediate state 108 + $this->assertSelectorIsVisible('.dropdown-menu'); 109 + $this->assertSelectorIsNotVisible('.modal'); 110 + $this->assertSelectorIsEnabled('[type=submit]'); 111 + $this->assertSelectorIsDisabled('[type=submit]'); 112 + $this->assertSelectorAttributeContains('.price', 'data-currency', 'EUR'); 113 + 114 + // Future state — wait then assert (Panther-specific) 115 + $this->assertSelectorWillExist('.product-card'); 116 + $this->assertSelectorWillNotExist('.loading'); 117 + $this->assertSelectorWillBeVisible('.toast'); 118 + $this->assertSelectorWillBeEnabled('[type=submit]'); 119 + $this->assertSelectorWillContain('.status', 'Complete'); 120 + $this->assertSelectorAttributeWillContain('.price', 'data-old-price', '25 €'); 121 + ``` 122 + 123 + ### Scripting & screenshots 124 + 125 + ```php 126 + $title = $client->executeScript('return document.title;'); 127 + $client->takeScreenshot('var/screenshots/page.png'); 128 + $logs = $client->getWebDriver()->manage()->getLog('browser'); // JS console 129 + ``` 130 + 131 + > Screenshots are debris — delete any `*.png` produced by `takeScreenshot()` or by 132 + > `PANTHER_ERROR_SCREENSHOT_DIR` at the end of the run. Never commit them. 133 + 134 + ### Config & environment 135 + 136 + Client options: `hostname` (127.0.0.1), `port` (9080), `external_base_uri`, `browser`. 137 + 138 + Env vars: `PANTHER_NO_HEADLESS`, `PANTHER_WEB_SERVER_DIR` (`./public/`), 139 + `PANTHER_WEB_SERVER_PORT` (9080), `PANTHER_EXTERNAL_BASE_URI`, `PANTHER_APP_ENV`, 140 + `PANTHER_ERROR_SCREENSHOT_DIR`, `PANTHER_NO_SANDBOX`, `PANTHER_CHROME_ARGUMENTS`, 141 + `PANTHER_CHROME_BINARY`, `PANTHER_FIREFOX_ARGUMENTS`, `PANTHER_NO_REDUCED_MOTION`. 142 + 143 + ```php 144 + // Custom Chrome args: 145 + Client::createChromeClient(null, ['--window-size=1500,4000']); 146 + ``` 147 + 148 + ### Debug 149 + 150 + ```bash 151 + # Pauses the browser on failure for visual inspection (needs ServerExtension) 152 + PANTHER_NO_HEADLESS=1 bin/phpunit --debug 153 + ``` 154 + 155 + ### Limitations 156 + 157 + HTML only (no XML crawling); no DOM updating from the crawler; no multidimensional array 158 + form values; returns `WebDriverElement`, not `\DOMElement`; Bootstrap 5 smooth-scroll can 159 + interfere (`$enable-smooth-scroll: false`). 160 + 161 + ## Playwright (alternative) 162 + 163 + For richer cross-browser JS scenarios (WebKit, tracing, video), run Playwright separately 164 + against a running app. Use this when Panther's WebDriver model is too limiting. 165 + 166 + ### JavaScript Playwright tests 167 + 168 + ```javascript 169 + // tests/e2e/login.spec.js 170 + const { test, expect } = require('@playwright/test'); 171 + 172 + test('user can login', async ({ page }) => { 173 + await page.goto('/login'); 174 + 175 + await page.fill('input[name="email"]', 'test@example.com'); 176 + await page.fill('input[name="password"]', 'password'); 177 + await page.click('button[type="submit"]'); 178 + 179 + await expect(page).toHaveURL('/dashboard'); 180 + await expect(page.locator('h1')).toContainText('Dashboard'); 181 + }); 182 + 183 + test('login validation', async ({ page }) => { 184 + await page.goto('/login'); 185 + 186 + await page.fill('input[name="email"]', 'invalid'); 187 + await page.click('button[type="submit"]'); 188 + 189 + await expect(page.locator('.error-message')).toBeVisible(); 190 + }); 191 + ``` 192 + 193 + ### Playwright configuration 194 + 195 + ```javascript 196 + // playwright.config.js 197 + module.exports = { 198 + testDir: './tests/e2e', 199 + timeout: 30000, 200 + use: { 201 + baseURL: 'http://localhost:8000', 202 + screenshot: 'only-on-failure', 203 + video: 'retain-on-failure', 204 + }, 205 + projects: [ 206 + { name: 'chromium', use: { browserName: 'chromium' } }, 207 + { name: 'firefox', use: { browserName: 'firefox' } }, 208 + { name: 'webkit', use: { browserName: 'webkit' } }, 209 + ], 210 + }; 211 + ``` 212 + 213 + ## Testing user flows 214 + 215 + ### Panther: complete checkout flow 216 + 217 + ```php 218 + public function testCheckoutFlow(): void 219 + { 220 + $client = static::createPantherClient(); 221 + 222 + // 1. Login 223 + $client->request('GET', '/login'); 224 + $client->submitForm('Login', [ 225 + 'email' => 'test@example.com', 226 + 'password' => 'password', 227 + ]); 228 + $client->waitFor('.dashboard'); 229 + 230 + // 2. Browse products 231 + $client->clickLink('Products'); 232 + $client->waitFor('.product-list'); 233 + 234 + // 3. Add to cart 235 + $client->click('.product-card:first-child .add-to-cart'); 236 + $client->waitForElementToContain('.cart-count', '1'); 237 + 238 + // 4. Cart 239 + $client->clickLink('Cart'); 240 + $client->waitFor('.cart-items'); 241 + 242 + // 5. Checkout 243 + $client->clickLink('Checkout'); 244 + $client->waitFor('.checkout-form'); 245 + 246 + // 6. Shipping 247 + $client->submitForm('Continue', [ 248 + 'shipping[address]' => '123 Main St', 249 + 'shipping[city]' => 'Paris', 250 + 'shipping[zip]' => '75001', 251 + ]); 252 + 253 + // 7. Confirm 254 + $client->waitFor('.order-summary'); 255 + $client->click('.confirm-order'); 256 + 257 + // 8. Verify 258 + $this->assertSelectorWillContain('.order-confirmation', 'Thank you'); 259 + } 260 + ``` 261 + 262 + ## CI configuration 263 + 264 + ### GitHub Actions with Panther 265 + 266 + ```yaml 267 + # .github/workflows/e2e.yml 268 + name: E2E Tests 269 + 270 + on: [push, pull_request] 271 + 272 + jobs: 273 + e2e: 274 + runs-on: ubuntu-latest 275 + 276 + services: 277 + database: 278 + image: postgres:16 279 + env: 280 + POSTGRES_PASSWORD: test 281 + options: >- 282 + --health-cmd pg_isready 283 + --health-interval 10s 284 + --health-timeout 5s 285 + --health-retries 5 286 + 287 + steps: 288 + - uses: actions/checkout@v6 289 + 290 + - name: Setup PHP 291 + uses: shivammathur/setup-php@v2 292 + with: 293 + php-version: '8.4' 294 + extensions: pdo_pgsql 295 + 296 + - name: Install Chrome 297 + uses: browser-actions/setup-chrome@latest 298 + 299 + - name: Install dependencies 300 + run: composer install --no-interaction --prefer-dist --no-progress 301 + 302 + - name: Detect WebDriver 303 + run: vendor/bin/bdi detect drivers 304 + 305 + - name: Setup database 306 + run: | 307 + bin/console doctrine:database:create --env=test 308 + bin/console doctrine:migrations:migrate --no-interaction --env=test 309 + bin/console doctrine:fixtures:load --no-interaction --env=test 310 + 311 + - name: Run E2E tests 312 + run: ./vendor/bin/phpunit tests/E2E 313 + env: 314 + PANTHER_NO_SANDBOX: 1 315 + ``` 316 + 317 + ## Best practices 318 + 319 + 1. **Test critical paths**: login, checkout, signup. 320 + 2. **Explicit waits**: prefer `waitFor*` / `assertSelectorWill*` over implicit timing. 321 + 3. **Stable selectors**: target `data-testid` attributes, not styling classes. 322 + 4. **Separate from unit tests**: E2E is slow — keep it in its own suite/dir. 323 + 5. **Reset state**: clean the database between tests (DAMADoctrineTestBundle / Foundry reset). 324 + 6. **Clean up screenshots**: delete any PNGs produced; never commit them. 325 + 326 + 327 + ## Skill Operating Checklist 328 + 329 + ### Design checklist 330 + - Confirm operation boundaries and invariants first. 331 + - Minimize scope while preserving contract correctness. 332 + - Test both happy path and negative path behavior. 333 + 334 + ### Validation commands 335 + - vendor/bin/bdi detect drivers 336 + - ./vendor/bin/phpunit tests/E2E 337 + - PANTHER_NO_HEADLESS=1 bin/phpunit --debug 338 + 339 + ### Failure modes to test 340 + - Invalid payload or forbidden actor. 341 + - Boundary values / not-found cases. 342 + - Retry or partial-failure behavior for async flows.
+39
.agents/skills/symfony-effective-context/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-effective-context 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Provide effective context to Claude for Symfony development with relevant files, patterns, and constraints 9 + --- 10 + 11 + # Effective Context (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+237
.agents/skills/symfony-effective-context/reference.md
··· 1 + # Reference 2 + 3 + # Providing Effective Context 4 + 5 + ## What Context Helps 6 + 7 + When asking Claude for help with Symfony, provide: 8 + 9 + 1. **Relevant code files** - Entity, Service, Controller involved 10 + 2. **Error messages** - Full stack trace if available 11 + 3. **Symfony version** - 8.x (stable), 7.4 LTS, or 6.4 legacy LTS 12 + 4. **Installed bundles** - API Platform, Messenger, etc. 13 + 5. **Constraints** - Performance requirements, backward compatibility 14 + 15 + ## Code Context Examples 16 + 17 + ### For Entity Questions 18 + 19 + Provide: 20 + ``` 21 + - The entity file 22 + - Related entities (if relationships involved) 23 + - The repository if custom queries 24 + - Current migration state 25 + ``` 26 + 27 + ### For API Platform Questions 28 + 29 + Provide: 30 + ``` 31 + - Entity with API Platform attributes 32 + - Custom providers/processors if any 33 + - Relevant filters 34 + - Expected request/response format 35 + ``` 36 + 37 + ### For Testing Questions 38 + 39 + Provide: 40 + ``` 41 + - Test file 42 + - Code being tested 43 + - Factory files 44 + - Error output 45 + ``` 46 + 47 + ## Effective Prompts 48 + 49 + ### Good: Specific and Contextual 50 + 51 + ``` 52 + I have a Symfony 7 app with API Platform 4. I need to add a filter 53 + that searches across multiple fields (title, description, author name). 54 + 55 + Here's my entity: 56 + [paste entity code] 57 + 58 + Current filters work for single fields. How do I create a custom 59 + filter that does OR search across these fields? 60 + ``` 61 + 62 + ### Bad: Vague and Missing Context 63 + 64 + ``` 65 + How do I search in API Platform? 66 + ``` 67 + 68 + ## Including Error Context 69 + 70 + ### Full Error Message 71 + 72 + ``` 73 + When I run bin/console doctrine:migrations:migrate, I get: 74 + 75 + [Error message with full stack trace] 76 + 77 + My entity is: 78 + [entity code] 79 + 80 + My migration is: 81 + [migration code] 82 + ``` 83 + 84 + ### Relevant Log Output 85 + 86 + ``` 87 + The Messenger worker crashes with this error in var/log/dev.log: 88 + 89 + [2024-01-15 10:30:00] messenger.ERROR: Error thrown while handling message... 90 + 91 + My message handler: 92 + [handler code] 93 + 94 + My message: 95 + [message code] 96 + ``` 97 + 98 + ## Constraints to Mention 99 + 100 + ### Performance Requirements 101 + 102 + ``` 103 + This endpoint needs to handle 1000 requests/second. Currently it's slow 104 + because of N+1 queries. Here's the code: 105 + [code] 106 + ``` 107 + 108 + ### Backward Compatibility 109 + 110 + ``` 111 + I need to add a new field to the API response without breaking existing 112 + clients. Current response format: 113 + [JSON example] 114 + ``` 115 + 116 + ### Existing Patterns 117 + 118 + ``` 119 + Our codebase uses CQRS pattern. Here's an example command handler: 120 + [example code] 121 + 122 + I need to add a new feature following the same pattern. 123 + ``` 124 + 125 + ## Project Structure Context 126 + 127 + ### When Asking About Architecture 128 + 129 + ``` 130 + Our project structure: 131 + src/ 132 + ├── Domain/ # Entities, Value Objects 133 + ├── Application/ # Commands, Queries, Handlers 134 + └── Infrastructure/ # Controllers, Repositories 135 + 136 + We follow hexagonal architecture. How should I structure a new 137 + feature for user notifications? 138 + ``` 139 + 140 + ### When Asking About Configuration 141 + 142 + ``` 143 + config/packages/messenger.yaml: 144 + [current config] 145 + 146 + I need to add a second transport for high-priority messages. 147 + ``` 148 + 149 + ## Version-Specific Context 150 + 151 + ### Symfony Version 152 + 153 + ``` 154 + Symfony 6.4 LTS project. Can I use MapRequestPayload attribute? 155 + Or is that only in 7.x? 156 + ``` 157 + 158 + ### PHP Version 159 + 160 + ``` 161 + PHP 8.2, Symfony 7.1. I want to use readonly classes for my DTOs. 162 + ``` 163 + 164 + ### Bundle Versions 165 + 166 + ``` 167 + API Platform 3.2. I see examples using "operations" but my version 168 + uses "collectionOperations". Which syntax should I use? 169 + ``` 170 + 171 + ## Anti-Patterns to Avoid 172 + 173 + ### Don't Provide Too Much 174 + 175 + ``` 176 + # Bad: dumping entire project 177 + Here's my entire src/ directory... 178 + [thousands of lines] 179 + ``` 180 + 181 + ### Don't Provide Too Little 182 + 183 + ``` 184 + # Bad: no context 185 + Why doesn't my query work? 186 + ``` 187 + 188 + ### Don't Assume Knowledge 189 + 190 + ``` 191 + # Bad: referring to unseen code 192 + The UserService I showed you earlier... 193 + [But it wasn't shown] 194 + ``` 195 + 196 + ## Template for Asking Questions 197 + 198 + ```markdown 199 + ## Context 200 + - Symfony version: X.Y 201 + - PHP version: 8.X 202 + - Relevant bundles: [list] 203 + 204 + ## What I'm Trying to Do 205 + [Clear description of goal] 206 + 207 + ## Current Code 208 + [Relevant files only] 209 + 210 + ## What's Happening 211 + [Error message or unexpected behavior] 212 + 213 + ## What I've Tried 214 + [Previous attempts if any] 215 + 216 + ## Constraints 217 + [Performance, compatibility, patterns to follow] 218 + ``` 219 + 220 + 221 + ## Skill Operating Checklist 222 + 223 + ### Design checklist 224 + - Confirm operation boundaries and invariants first. 225 + - Minimize scope while preserving contract correctness. 226 + - Test both happy path and negative path behavior. 227 + 228 + ### Validation commands 229 + - rg --files 230 + - composer validate 231 + - ./vendor/bin/phpstan analyse 232 + 233 + ### Failure modes to test 234 + - Invalid payload or forbidden actor. 235 + - Boundary values / not-found cases. 236 + - Retry or partial-failure behavior for async flows. 237 +
+42
.agents/skills/symfony-executing-plans/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-executing-plans 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Methodically execute implementation plans with a TDD approach, incremental commits, and continuous validation 12 + --- 13 + 14 + # Executing Plans (Symfony) 15 + 16 + ## Use when 17 + - Refining architecture/workflows/context handling in Symfony projects. 18 + - Planning and executing medium/complex changes safely. 19 + 20 + ## Default workflow 21 + 1. Establish current boundaries, constraints, and coupling points. 22 + 2. Propose smallest coherent architectural adjustment. 23 + 3. Execute in checkpoints with validation at each stage. 24 + 4. Summarize tradeoffs and follow-up backlog. 25 + 26 + ## Guardrails 27 + - Use existing project patterns by default. 28 + - Avoid broad refactors without explicit need. 29 + - Keep decision log clear and auditable. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Architecture/workflow changes. 37 + - Checkpoint validation outcomes. 38 + - Residual risks and next steps. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+291
.agents/skills/symfony-executing-plans/reference.md
··· 1 + # Reference 2 + 3 + # Executing Implementation Plans 4 + 5 + Follow this skill to execute plans systematically with quality gates. 6 + 7 + ## Execution Workflow 8 + 9 + ### Step 1: Setup 10 + 11 + Before starting: 12 + 13 + ```bash 14 + # Ensure clean state 15 + git status 16 + 17 + # Create feature branch 18 + git checkout -b feature/[feature-name] 19 + 20 + # Pull latest dependencies 21 + composer install 22 + 23 + # Clear cache 24 + bin/console cache:clear 25 + 26 + # Ensure tests pass 27 + ./vendor/bin/pest # or phpunit 28 + ``` 29 + 30 + ### Step 2: For Each Plan Step 31 + 32 + Follow the TDD cycle: 33 + 34 + ``` 35 + ┌─────────────────┐ 36 + │ Read Step │ 37 + └────────┬────────┘ 38 + 39 + 40 + ┌─────────────────┐ 41 + │ Write Test │◄──────┐ 42 + │ (RED) │ │ 43 + └────────┬────────┘ │ 44 + │ │ 45 + ▼ │ 46 + ┌─────────────────┐ │ 47 + │ Run Test │ │ 48 + │ (Verify Fail) │ │ 49 + └────────┬────────┘ │ 50 + │ │ 51 + ▼ │ 52 + ┌─────────────────┐ │ 53 + │ Implement │ │ 54 + │ (GREEN) │ │ 55 + └────────┬────────┘ │ 56 + │ │ 57 + ▼ │ 58 + ┌─────────────────┐ │ 59 + │ Run Test │───No──┘ 60 + │ (Verify Pass) │ 61 + └────────┬────────┘ 62 + │ Yes 63 + 64 + ┌─────────────────┐ 65 + │ Refactor │ 66 + └────────┬────────┘ 67 + 68 + 69 + ┌─────────────────┐ 70 + │ Commit │ 71 + └─────────────────┘ 72 + ``` 73 + 74 + ### Step 3: Commit Strategy 75 + 76 + Commit after each completed step: 77 + 78 + ```bash 79 + # Stage changes 80 + git add src/Entity/Order.php 81 + git add tests/Unit/Entity/OrderTest.php 82 + 83 + # Commit with clear message 84 + git commit -m "feat(order): add Order entity with status enum 85 + 86 + - Create Order entity with uuid, status, customer relation 87 + - Create OrderStatus enum (pending, processing, completed, cancelled) 88 + - Add migration for orders table 89 + - Add unit tests for entity" 90 + ``` 91 + 92 + ### Step 4: Quality Gates 93 + 94 + Run after each phase: 95 + 96 + ```bash 97 + # Code style 98 + ./vendor/bin/php-cs-fixer fix --dry-run 99 + 100 + # Static analysis 101 + ./vendor/bin/phpstan analyse 102 + 103 + # Tests 104 + ./vendor/bin/pest 105 + 106 + # All checks 107 + composer run-script check 108 + ``` 109 + 110 + ## Execution Patterns 111 + 112 + ### Entity Implementation 113 + 114 + ```bash 115 + # 1. Create test 116 + # tests/Unit/Entity/OrderTest.php 117 + 118 + # 2. Create entity 119 + bin/console make:entity Order 120 + 121 + # 3. Adjust entity code 122 + 123 + # 4. Create migration 124 + bin/console make:migration 125 + 126 + # 5. Run migration 127 + bin/console doctrine:migrations:migrate 128 + 129 + # 6. Verify 130 + bin/console doctrine:schema:validate 131 + ``` 132 + 133 + ### Service Implementation 134 + 135 + ```bash 136 + # 1. Create test 137 + # tests/Unit/Service/OrderServiceTest.php 138 + 139 + # 2. Create service interface (if needed) 140 + # src/Service/OrderServiceInterface.php 141 + 142 + # 3. Create service 143 + # src/Service/OrderService.php 144 + 145 + # 4. Configure in services.yaml (if needed) 146 + 147 + # 5. Run tests 148 + ./vendor/bin/pest tests/Unit/Service/OrderServiceTest.php 149 + ``` 150 + 151 + ### API Endpoint Implementation 152 + 153 + ```bash 154 + # 1. Create functional test 155 + # tests/Functional/Api/OrderTest.php 156 + 157 + # 2. Configure API Platform resource 158 + 159 + # 3. Create/configure voter 160 + 161 + # 4. Run tests 162 + ./vendor/bin/pest tests/Functional/Api/OrderTest.php 163 + 164 + # 5. Verify in browser/Postman 165 + curl http://localhost/api/orders 166 + ``` 167 + 168 + ### Message Handler Implementation 169 + 170 + ```bash 171 + # 1. Create message class 172 + # src/Message/ProcessOrder.php 173 + 174 + # 2. Create handler test 175 + # tests/Unit/MessageHandler/ProcessOrderHandlerTest.php 176 + 177 + # 3. Create handler 178 + # src/MessageHandler/ProcessOrderHandler.php 179 + 180 + # 4. Configure routing in messenger.yaml 181 + 182 + # 5. Run tests with in-memory transport 183 + ./vendor/bin/pest tests/Unit/MessageHandler/ 184 + ``` 185 + 186 + ## Handling Blockers 187 + 188 + ### When tests fail unexpectedly 189 + 190 + ```bash 191 + # Run single test with verbose output 192 + ./vendor/bin/pest tests/path/to/Test.php --filter testName -vvv 193 + 194 + # Check logs 195 + tail -f var/log/dev.log 196 + 197 + # Debug with dump 198 + dd($variable); # or dump($variable); 199 + ``` 200 + 201 + ### When migrations fail 202 + 203 + ```bash 204 + # Check status 205 + bin/console doctrine:migrations:status 206 + 207 + # Rollback last migration 208 + bin/console doctrine:migrations:migrate prev 209 + 210 + # Regenerate migration 211 + bin/console doctrine:migrations:diff 212 + ``` 213 + 214 + ### When services won't autowire 215 + 216 + ```bash 217 + # Debug autowiring 218 + bin/console debug:autowiring ServiceName 219 + 220 + # Check container 221 + bin/console debug:container ServiceName 222 + 223 + # Clear cache 224 + bin/console cache:clear 225 + ``` 226 + 227 + ## Progress Tracking 228 + 229 + Update plan checkboxes as you complete: 230 + 231 + ```markdown 232 + ## Steps 233 + 1. [x] Create entity ✓ (commit: abc123) 234 + 2. [x] Create migration ✓ (commit: def456) 235 + 3. [ ] Create service <- CURRENT 236 + 4. [ ] Create tests 237 + ``` 238 + 239 + ## Final Validation 240 + 241 + Before marking plan complete: 242 + 243 + ```bash 244 + # Full test suite 245 + ./vendor/bin/pest 246 + 247 + # Code coverage 248 + ./vendor/bin/pest --coverage --min=80 249 + 250 + # Static analysis 251 + ./vendor/bin/phpstan analyse 252 + 253 + # Code style 254 + ./vendor/bin/php-cs-fixer fix 255 + 256 + # Manual testing 257 + # - Test happy path 258 + # - Test edge cases 259 + # - Test error handling 260 + ``` 261 + 262 + ## Merge Checklist 263 + 264 + Before merging feature branch: 265 + 266 + - [ ] All tests pass 267 + - [ ] Code coverage maintained/improved 268 + - [ ] No PHPStan errors 269 + - [ ] Code style fixed 270 + - [ ] Documentation updated 271 + - [ ] PR reviewed 272 + - [ ] Rebased on main 273 + 274 + 275 + ## Skill Operating Checklist 276 + 277 + ### Design checklist 278 + - Confirm operation boundaries and invariants first. 279 + - Minimize scope while preserving contract correctness. 280 + - Test both happy path and negative path behavior. 281 + 282 + ### Validation commands 283 + - rg --files 284 + - composer validate 285 + - ./vendor/bin/phpstan analyse 286 + 287 + ### Failure modes to test 288 + - Invalid payload or forbidden actor. 289 + - Boundary values / not-found cases. 290 + - Retry or partial-failure behavior for async flows. 291 +
+42
.agents/skills/symfony-form-types-validation/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-form-types-validation 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Build Symfony forms with custom Form Types, validation constraints, HTTP 422 handling, and multi-step flows 12 + --- 13 + 14 + # Form Types Validation (Symfony) 15 + 16 + ## Use when 17 + - Hardening access-control or validation boundaries. 18 + - Aligning voters/security expressions with domain rules. 19 + 20 + ## Default workflow 21 + 1. Map actor/resource/action decision matrix. 22 + 2. Implement voter/constraint logic at the right boundary. 23 + 3. Wire checks at controllers and API operations. 24 + 4. Test allowed/forbidden/invalid paths comprehensively. 25 + 26 + ## Guardrails 27 + - Avoid policy logic duplication across layers. 28 + - Do not leak privileged state via error detail. 29 + - Preserve explicit deny behavior for sensitive actions. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Security boundary updates. 37 + - Integration points enforcing decisions. 38 + - Negative-path test results. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+455
.agents/skills/symfony-form-types-validation/reference.md
··· 1 + # Reference 2 + 3 + # Symfony Forms and Validation 4 + 5 + ## Basic Form Type 6 + 7 + ```php 8 + <?php 9 + // src/Form/UserType.php 10 + 11 + namespace App\Form; 12 + 13 + use App\Entity\User; 14 + use Symfony\Component\Form\AbstractType; 15 + use Symfony\Component\Form\Extension\Core\Type\EmailType; 16 + use Symfony\Component\Form\Extension\Core\Type\PasswordType; 17 + use Symfony\Component\Form\Extension\Core\Type\RepeatedType; 18 + use Symfony\Component\Form\Extension\Core\Type\TextType; 19 + use Symfony\Component\Form\FormBuilderInterface; 20 + use Symfony\Component\OptionsResolver\OptionsResolver; 21 + 22 + class UserType extends AbstractType 23 + { 24 + public function buildForm(FormBuilderInterface $builder, array $options): void 25 + { 26 + $builder 27 + ->add('name', TextType::class, [ 28 + 'label' => 'Full Name', 29 + 'attr' => ['placeholder' => 'John Doe'], 30 + ]) 31 + ->add('email', EmailType::class, [ 32 + 'label' => 'Email Address', 33 + ]) 34 + ->add('password', RepeatedType::class, [ 35 + 'type' => PasswordType::class, 36 + 'first_options' => ['label' => 'Password'], 37 + 'second_options' => ['label' => 'Confirm Password'], 38 + 'invalid_message' => 'The passwords do not match.', 39 + ]) 40 + ; 41 + } 42 + 43 + public function configureOptions(OptionsResolver $resolver): void 44 + { 45 + $resolver->setDefaults([ 46 + 'data_class' => User::class, 47 + ]); 48 + } 49 + } 50 + ``` 51 + 52 + ## Validation Constraints 53 + 54 + ### On Entity 55 + 56 + ```php 57 + <?php 58 + // src/Entity/User.php 59 + 60 + namespace App\Entity; 61 + 62 + use Symfony\Component\Validator\Constraints as Assert; 63 + use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; 64 + 65 + #[ORM\Entity] 66 + #[UniqueEntity(fields: ['email'], message: 'This email is already registered.')] 67 + class User 68 + { 69 + #[ORM\Column(length: 255)] 70 + #[Assert\NotBlank(message: 'Please enter your name.')] 71 + #[Assert\Length( 72 + min: 2, 73 + max: 100, 74 + minMessage: 'Name must be at least {{ limit }} characters.', 75 + maxMessage: 'Name cannot exceed {{ limit }} characters.', 76 + )] 77 + private string $name; 78 + 79 + #[ORM\Column(length: 255, unique: true)] 80 + #[Assert\NotBlank] 81 + #[Assert\Email(message: 'Please enter a valid email address.')] 82 + private string $email; 83 + 84 + #[ORM\Column] 85 + #[Assert\NotBlank] 86 + #[Assert\Length(min: 8, minMessage: 'Password must be at least {{ limit }} characters.')] 87 + #[Assert\Regex( 88 + pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/', 89 + message: 'Password must contain uppercase, lowercase, and numbers.', 90 + )] 91 + private string $password; 92 + 93 + #[ORM\Column(type: 'date')] 94 + #[Assert\NotNull] 95 + #[Assert\LessThan('-18 years', message: 'You must be at least 18 years old.')] 96 + private \DateTimeInterface $birthDate; 97 + } 98 + ``` 99 + 100 + ### On Form Type 101 + 102 + ```php 103 + public function buildForm(FormBuilderInterface $builder, array $options): void 104 + { 105 + $builder 106 + ->add('website', UrlType::class, [ 107 + 'constraints' => [ 108 + new Assert\Url(), 109 + new Assert\Length(['max' => 255]), 110 + ], 111 + ]) 112 + ->add('age', IntegerType::class, [ 113 + 'constraints' => [ 114 + new Assert\Range(['min' => 18, 'max' => 120]), 115 + ], 116 + ]) 117 + ; 118 + } 119 + ``` 120 + 121 + ## Validation Groups 122 + 123 + ```php 124 + <?php 125 + // src/Entity/User.php 126 + 127 + class User 128 + { 129 + #[Assert\NotBlank(groups: ['registration', 'profile'])] 130 + private string $name; 131 + 132 + #[Assert\NotBlank(groups: ['registration'])] 133 + #[Assert\Email(groups: ['registration', 'profile'])] 134 + private string $email; 135 + 136 + #[Assert\NotBlank(groups: ['registration'])] 137 + private string $password; 138 + } 139 + 140 + // src/Form/RegistrationType.php 141 + 142 + public function configureOptions(OptionsResolver $resolver): void 143 + { 144 + $resolver->setDefaults([ 145 + 'data_class' => User::class, 146 + 'validation_groups' => ['registration'], 147 + ]); 148 + } 149 + 150 + // src/Form/ProfileType.php 151 + 152 + public function configureOptions(OptionsResolver $resolver): void 153 + { 154 + $resolver->setDefaults([ 155 + 'data_class' => User::class, 156 + 'validation_groups' => ['profile'], 157 + ]); 158 + } 159 + ``` 160 + 161 + ## Custom Constraint 162 + 163 + ```php 164 + <?php 165 + // src/Validator/Constraints/ValidPhoneNumber.php 166 + 167 + namespace App\Validator\Constraints; 168 + 169 + use Symfony\Component\Validator\Constraint; 170 + 171 + #[\Attribute] 172 + class ValidPhoneNumber extends Constraint 173 + { 174 + public string $message = 'The phone number "{{ value }}" is not valid.'; 175 + public string $region = 'FR'; 176 + } 177 + 178 + // src/Validator/Constraints/ValidPhoneNumberValidator.php 179 + 180 + namespace App\Validator\Constraints; 181 + 182 + use Symfony\Component\Validator\Constraint; 183 + use Symfony\Component\Validator\ConstraintValidator; 184 + use Symfony\Component\Validator\Exception\UnexpectedTypeException; 185 + 186 + class ValidPhoneNumberValidator extends ConstraintValidator 187 + { 188 + public function validate(mixed $value, Constraint $constraint): void 189 + { 190 + if (!$constraint instanceof ValidPhoneNumber) { 191 + throw new UnexpectedTypeException($constraint, ValidPhoneNumber::class); 192 + } 193 + 194 + if (null === $value || '' === $value) { 195 + return; // Let NotBlank handle empty values 196 + } 197 + 198 + // Custom validation logic 199 + $phoneUtil = \libphonenumber\PhoneNumberUtil::getInstance(); 200 + try { 201 + $number = $phoneUtil->parse($value, $constraint->region); 202 + if (!$phoneUtil->isValidNumber($number)) { 203 + $this->context->buildViolation($constraint->message) 204 + ->setParameter('{{ value }}', $value) 205 + ->addViolation(); 206 + } 207 + } catch (\Exception $e) { 208 + $this->context->buildViolation($constraint->message) 209 + ->setParameter('{{ value }}', $value) 210 + ->addViolation(); 211 + } 212 + } 213 + } 214 + ``` 215 + 216 + Usage: 217 + 218 + ```php 219 + #[ValidPhoneNumber(region: 'US')] 220 + private string $phone; 221 + ``` 222 + 223 + ## Data Transformers 224 + 225 + ```php 226 + <?php 227 + // src/Form/DataTransformer/TagsTransformer.php 228 + 229 + namespace App\Form\DataTransformer; 230 + 231 + use App\Entity\Tag; 232 + use Doctrine\Common\Collections\ArrayCollection; 233 + use Doctrine\ORM\EntityManagerInterface; 234 + use Symfony\Component\Form\DataTransformerInterface; 235 + 236 + class TagsTransformer implements DataTransformerInterface 237 + { 238 + public function __construct( 239 + private EntityManagerInterface $em, 240 + ) {} 241 + 242 + // Entity Collection -> String (for display) 243 + public function transform(mixed $value): string 244 + { 245 + if ($value->isEmpty()) { 246 + return ''; 247 + } 248 + 249 + return implode(', ', $value->map(fn(Tag $tag) => $tag->getName())->toArray()); 250 + } 251 + 252 + // String -> Entity Collection (from input) 253 + public function reverseTransform(mixed $value): ArrayCollection 254 + { 255 + if (!$value) { 256 + return new ArrayCollection(); 257 + } 258 + 259 + $names = array_map('trim', explode(',', $value)); 260 + $tags = new ArrayCollection(); 261 + 262 + foreach ($names as $name) { 263 + if (empty($name)) { 264 + continue; 265 + } 266 + 267 + $tag = $this->em->getRepository(Tag::class)->findOneBy(['name' => $name]); 268 + 269 + if (!$tag) { 270 + $tag = new Tag(); 271 + $tag->setName($name); 272 + $this->em->persist($tag); 273 + } 274 + 275 + $tags->add($tag); 276 + } 277 + 278 + return $tags; 279 + } 280 + } 281 + ``` 282 + 283 + Usage in form: 284 + 285 + ```php 286 + public function buildForm(FormBuilderInterface $builder, array $options): void 287 + { 288 + $builder 289 + ->add('tags', TextType::class, [ 290 + 'label' => 'Tags (comma-separated)', 291 + ]) 292 + ; 293 + 294 + $builder->get('tags')->addModelTransformer($this->tagsTransformer); 295 + } 296 + ``` 297 + 298 + ## Form Events 299 + 300 + ```php 301 + public function buildForm(FormBuilderInterface $builder, array $options): void 302 + { 303 + $builder 304 + ->add('country', CountryType::class) 305 + ; 306 + 307 + // Add state field dynamically based on country 308 + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { 309 + $form = $event->getForm(); 310 + $data = $event->getData(); 311 + 312 + $country = $data?->getCountry(); 313 + $this->addStateField($form, $country); 314 + }); 315 + 316 + $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) { 317 + $form = $event->getForm(); 318 + $data = $event->getData(); 319 + 320 + $country = $data['country'] ?? null; 321 + $this->addStateField($form, $country); 322 + }); 323 + } 324 + 325 + private function addStateField(FormInterface $form, ?string $country): void 326 + { 327 + if ($country === 'US') { 328 + $form->add('state', ChoiceType::class, [ 329 + 'choices' => $this->usStates, 330 + ]); 331 + } else { 332 + $form->add('state', TextType::class, [ 333 + 'required' => false, 334 + ]); 335 + } 336 + } 337 + ``` 338 + 339 + ## Controller Usage 340 + 341 + ```php 342 + #[Route('/register', name: 'register')] 343 + public function register(Request $request): Response 344 + { 345 + $user = new User(); 346 + $form = $this->createForm(UserType::class, $user); 347 + 348 + $form->handleRequest($request); 349 + 350 + if ($form->isSubmitted() && $form->isValid()) { 351 + $this->em->persist($user); 352 + $this->em->flush(); 353 + 354 + $this->addFlash('success', 'Registration successful!'); 355 + return $this->redirectToRoute('home'); 356 + } 357 + 358 + return $this->render('security/register.html.twig', [ 359 + 'form' => $form, 360 + ]); 361 + } 362 + ``` 363 + 364 + > **HTTP 422 on invalid submit (Symfony 8.0+):** pass the *form* (not 365 + > `$form->createView()`) to `render()`. When the form was submitted and is 366 + > invalid, Symfony automatically sets the response status to 367 + > **422 Unprocessable Content** (Turbo-compatible) instead of 200. The snippet 368 + > above already passes `'form' => $form`, so it benefits from this. 369 + 370 + ## Recent Constraints 371 + 372 + ```php 373 + use Symfony\Component\Validator\Constraints as Assert; 374 + 375 + class ChangePassword 376 + { 377 + // Strong password without a regex (weak | medium | strong | very_strong) 378 + #[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_STRONG)] 379 + #[Assert\NotCompromisedPassword] 380 + public string $newPassword; 381 + 382 + // Run constraints in order, stopping at the first violation 383 + #[Assert\Sequentially([ 384 + new Assert\NotBlank(), 385 + new Assert\Length(min: 3), 386 + new Assert\Regex('/^[a-z0-9_]+$/'), 387 + ])] 388 + public string $username; 389 + 390 + // Conditional validation 391 + #[Assert\When( 392 + expression: 'this.type === "company"', 393 + constraints: [new Assert\NotBlank(), new Assert\Length(max: 14)], 394 + )] 395 + public ?string $vatNumber = null; 396 + 397 + public string $type = 'individual'; 398 + } 399 + ``` 400 + 401 + ## Multi-Step Forms (Symfony 8.1+ — verify) 402 + 403 + ```php 404 + // Build a form spread across several steps from a single object. 405 + $form = $this->createFormFlowBuilder($task)->getForm(); 406 + ``` 407 + 408 + ## Custom Violation Mapper (Symfony 8.1+ — verify) 409 + 410 + Override how constraint violations are mapped onto form fields by implementing 411 + `ViolationMapperInterface`; it is auto-registered as the `form.violation_mapper` 412 + service. 413 + 414 + ```php 415 + use Symfony\Component\Form\Util\ViolationMapperInterface; 416 + 417 + class CustomViolationMapper implements ViolationMapperInterface 418 + { 419 + public function mapViolation( 420 + ConstraintViolation $violation, 421 + FormInterface $form, 422 + bool $allowNonSynchronized = false, 423 + ): void { 424 + // custom mapping 425 + } 426 + } 427 + ``` 428 + 429 + ## Best Practices 430 + 431 + 1. **Constraints on entities**: Primary validation source 432 + 2. **Form constraints for UI-specific validation**: File uploads, etc. 433 + 3. **Validation groups**: Different rules for different contexts 434 + 4. **Data transformers**: Convert between formats 435 + 5. **Custom constraints**: Reusable business logic 436 + 6. **Test validation**: Unit test constraints 437 + 438 + 439 + ## Skill Operating Checklist 440 + 441 + ### Design checklist 442 + - Confirm operation boundaries and invariants first. 443 + - Minimize scope while preserving contract correctness. 444 + - Test both happy path and negative path behavior. 445 + 446 + ### Validation commands 447 + - ./vendor/bin/phpunit --filter=Voter 448 + - php bin/console debug:container security 449 + - ./vendor/bin/phpstan analyse 450 + 451 + ### Failure modes to test 452 + - Invalid payload or forbidden actor. 453 + - Boundary values / not-found cases. 454 + - Retry or partial-failure behavior for async flows. 455 +
+41
.agents/skills/symfony-functional-tests/SKILL.md
··· 1 + --- 2 + name: symfony-functional-tests 3 + allowed-tools: 4 + - Read 5 + - Write 6 + - Edit 7 + - Bash 8 + - Glob 9 + - Grep 10 + description: Write functional tests for Symfony controllers and HTTP endpoints using WebTestCase, getContainer, loginUser, and DAMA rollback 11 + --- 12 + 13 + # Functional Tests (Symfony) 14 + 15 + ## Use when 16 + - Building regression-safe behavior with TDD/functional/e2e tests. 17 + - Converting bug reports into executable failing tests. 18 + 19 + ## Default workflow 20 + 1. Write failing test for target behavior and one boundary case. 21 + 2. Implement minimal code to pass. 22 + 3. Refactor while preserving green suite. 23 + 4. Broaden coverage for invalid/unauthorized/not-found paths. 24 + 25 + ## Guardrails 26 + - Prefer deterministic fixtures/builders. 27 + - Assert observable behavior, not internal implementation. 28 + - Keep tests isolated and stable in CI. 29 + 30 + ## Progressive disclosure 31 + - Use this file for execution posture and risk controls. 32 + - Open references when deep implementation details are needed. 33 + 34 + ## Output contract 35 + - RED/GREEN/REFACTOR trace. 36 + - Test files changed and executed commands. 37 + - Coverage and confidence notes. 38 + 39 + ## References 40 + - `reference.md` 41 + - `docs/complexity-tiers.md`
+260
.agents/skills/symfony-functional-tests/reference.md
··· 1 + # Functional Tests Reference (Symfony) 2 + 3 + Use this reference for implementation details and review criteria specific to `functional-tests`. 4 + 5 + > **Version applicability** — Targets Symfony 7.4 LTS / 8.x with PHPUnit 10/11. 6 + > `static::getContainer()` is the modern container accessor (not legacy `self::$container`); 7 + > `assertResponseIsUnprocessable` (422) and `assertSessionHasFlashMessage` (8.1+) are recent. 8 + 9 + ## Setup 10 + 11 + ```bash 12 + composer require --dev symfony/test-pack 13 + php bin/phpunit 14 + ``` 15 + 16 + Flex creates `phpunit.dist.xml` and `tests/bootstrap.php`. Test env files: 17 + `.env` → `.env.test` (committed) → `.env.test.local` (gitignored). `.env.local` is **not** 18 + loaded in the test env. 19 + 20 + ## Test base classes 21 + 22 + | Base class | Use for | 23 + | --- | --- | 24 + | `TestCase` (PHPUnit) | Pure unit tests, no kernel. | 25 + | `KernelTestCase` | Integration — boot the kernel, use the **real** container & services. | 26 + | `WebTestCase` | Functional/application — simulate HTTP requests through the kernel. | 27 + 28 + ```php 29 + use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; 30 + 31 + final class HomeControllerTest extends WebTestCase 32 + { 33 + public function testHomepageIsSuccessful(): void 34 + { 35 + $client = static::createClient(); 36 + $client->request('GET', '/'); 37 + 38 + $this->assertResponseIsSuccessful(); 39 + $this->assertSelectorTextContains('h1', 'Hello World'); 40 + } 41 + } 42 + ``` 43 + 44 + > `static::createClient()` boots the kernel internally — do **not** call `bootKernel()` 45 + > first, or you'll get a "kernel already booted" error. 46 + 47 + ## Accessing services (private services in test) 48 + 49 + In the test env the container exposes a special test container where **private** services 50 + are reachable via `static::getContainer()`: 51 + 52 + ```php 53 + final class NewsletterTest extends KernelTestCase 54 + { 55 + public function testGenerate(): void 56 + { 57 + self::bootKernel(); 58 + $container = static::getContainer(); 59 + 60 + $generator = $container->get(NewsletterGenerator::class); 61 + self::assertNotEmpty($generator->generate()); 62 + } 63 + } 64 + ``` 65 + 66 + The kernel reboots between tests. Configure test-only behavior under 67 + `config/packages/test/` or with `when@test`. 68 + 69 + ## Client API 70 + 71 + ```php 72 + $client = static::createClient(); 73 + 74 + $crawler = $client->request('GET', '/'); 75 + $client->request($method, $uri, $parameters = [], $files = [], $server = [], $content = null); 76 + $client->xmlHttpRequest('POST', '/submit', ['name' => 'Fabien']); // AJAX 77 + 78 + $client->followRedirect(); // or followRedirects() to auto-follow 79 + $client->back(); $client->forward(); $client->reload(); 80 + $client->disableReboot(); // reset instead of reboot between requests 81 + $client->enableProfiler(); // collect profiler data for HttpClient/mailer asserts 82 + $client->catchExceptions(false); // let exceptions bubble for debugging 83 + ``` 84 + 85 + ## Authentication 86 + 87 + Prefer the `loginUser()` helper over crafting login form requests: 88 + 89 + ```php 90 + $user = $userRepository->findOneByEmail('admin@example.com'); 91 + $client->loginUser($user); // default firewall 92 + $client->loginUser($user, 'main'); // explicit firewall 93 + ``` 94 + 95 + For lightweight cases, an in-memory user + a `when@test` security provider works too: 96 + 97 + ```php 98 + $client->loginUser(new InMemoryUser('admin', 'password', ['ROLE_ADMIN'])); 99 + ``` 100 + 101 + ## Crawler & forms 102 + 103 + ```php 104 + $client->clickLink('Add comment'); 105 + $client->submitForm('Add comment', ['comment[text]' => 'Nice!']); 106 + 107 + $form = $crawler->selectButton('Submit')->form(); 108 + $form['contact[subject]'] = 'Hello'; 109 + $form['contact[category]']->select('support'); 110 + $form['contact[agree]']->tick(); 111 + $form['contact[file]']->upload('/path/to/file.pdf'); 112 + $client->submit($form); 113 + ``` 114 + 115 + ## Assertions 116 + 117 + ```php 118 + // Response 119 + $this->assertResponseIsSuccessful(); // 2xx 120 + $this->assertResponseStatusCodeSame(201); 121 + $this->assertResponseRedirects('/login', 302); 122 + $this->assertResponseHasHeader('Content-Type'); 123 + $this->assertResponseHeaderSame('Content-Type', 'application/json'); 124 + $this->assertResponseHasCookie('session'); 125 + $this->assertResponseIsUnprocessable(); // 422 — invalid form (Symfony 8.0 Turbo) 126 + 127 + // Request / routing 128 + $this->assertRouteSame('app_home'); 129 + $this->assertRequestAttributeValueSame('_route', 'app_home'); 130 + $this->assertSessionHasFlashMessage('success'); // 8.1+ 131 + 132 + // DOM 133 + $this->assertSelectorExists('.alert'); 134 + $this->assertSelectorNotExists('.error'); 135 + $this->assertSelectorCount(3, 'li.item'); 136 + $this->assertSelectorTextContains('h1', 'Title'); 137 + $this->assertSelectorTextSame('h1', 'Exact Title'); 138 + $this->assertPageTitleContains('Dashboard'); 139 + $this->assertInputValueSame('email', 'a@b.com'); 140 + $this->assertCheckboxChecked('agree'); 141 + 142 + // Mailer (needs the kernel mailer) 143 + $this->assertEmailCount(1); 144 + $this->assertQueuedEmailCount(1); 145 + $email = $this->getMailerMessage(); 146 + $this->assertEmailSubjectContains($email, 'Welcome'); 147 + $this->assertEmailHtmlBodyContains($email, 'Confirm'); 148 + ``` 149 + 150 + ## Database in functional tests 151 + 152 + Use a **real database** with real repositories — repositories are meant to be tested 153 + against a real connection, not mocked. Mocking the EM/repository sacrifices coverage of 154 + the actual query behavior. 155 + 156 + ```bash 157 + # one-time test DB setup 158 + php bin/console --env=test doctrine:database:create 159 + php bin/console --env=test doctrine:schema:create 160 + ``` 161 + 162 + ### Transactional rollback per test — DAMADoctrineTestBundle 163 + 164 + ```bash 165 + composer require --dev dama/doctrine-test-bundle 166 + ``` 167 + 168 + ```xml 169 + <!-- phpunit.dist.xml --> 170 + <extensions> 171 + <bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/> 172 + </extensions> 173 + ``` 174 + 175 + Each test runs inside a transaction that is **rolled back** at the end — fast, isolated, 176 + no manual cleanup. Combine with Foundry's reset/`#[ResetDatabase]` (see the 177 + `doctrine-fixtures-foundry` skill) for fixtures. 178 + 179 + ### Repository test pattern (KernelTestCase + real DB) 180 + 181 + ```php 182 + final class ProductRepositoryTest extends KernelTestCase 183 + { 184 + private EntityManagerInterface $em; 185 + 186 + protected function setUp(): void 187 + { 188 + $kernel = self::bootKernel(); 189 + $this->em = $kernel->getContainer()->get('doctrine')->getManager(); 190 + } 191 + 192 + public function testFindByName(): void 193 + { 194 + $product = $this->em->getRepository(Product::class) 195 + ->findOneBy(['name' => 'Widget']); 196 + 197 + self::assertSame(14.50, $product->getPrice()); 198 + } 199 + 200 + protected function tearDown(): void 201 + { 202 + parent::tearDown(); 203 + $this->em->close(); // avoid memory leaks across tests 204 + unset($this->em); 205 + } 206 + } 207 + ``` 208 + 209 + ## Smoke tests (best practice) 210 + 211 + Cheap, high-value: assert that key URLs return a successful (or expected) status. Use 212 + **hardcoded URLs**, not the router — a smoke test should fail if a route URL silently 213 + changes. 214 + 215 + ```php 216 + /** 217 + * @dataProvider provideUrls 218 + */ 219 + public function testPageIsSuccessful(string $url): void 220 + { 221 + $client = static::createClient(); 222 + $client->request('GET', $url); 223 + 224 + $this->assertResponseIsSuccessful(); 225 + } 226 + 227 + public static function provideUrls(): \Generator 228 + { 229 + yield ['/']; 230 + yield ['/about']; 231 + yield ['/contact']; 232 + } 233 + ``` 234 + 235 + ## Review criteria 236 + 237 + - Functional tests use `WebTestCase` + `static::createClient()`; integration uses `KernelTestCase`. 238 + - Real DB + real repositories (no mocked EM/repository) for data-touching tests. 239 + - DAMADoctrineTestBundle (or Foundry reset) provides per-test isolation. 240 + - Invalid form submissions assert **422** via `assertResponseIsUnprocessable()` (Symfony 8.0+). 241 + - Auth via `loginUser()`, not by replaying the login form. 242 + - Smoke tests cover key URLs with hardcoded paths. 243 + 244 + 245 + ## Skill Operating Checklist 246 + 247 + ### Design checklist 248 + - Confirm operation boundaries and invariants first. 249 + - Minimize scope while preserving contract correctness. 250 + - Test both happy path and negative path behavior. 251 + 252 + ### Validation commands 253 + - ./vendor/bin/phpunit --filter=... 254 + - ./vendor/bin/phpunit 255 + - ./vendor/bin/pest --filter=... 256 + 257 + ### Failure modes to test 258 + - Invalid payload or forbidden actor. 259 + - Boundary values / not-found cases. 260 + - Retry or partial-failure behavior for async flows.
+39
.agents/skills/symfony-interfaces-and-autowiring/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-interfaces-and-autowiring 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Master Symfony Dependency Injection with autowiring, #[Target] interface binding, decoration, and tagged services 9 + --- 10 + 11 + # Interfaces And Autowiring (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+433
.agents/skills/symfony-interfaces-and-autowiring/reference.md
··· 1 + # Reference 2 + 3 + # Interfaces and Autowiring in Symfony 4 + 5 + ## Basic Autowiring 6 + 7 + Symfony automatically injects dependencies based on type-hints: 8 + 9 + ```php 10 + <?php 11 + // src/Service/OrderService.php 12 + 13 + namespace App\Service; 14 + 15 + use Doctrine\ORM\EntityManagerInterface; 16 + use Psr\Log\LoggerInterface; 17 + 18 + class OrderService 19 + { 20 + public function __construct( 21 + private EntityManagerInterface $em, 22 + private LoggerInterface $logger, 23 + ) {} 24 + 25 + public function createOrder(array $data): Order 26 + { 27 + $this->logger->info('Creating order', $data); 28 + // ... 29 + } 30 + } 31 + ``` 32 + 33 + No configuration needed - Symfony wires it automatically. 34 + 35 + ## Interface Binding 36 + 37 + ### Define Interface 38 + 39 + ```php 40 + <?php 41 + // src/Service/PaymentGatewayInterface.php 42 + 43 + namespace App\Service; 44 + 45 + interface PaymentGatewayInterface 46 + { 47 + public function charge(int $amount, string $currency): PaymentResult; 48 + public function refund(string $transactionId, int $amount): RefundResult; 49 + } 50 + ``` 51 + 52 + ### Implementation 53 + 54 + ```php 55 + <?php 56 + // src/Service/StripePaymentGateway.php 57 + 58 + namespace App\Service; 59 + 60 + class StripePaymentGateway implements PaymentGatewayInterface 61 + { 62 + public function __construct( 63 + private string $apiKey, 64 + ) {} 65 + 66 + public function charge(int $amount, string $currency): PaymentResult 67 + { 68 + // Stripe implementation 69 + } 70 + 71 + public function refund(string $transactionId, int $amount): RefundResult 72 + { 73 + // Stripe implementation 74 + } 75 + } 76 + ``` 77 + 78 + ### Bind Interface to Implementation 79 + 80 + ```yaml 81 + # config/services.yaml 82 + services: 83 + _defaults: 84 + autowire: true 85 + autoconfigure: true 86 + 87 + App\: 88 + resource: '../src/' 89 + exclude: 90 + - '../src/DependencyInjection/' 91 + - '../src/Entity/' 92 + - '../src/Kernel.php' 93 + 94 + # Bind interface to implementation 95 + App\Service\PaymentGatewayInterface: '@App\Service\StripePaymentGateway' 96 + 97 + # Or with parameters 98 + App\Service\StripePaymentGateway: 99 + arguments: 100 + $apiKey: '%env(STRIPE_API_KEY)%' 101 + ``` 102 + 103 + ### Use in Services 104 + 105 + ```php 106 + class OrderService 107 + { 108 + public function __construct( 109 + private PaymentGatewayInterface $paymentGateway, // Autowired! 110 + ) {} 111 + } 112 + ``` 113 + 114 + ## Service Decoration 115 + 116 + Wrap a service to add behavior without modifying it: 117 + 118 + ```php 119 + <?php 120 + // src/Service/LoggingPaymentGateway.php 121 + 122 + namespace App\Service; 123 + 124 + use Psr\Log\LoggerInterface; 125 + use Symfony\Component\DependencyInjection\Attribute\AsDecorator; 126 + use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; 127 + 128 + #[AsDecorator(decorates: StripePaymentGateway::class)] 129 + class LoggingPaymentGateway implements PaymentGatewayInterface 130 + { 131 + public function __construct( 132 + #[AutowireDecorated] 133 + private PaymentGatewayInterface $inner, 134 + private LoggerInterface $logger, 135 + ) {} 136 + 137 + public function charge(int $amount, string $currency): PaymentResult 138 + { 139 + $this->logger->info('Charging payment', [ 140 + 'amount' => $amount, 141 + 'currency' => $currency, 142 + ]); 143 + 144 + $result = $this->inner->charge($amount, $currency); 145 + 146 + $this->logger->info('Payment result', [ 147 + 'success' => $result->isSuccessful(), 148 + 'transactionId' => $result->getTransactionId(), 149 + ]); 150 + 151 + return $result; 152 + } 153 + 154 + public function refund(string $transactionId, int $amount): RefundResult 155 + { 156 + $this->logger->info('Processing refund', [ 157 + 'transactionId' => $transactionId, 158 + 'amount' => $amount, 159 + ]); 160 + 161 + return $this->inner->refund($transactionId, $amount); 162 + } 163 + } 164 + ``` 165 + 166 + ## Tagged Services 167 + 168 + ### Define Tag 169 + 170 + ```php 171 + <?php 172 + // src/Export/ExporterInterface.php 173 + 174 + namespace App\Export; 175 + 176 + use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; 177 + 178 + #[AutoconfigureTag('app.exporter')] 179 + interface ExporterInterface 180 + { 181 + public function supports(string $format): bool; 182 + public function export(array $data): string; 183 + } 184 + ``` 185 + 186 + ### Implementations 187 + 188 + ```php 189 + <?php 190 + // src/Export/CsvExporter.php 191 + 192 + namespace App\Export; 193 + 194 + class CsvExporter implements ExporterInterface 195 + { 196 + public function supports(string $format): bool 197 + { 198 + return $format === 'csv'; 199 + } 200 + 201 + public function export(array $data): string 202 + { 203 + // CSV export logic 204 + } 205 + } 206 + 207 + // src/Export/JsonExporter.php 208 + 209 + class JsonExporter implements ExporterInterface 210 + { 211 + public function supports(string $format): bool 212 + { 213 + return $format === 'json'; 214 + } 215 + 216 + public function export(array $data): string 217 + { 218 + return json_encode($data, JSON_PRETTY_PRINT); 219 + } 220 + } 221 + ``` 222 + 223 + ### Inject All Tagged Services 224 + 225 + ```php 226 + <?php 227 + // src/Service/ExportService.php 228 + 229 + namespace App\Service; 230 + 231 + use App\Export\ExporterInterface; 232 + use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; 233 + 234 + class ExportService 235 + { 236 + /** 237 + * @param iterable<ExporterInterface> $exporters 238 + */ 239 + public function __construct( 240 + #[AutowireIterator('app.exporter')] 241 + private iterable $exporters, 242 + ) {} 243 + 244 + public function export(array $data, string $format): string 245 + { 246 + foreach ($this->exporters as $exporter) { 247 + if ($exporter->supports($format)) { 248 + return $exporter->export($data); 249 + } 250 + } 251 + 252 + throw new \InvalidArgumentException("Unsupported format: {$format}"); 253 + } 254 + 255 + public function getSupportedFormats(): array 256 + { 257 + $formats = []; 258 + foreach ($this->exporters as $exporter) { 259 + // Each exporter reports what it supports 260 + } 261 + return $formats; 262 + } 263 + } 264 + ``` 265 + 266 + ## Named Autowiring 267 + 268 + When you have multiple implementations: 269 + 270 + ```yaml 271 + # config/services.yaml 272 + services: 273 + App\Service\StripePaymentGateway: 274 + arguments: 275 + $apiKey: '%env(STRIPE_API_KEY)%' 276 + 277 + App\Service\PaypalPaymentGateway: 278 + arguments: 279 + $clientId: '%env(PAYPAL_CLIENT_ID)%' 280 + 281 + # Named bindings 282 + App\Service\PaymentGatewayInterface $stripeGateway: '@App\Service\StripePaymentGateway' 283 + App\Service\PaymentGatewayInterface $paypalGateway: '@App\Service\PaypalPaymentGateway' 284 + ``` 285 + 286 + ```php 287 + class PaymentService 288 + { 289 + public function __construct( 290 + private PaymentGatewayInterface $stripeGateway, // Stripe 291 + private PaymentGatewayInterface $paypalGateway, // PayPal 292 + ) {} 293 + } 294 + ``` 295 + 296 + > **Deprecation (8.1+ — verify):** matching a named alias by the *parameter name* 297 + > alone (as above) is deprecated. Prefer `#[Target]` so the binding is explicit 298 + > and rename-safe. 299 + 300 + ## Named Autowiring with #[Target] 301 + 302 + `#[Target]` selects a specific implementation by its alias name, independent of 303 + the parameter name: 304 + 305 + ```php 306 + use Symfony\Component\DependencyInjection\Attribute\Target; 307 + 308 + class MastodonClient 309 + { 310 + public function __construct( 311 + #[Target('shoutyTransformer')] 312 + private TransformerInterface $transformer, 313 + ) {} 314 + } 315 + ``` 316 + 317 + ```yaml 318 + services: 319 + App\Util\TransformerInterface $shoutyTransformer: '@App\Util\UppercaseTransformer' 320 + App\Util\TransformerInterface: '@App\Util\Rot13Transformer' # default 321 + ``` 322 + 323 + Inspect candidates: `php bin/console debug:autowiring TransformerInterface`. 324 + 325 + ## #[Autowire] — scalars, params, env, expressions 326 + 327 + ```php 328 + use Symfony\Component\DependencyInjection\Attribute\Autowire; 329 + 330 + public function __construct( 331 + #[Autowire(service: 'monolog.logger.request')] private LoggerInterface $logger, 332 + #[Autowire('%kernel.project_dir%/data')] private string $dataDir, 333 + #[Autowire(param: 'kernel.debug')] private bool $debugMode, 334 + #[Autowire(env: 'SOME_ENV_VAR')] private string $senderName, 335 + #[Autowire(env: 'bool:ALLOW_ATTACHMENTS')] private bool $allowAttachments, 336 + #[Autowire(expression: 'service("App\\\Mail\\\MailerConfiguration").getMailerMethod()')] private string $mailerMethod, 337 + ) {} 338 + ``` 339 + 340 + ### Refreshable env vars in long-running workers (8.1+ — verify) 341 + 342 + For workers (Messenger, daemons), inject an env var as a `\Closure` so it can 343 + refresh between messages instead of being frozen at boot: 344 + 345 + ```php 346 + public function __construct( 347 + #[Autowire(env: 'DB_URL')] private \Closure $dbUrl, // ($this->dbUrl)() 348 + ) {} 349 + ``` 350 + 351 + ### Service closures 352 + 353 + ```php 354 + use Symfony\Component\DependencyInjection\Attribute\AutowireServiceClosure; 355 + 356 + #[AutowireServiceClosure('mailer.default')] 357 + private \Closure $mailer; // ($this->mailer)()->send(...) — lazily resolved 358 + ``` 359 + 360 + ### Inline anonymous services (8.1+ — verify) 361 + 362 + ```php 363 + use Symfony\Component\DependencyInjection\Attribute\AutowireInline; 364 + 365 + public function __construct( 366 + #[AutowireInline( 367 + class: [ScopingHttpClient::class, 'forBaseUri'], 368 + arguments: ['$baseUri' => 'https://api.example.com'], 369 + )] 370 + private HttpClientInterface $client, 371 + ) {} 372 + ``` 373 + 374 + ## Lazy Services 375 + 376 + Load service only when actually used: 377 + 378 + ```php 379 + use Symfony\Component\DependencyInjection\Attribute\Lazy; 380 + 381 + #[Lazy] 382 + class ExpensiveService 383 + { 384 + public function __construct() 385 + { 386 + // Heavy initialization 387 + } 388 + } 389 + ``` 390 + 391 + ## Debug Commands 392 + 393 + ```bash 394 + # List all services 395 + bin/console debug:container 396 + 397 + # Find specific service 398 + bin/console debug:container OrderService 399 + 400 + # Show autowiring candidates 401 + bin/console debug:autowiring 402 + 403 + # Show autowiring for specific type 404 + bin/console debug:autowiring Payment 405 + ``` 406 + 407 + ## Best Practices 408 + 409 + 1. **Program to interfaces**: Depend on interfaces, not implementations 410 + 2. **Constructor injection**: Always use constructor injection 411 + 3. **Final classes**: Make services `final` by default 412 + 4. **Readonly properties**: Use `private readonly` for dependencies 413 + 5. **Minimal interfaces**: Keep interfaces focused (ISP) 414 + 6. **Decorate, don't modify**: Use decoration for cross-cutting concerns 415 + 416 + 417 + ## Skill Operating Checklist 418 + 419 + ### Design checklist 420 + - Confirm operation boundaries and invariants first. 421 + - Minimize scope while preserving contract correctness. 422 + - Test both happy path and negative path behavior. 423 + 424 + ### Validation commands 425 + - rg --files 426 + - composer validate 427 + - ./vendor/bin/phpstan analyse 428 + 429 + ### Failure modes to test 430 + - Invalid payload or forbidden actor. 431 + - Boundary values / not-found cases. 432 + - Retry or partial-failure behavior for async flows. 433 +
+42
.agents/skills/symfony-messenger-retry-failures/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-messenger-retry-failures 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Handle message failures with retry strategies, failure transport, and recovery in Symfony Messenger (Recoverable/Unrecoverable exceptions) 12 + --- 13 + 14 + # Messenger Retry Failures (Symfony) 15 + 16 + ## Use when 17 + - Implementing asynchronous workflows with Messenger/Scheduler/Cache. 18 + - Stabilizing retries and failure transports. 19 + 20 + ## Default workflow 21 + 1. Define async contract and delivery semantics. 22 + 2. Implement idempotent handlers and routing strategy. 23 + 3. Configure retries, failure transport, and observability. 24 + 4. Validate success/failure replay scenarios. 25 + 26 + ## Guardrails 27 + - Assume at-least-once delivery, not exactly-once. 28 + - Keep handlers deterministic and side-effect aware. 29 + - Surface poison-message handling strategy. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Async config/handlers updated. 37 + - Retry/failure policy decisions. 38 + - Operational validation evidence. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+357
.agents/skills/symfony-messenger-retry-failures/reference.md
··· 1 + # Reference 2 + 3 + # Messenger Retry and Failure Handling 4 + 5 + ## Retry Configuration 6 + 7 + ```yaml 8 + # config/packages/messenger.yaml 9 + framework: 10 + messenger: 11 + transports: 12 + async: 13 + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' 14 + retry_strategy: 15 + max_retries: 3 16 + delay: 1000 # Initial delay: 1 second 17 + multiplier: 2 # Exponential backoff 18 + max_delay: 60000 # Max delay: 1 minute 19 + service: null # Or custom retry strategy service 20 + 21 + # High-priority with aggressive retries 22 + high_priority: 23 + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' 24 + options: 25 + queue_name: high_priority 26 + retry_strategy: 27 + max_retries: 5 28 + delay: 500 29 + multiplier: 1.5 30 + 31 + # Failed message storage 32 + failed: 33 + dsn: 'doctrine://default?queue_name=failed' 34 + 35 + failure_transport: failed 36 + ``` 37 + 38 + ## Retry Behavior 39 + 40 + With the above config, retries happen at: 41 + 1. **Attempt 1**: Immediate 42 + 2. **Retry 1**: +1 second (1000ms) 43 + 3. **Retry 2**: +2 seconds (2000ms) 44 + 4. **Retry 3**: +4 seconds (4000ms) 45 + 5. **Failed**: Moved to `failed` transport 46 + 47 + ## Exception Types 48 + 49 + ### Unrecoverable - Don't Retry 50 + 51 + ```php 52 + <?php 53 + 54 + use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; 55 + 56 + #[AsMessageHandler] 57 + class ProcessPaymentHandler 58 + { 59 + public function __invoke(ProcessPayment $message): void 60 + { 61 + try { 62 + $this->gateway->charge($message->amount); 63 + } catch (InvalidCardException $e) { 64 + // Card is invalid - retrying won't help 65 + throw new UnrecoverableMessageHandlingException( 66 + 'Payment failed: invalid card', 67 + previous: $e 68 + ); 69 + } catch (InsufficientFundsException $e) { 70 + // Permanent failure 71 + throw new UnrecoverableMessageHandlingException( 72 + 'Payment failed: insufficient funds', 73 + previous: $e 74 + ); 75 + } 76 + } 77 + } 78 + ``` 79 + 80 + ### Recoverable - Do Retry 81 + 82 + ```php 83 + <?php 84 + 85 + use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException; 86 + 87 + #[AsMessageHandler] 88 + class ProcessPaymentHandler 89 + { 90 + public function __invoke(ProcessPayment $message): void 91 + { 92 + try { 93 + $this->gateway->charge($message->amount); 94 + } catch (GatewayTimeoutException $e) { 95 + // Gateway temporarily unavailable - retry 96 + throw new RecoverableMessageHandlingException( 97 + 'Payment gateway timeout', 98 + previous: $e 99 + ); 100 + } catch (RateLimitException $e) { 101 + // Rate limited - retry after delay, ignoring max_retries. 102 + // `retryDelay` (ms) and `forceRetry` (8.1+ — verify) let you retry 103 + // past the configured limit. forceRetry: false respects max_retries. 104 + throw new RecoverableMessageHandlingException( 105 + 'Rate limited, will retry', 106 + previous: $e, 107 + retryDelay: 5000, 108 + forceRetry: true, 109 + ); 110 + } 111 + } 112 + } 113 + ``` 114 + 115 + > **Decode failures (8.1+ — verify):** messages that fail to decode are now 116 + > routed through the retry/failure pipeline instead of being silently deleted, 117 + > so a malformed payload lands in the `failed` transport for inspection. 118 + 119 + ## Custom Retry Strategy 120 + 121 + ```php 122 + <?php 123 + // src/Messenger/CustomRetryStrategy.php 124 + 125 + namespace App\Messenger; 126 + 127 + use Symfony\Component\Messenger\Envelope; 128 + use Symfony\Component\Messenger\Retry\RetryStrategyInterface; 129 + use Symfony\Component\Messenger\Stamp\RedeliveryStamp; 130 + 131 + class CustomRetryStrategy implements RetryStrategyInterface 132 + { 133 + public function isRetryable(Envelope $message, ?\Throwable $throwable = null): bool 134 + { 135 + // Don't retry if max retries exceeded 136 + $retryCount = RedeliveryStamp::getRetryCountFromEnvelope($message); 137 + if ($retryCount >= 5) { 138 + return false; 139 + } 140 + 141 + // Don't retry certain exceptions 142 + if ($throwable instanceof \InvalidArgumentException) { 143 + return false; 144 + } 145 + 146 + // Don't retry if message is too old 147 + $sentStamp = $message->last(SentStamp::class); 148 + if ($sentStamp && $sentStamp->getSentAt() < new \DateTimeImmutable('-1 hour')) { 149 + return false; 150 + } 151 + 152 + return true; 153 + } 154 + 155 + public function getWaitingTime(Envelope $message, ?\Throwable $throwable = null): int 156 + { 157 + $retryCount = RedeliveryStamp::getRetryCountFromEnvelope($message); 158 + 159 + // Custom delays based on retry count 160 + return match ($retryCount) { 161 + 0 => 1000, // 1 second 162 + 1 => 5000, // 5 seconds 163 + 2 => 30000, // 30 seconds 164 + 3 => 120000, // 2 minutes 165 + default => 300000, // 5 minutes 166 + }; 167 + } 168 + } 169 + ``` 170 + 171 + Register: 172 + 173 + ```yaml 174 + services: 175 + App\Messenger\CustomRetryStrategy: ~ 176 + 177 + framework: 178 + messenger: 179 + transports: 180 + async: 181 + retry_strategy: 182 + service: App\Messenger\CustomRetryStrategy 183 + ``` 184 + 185 + ## Managing Failed Messages 186 + 187 + ### CLI Commands 188 + 189 + ```bash 190 + # View failed messages (filterable / with stats) 191 + bin/console messenger:failed:show [--max=50] [--class-filter='App\Message\Foo'] [--stats] 192 + 193 + # View a specific failed message with full stack trace 194 + bin/console messenger:failed:show 123 -vv 195 + 196 + # Retry messages (interactive unless --force); target a transport 197 + bin/console messenger:failed:retry [--force] [--transport=failed] 198 + bin/console messenger:failed:retry 123 --force 199 + 200 + # Remove failed messages 201 + bin/console messenger:failed:remove 123 202 + bin/console messenger:failed:remove --all [--class-filter='App\Message\Foo'] 203 + ``` 204 + 205 + ### Programmatic Retry 206 + 207 + ```php 208 + <?php 209 + 210 + use Symfony\Component\Messenger\Transport\Receiver\ReceiverInterface; 211 + 212 + class FailedMessageService 213 + { 214 + public function __construct( 215 + private ReceiverInterface $failedTransport, 216 + private MessageBusInterface $bus, 217 + ) {} 218 + 219 + public function retryMessage(int $id): void 220 + { 221 + $envelope = $this->failedTransport->find($id); 222 + 223 + if (!$envelope) { 224 + throw new \RuntimeException("Message {$id} not found"); 225 + } 226 + 227 + // Re-dispatch to original transport 228 + $this->bus->dispatch($envelope->getMessage()); 229 + 230 + // Remove from failed queue 231 + $this->failedTransport->reject($envelope); 232 + } 233 + 234 + public function getFailedMessages(): iterable 235 + { 236 + return $this->failedTransport->all(); 237 + } 238 + } 239 + ``` 240 + 241 + ## Failure Notifications 242 + 243 + ```php 244 + <?php 245 + // src/EventSubscriber/MessengerFailureSubscriber.php 246 + 247 + namespace App\EventSubscriber; 248 + 249 + use Symfony\Component\EventDispatcher\EventSubscriberInterface; 250 + use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; 251 + use Symfony\Component\Notifier\NotifierInterface; 252 + use Symfony\Component\Notifier\Notification\Notification; 253 + 254 + class MessengerFailureSubscriber implements EventSubscriberInterface 255 + { 256 + public function __construct( 257 + private NotifierInterface $notifier, 258 + private LoggerInterface $logger, 259 + ) {} 260 + 261 + public static function getSubscribedEvents(): array 262 + { 263 + return [ 264 + WorkerMessageFailedEvent::class => 'onMessageFailed', 265 + ]; 266 + } 267 + 268 + public function onMessageFailed(WorkerMessageFailedEvent $event): void 269 + { 270 + // Only notify on final failure (not retries) 271 + if ($event->willRetry()) { 272 + return; 273 + } 274 + 275 + $envelope = $event->getEnvelope(); 276 + $message = $envelope->getMessage(); 277 + $throwable = $event->getThrowable(); 278 + 279 + $this->logger->error('Message failed permanently', [ 280 + 'message_class' => get_class($message), 281 + 'error' => $throwable->getMessage(), 282 + ]); 283 + 284 + // Send notification 285 + $notification = (new Notification('Message Failed', ['email'])) 286 + ->content(sprintf( 287 + "Message %s failed: %s", 288 + get_class($message), 289 + $throwable->getMessage() 290 + )); 291 + 292 + $this->notifier->send($notification); 293 + } 294 + } 295 + ``` 296 + 297 + ## Idempotent Handlers 298 + 299 + Design handlers to be safely retried: 300 + 301 + ```php 302 + <?php 303 + 304 + #[AsMessageHandler] 305 + class ProcessOrderHandler 306 + { 307 + public function __invoke(ProcessOrder $message): void 308 + { 309 + $order = $this->orders->find($message->orderId); 310 + 311 + // Idempotency check - already processed? 312 + if ($order->getStatus() === OrderStatus::PROCESSED) { 313 + $this->logger->info('Order already processed, skipping'); 314 + return; // Success - don't throw 315 + } 316 + 317 + // Idempotency key for external calls 318 + $idempotencyKey = sprintf('order_%d_%s', $order->getId(), $order->getUpdatedAt()->format('U')); 319 + 320 + $this->paymentGateway->charge( 321 + amount: $order->getTotal(), 322 + idempotencyKey: $idempotencyKey 323 + ); 324 + 325 + $order->setStatus(OrderStatus::PROCESSED); 326 + $this->em->flush(); 327 + } 328 + } 329 + ``` 330 + 331 + ## Best Practices 332 + 333 + 1. **Use exception types**: `Unrecoverable` vs `Recoverable` 334 + 2. **Idempotent handlers**: Safe to retry multiple times 335 + 3. **Monitor failed queue**: Set up alerts 336 + 4. **Reasonable max retries**: 3-5 usually sufficient 337 + 5. **Exponential backoff**: Don't hammer failing services 338 + 6. **Log failures**: With context for debugging 339 + 340 + 341 + ## Skill Operating Checklist 342 + 343 + ### Design checklist 344 + - Confirm operation boundaries and invariants first. 345 + - Minimize scope while preserving contract correctness. 346 + - Test both happy path and negative path behavior. 347 + 348 + ### Validation commands 349 + - php bin/console messenger:consume --limit=1 350 + - php bin/console messenger:failed:show 351 + - ./vendor/bin/phpunit --filter=Messenger 352 + 353 + ### Failure modes to test 354 + - Invalid payload or forbidden actor. 355 + - Boundary values / not-found cases. 356 + - Retry or partial-failure behavior for async flows. 357 +
+39
.agents/skills/symfony-ports-and-adapters/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-ports-and-adapters 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Implement Hexagonal Architecture (Ports and Adapters) in Symfony; separate domain logic from infrastructure with clear boundaries 9 + --- 10 + 11 + # Ports And Adapters (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+422
.agents/skills/symfony-ports-and-adapters/reference.md
··· 1 + # Reference 2 + 3 + # Ports and Adapters (Hexagonal Architecture) 4 + 5 + ## Concept 6 + 7 + Hexagonal Architecture separates your application into three layers: 8 + - **Domain**: Pure business logic, no framework dependencies 9 + - **Application**: Use cases, orchestration 10 + - **Infrastructure**: Symfony, Doctrine, external APIs 11 + 12 + ``` 13 + ┌─────────────────────────────────────────────┐ 14 + │ Infrastructure │ 15 + │ ┌───────────────────────────────────────┐ │ 16 + │ │ Application │ │ 17 + │ │ ┌─────────────────────────────────┐ │ │ 18 + │ │ │ Domain │ │ │ 19 + │ │ │ (Entities, Value Objects, │ │ │ 20 + │ │ │ Domain Services, Events) │ │ │ 21 + │ │ └─────────────────────────────────┘ │ │ 22 + │ │ (Use Cases, Commands, Queries) │ │ 23 + │ └───────────────────────────────────────┘ │ 24 + │ (Controllers, Repositories, APIs) │ 25 + └─────────────────────────────────────────────┘ 26 + ``` 27 + 28 + ## Directory Structure 29 + 30 + ``` 31 + src/ 32 + ├── Domain/ 33 + │ ├── Order/ 34 + │ │ ├── Entity/ 35 + │ │ │ ├── Order.php 36 + │ │ │ └── OrderItem.php 37 + │ │ ├── ValueObject/ 38 + │ │ │ ├── OrderId.php 39 + │ │ │ └── Money.php 40 + │ │ ├── Repository/ 41 + │ │ │ └── OrderRepositoryInterface.php 42 + │ │ ├── Service/ 43 + │ │ │ └── OrderPricingService.php 44 + │ │ └── Event/ 45 + │ │ └── OrderCreated.php 46 + │ └── User/ 47 + │ └── ... 48 + ├── Application/ 49 + │ ├── Order/ 50 + │ │ ├── Command/ 51 + │ │ │ ├── CreateOrder.php 52 + │ │ │ └── CreateOrderHandler.php 53 + │ │ └── Query/ 54 + │ │ ├── GetOrder.php 55 + │ │ └── GetOrderHandler.php 56 + │ └── ... 57 + └── Infrastructure/ 58 + ├── Doctrine/ 59 + │ └── Repository/ 60 + │ └── DoctrineOrderRepository.php 61 + ├── Controller/ 62 + │ └── Api/ 63 + │ └── OrderController.php 64 + └── External/ 65 + └── PaymentGateway/ 66 + └── StripeAdapter.php 67 + ``` 68 + 69 + ## Domain Layer 70 + 71 + ### Entity (No ORM Annotations) 72 + 73 + ```php 74 + <?php 75 + // src/Domain/Order/Entity/Order.php 76 + 77 + namespace App\Domain\Order\Entity; 78 + 79 + use App\Domain\Order\Event\OrderCreated; 80 + use App\Domain\Order\ValueObject\OrderId; 81 + use App\Domain\Order\ValueObject\Money; 82 + 83 + final class Order 84 + { 85 + private array $domainEvents = []; 86 + 87 + /** @var OrderItem[] */ 88 + private array $items = []; 89 + 90 + private function __construct( 91 + private OrderId $id, 92 + private int $customerId, 93 + private OrderStatus $status, 94 + private \DateTimeImmutable $createdAt, 95 + ) {} 96 + 97 + public static function create(OrderId $id, int $customerId): self 98 + { 99 + $order = new self( 100 + $id, 101 + $customerId, 102 + OrderStatus::PENDING, 103 + new \DateTimeImmutable(), 104 + ); 105 + 106 + $order->recordEvent(new OrderCreated($id, $customerId)); 107 + 108 + return $order; 109 + } 110 + 111 + public function addItem(int $productId, int $quantity, Money $price): void 112 + { 113 + $this->items[] = new OrderItem($productId, $quantity, $price); 114 + } 115 + 116 + public function getTotal(): Money 117 + { 118 + return array_reduce( 119 + $this->items, 120 + fn(Money $carry, OrderItem $item) => $carry->add($item->getSubtotal()), 121 + Money::zero('EUR') 122 + ); 123 + } 124 + 125 + public function confirm(): void 126 + { 127 + if ($this->status !== OrderStatus::PENDING) { 128 + throw new \DomainException('Only pending orders can be confirmed'); 129 + } 130 + 131 + $this->status = OrderStatus::CONFIRMED; 132 + } 133 + 134 + private function recordEvent(object $event): void 135 + { 136 + $this->domainEvents[] = $event; 137 + } 138 + 139 + public function pullDomainEvents(): array 140 + { 141 + $events = $this->domainEvents; 142 + $this->domainEvents = []; 143 + return $events; 144 + } 145 + 146 + // Getters... 147 + } 148 + ``` 149 + 150 + ### Value Object 151 + 152 + ```php 153 + <?php 154 + // src/Domain/Order/ValueObject/Money.php 155 + 156 + namespace App\Domain\Order\ValueObject; 157 + 158 + final readonly class Money 159 + { 160 + private function __construct( 161 + private int $amount, // In cents 162 + private string $currency, 163 + ) {} 164 + 165 + public static function of(int $amount, string $currency): self 166 + { 167 + if ($amount < 0) { 168 + throw new \InvalidArgumentException('Amount cannot be negative'); 169 + } 170 + 171 + return new self($amount, $currency); 172 + } 173 + 174 + public static function zero(string $currency): self 175 + { 176 + return new self(0, $currency); 177 + } 178 + 179 + public function add(self $other): self 180 + { 181 + if ($this->currency !== $other->currency) { 182 + throw new \InvalidArgumentException('Cannot add different currencies'); 183 + } 184 + 185 + return new self($this->amount + $other->amount, $this->currency); 186 + } 187 + 188 + public function getAmount(): int 189 + { 190 + return $this->amount; 191 + } 192 + 193 + public function getCurrency(): string 194 + { 195 + return $this->currency; 196 + } 197 + 198 + public function equals(self $other): bool 199 + { 200 + return $this->amount === $other->amount 201 + && $this->currency === $other->currency; 202 + } 203 + } 204 + ``` 205 + 206 + ### Port (Repository Interface) 207 + 208 + ```php 209 + <?php 210 + // src/Domain/Order/Repository/OrderRepositoryInterface.php 211 + 212 + namespace App\Domain\Order\Repository; 213 + 214 + use App\Domain\Order\Entity\Order; 215 + use App\Domain\Order\ValueObject\OrderId; 216 + 217 + interface OrderRepositoryInterface 218 + { 219 + public function nextId(): OrderId; 220 + public function save(Order $order): void; 221 + public function findById(OrderId $id): ?Order; 222 + public function findByCustomer(int $customerId): array; 223 + } 224 + ``` 225 + 226 + ## Application Layer 227 + 228 + ### Command 229 + 230 + ```php 231 + <?php 232 + // src/Application/Order/Command/CreateOrder.php 233 + 234 + namespace App\Application\Order\Command; 235 + 236 + final readonly class CreateOrder 237 + { 238 + public function __construct( 239 + public int $customerId, 240 + public array $items, // [{productId, quantity, price}] 241 + ) {} 242 + } 243 + ``` 244 + 245 + ### Command Handler 246 + 247 + ```php 248 + <?php 249 + // src/Application/Order/Command/CreateOrderHandler.php 250 + 251 + namespace App\Application\Order\Command; 252 + 253 + use App\Domain\Order\Entity\Order; 254 + use App\Domain\Order\Repository\OrderRepositoryInterface; 255 + use App\Domain\Order\ValueObject\Money; 256 + use Symfony\Component\Messenger\Attribute\AsMessageHandler; 257 + 258 + #[AsMessageHandler] 259 + final readonly class CreateOrderHandler 260 + { 261 + public function __construct( 262 + private OrderRepositoryInterface $orders, 263 + ) {} 264 + 265 + public function __invoke(CreateOrder $command): Order 266 + { 267 + $order = Order::create( 268 + $this->orders->nextId(), 269 + $command->customerId, 270 + ); 271 + 272 + foreach ($command->items as $item) { 273 + $order->addItem( 274 + $item['productId'], 275 + $item['quantity'], 276 + Money::of($item['price'], 'EUR'), 277 + ); 278 + } 279 + 280 + $this->orders->save($order); 281 + 282 + return $order; 283 + } 284 + } 285 + ``` 286 + 287 + ## Infrastructure Layer 288 + 289 + ### Adapter (Doctrine Repository) 290 + 291 + ```php 292 + <?php 293 + // src/Infrastructure/Doctrine/Repository/DoctrineOrderRepository.php 294 + 295 + namespace App\Infrastructure\Doctrine\Repository; 296 + 297 + use App\Domain\Order\Entity\Order; 298 + use App\Domain\Order\Repository\OrderRepositoryInterface; 299 + use App\Domain\Order\ValueObject\OrderId; 300 + use Doctrine\ORM\EntityManagerInterface; 301 + use Symfony\Component\Uid\Uuid; 302 + 303 + final class DoctrineOrderRepository implements OrderRepositoryInterface 304 + { 305 + public function __construct( 306 + private EntityManagerInterface $em, 307 + ) {} 308 + 309 + public function nextId(): OrderId 310 + { 311 + return OrderId::fromString(Uuid::v4()->toRfc4122()); 312 + } 313 + 314 + public function save(Order $order): void 315 + { 316 + $this->em->persist($order); 317 + $this->em->flush(); 318 + } 319 + 320 + public function findById(OrderId $id): ?Order 321 + { 322 + return $this->em->find(Order::class, $id->toString()); 323 + } 324 + 325 + public function findByCustomer(int $customerId): array 326 + { 327 + return $this->em->getRepository(Order::class) 328 + ->findBy(['customerId' => $customerId]); 329 + } 330 + } 331 + ``` 332 + 333 + ### Doctrine Mapping (XML) 334 + 335 + ```xml 336 + <!-- config/doctrine/Order.orm.xml --> 337 + <doctrine-mapping> 338 + <entity name="App\Domain\Order\Entity\Order" table="orders"> 339 + <id name="id" type="string" length="36"> 340 + <generator strategy="NONE"/> 341 + </id> 342 + <field name="customerId" column="customer_id" type="integer"/> 343 + <field name="status" type="string" enumType="App\Domain\Order\Entity\OrderStatus"/> 344 + <field name="createdAt" column="created_at" type="datetime_immutable"/> 345 + </entity> 346 + </doctrine-mapping> 347 + ``` 348 + 349 + ### Service Configuration 350 + 351 + ```yaml 352 + # config/services.yaml 353 + services: 354 + # Bind interface to implementation 355 + App\Domain\Order\Repository\OrderRepositoryInterface: 356 + '@App\Infrastructure\Doctrine\Repository\DoctrineOrderRepository' 357 + ``` 358 + 359 + ## Controller (Infrastructure) 360 + 361 + ```php 362 + <?php 363 + // src/Infrastructure/Controller/Api/OrderController.php 364 + 365 + namespace App\Infrastructure\Controller\Api; 366 + 367 + use App\Application\Order\Command\CreateOrder; 368 + use Symfony\Component\HttpFoundation\JsonResponse; 369 + use Symfony\Component\HttpFoundation\Request; 370 + use Symfony\Component\Messenger\MessageBusInterface; 371 + use Symfony\Component\Messenger\Stamp\HandledStamp; 372 + use Symfony\Component\Routing\Attribute\Route; 373 + 374 + #[Route('/api/orders')] 375 + final class OrderController 376 + { 377 + public function __construct( 378 + private MessageBusInterface $bus, 379 + ) {} 380 + 381 + #[Route('', methods: ['POST'])] 382 + public function create(Request $request): JsonResponse 383 + { 384 + $data = json_decode($request->getContent(), true); 385 + 386 + $envelope = $this->bus->dispatch(new CreateOrder( 387 + customerId: $data['customerId'], 388 + items: $data['items'], 389 + )); 390 + 391 + $order = $envelope->last(HandledStamp::class)->getResult(); 392 + 393 + return new JsonResponse(['id' => $order->getId()->toString()], 201); 394 + } 395 + } 396 + ``` 397 + 398 + ## Benefits 399 + 400 + 1. **Testability**: Domain is pure PHP, easily unit tested 401 + 2. **Flexibility**: Swap infrastructure without touching domain 402 + 3. **Focus**: Domain logic is isolated and explicit 403 + 4. **Framework agnostic**: Domain doesn't know about Symfony 404 + 405 + 406 + ## Skill Operating Checklist 407 + 408 + ### Design checklist 409 + - Confirm operation boundaries and invariants first. 410 + - Minimize scope while preserving contract correctness. 411 + - Test both happy path and negative path behavior. 412 + 413 + ### Validation commands 414 + - rg --files 415 + - composer validate 416 + - ./vendor/bin/phpstan analyse 417 + 418 + ### Failure modes to test 419 + - Invalid payload or forbidden actor. 420 + - Boundary values / not-found cases. 421 + - Retry or partial-failure behavior for async flows. 422 +
+42
.agents/skills/symfony-rate-limiting/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-rate-limiting 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Implement rate limiting with the Symfony RateLimiter (sliding window, token bucket, fixed window) and the #[RateLimit] controller attribute 12 + --- 13 + 14 + # Rate Limiting (Symfony) 15 + 16 + ## Use when 17 + - Implementing asynchronous workflows with Messenger/Scheduler/Cache. 18 + - Stabilizing retries and failure transports. 19 + 20 + ## Default workflow 21 + 1. Define async contract and delivery semantics. 22 + 2. Implement idempotent handlers and routing strategy. 23 + 3. Configure retries, failure transport, and observability. 24 + 4. Validate success/failure replay scenarios. 25 + 26 + ## Guardrails 27 + - Assume at-least-once delivery, not exactly-once. 28 + - Keep handlers deterministic and side-effect aware. 29 + - Surface poison-message handling strategy. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Async config/handlers updated. 37 + - Retry/failure policy decisions. 38 + - Operational validation evidence. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+401
.agents/skills/symfony-rate-limiting/reference.md
··· 1 + # Reference 2 + 3 + # Symfony Rate Limiting 4 + 5 + ## Installation 6 + 7 + ```bash 8 + composer require symfony/rate-limiter 9 + ``` 10 + 11 + ## Configuration 12 + 13 + ```yaml 14 + # config/packages/rate_limiter.yaml 15 + framework: 16 + rate_limiter: 17 + # Anonymous API requests 18 + anonymous_api: 19 + policy: sliding_window 20 + limit: 100 21 + interval: '1 hour' 22 + 23 + # Authenticated API requests 24 + authenticated_api: 25 + policy: sliding_window 26 + limit: 1000 27 + interval: '1 hour' 28 + 29 + # Login attempts 30 + login: 31 + policy: fixed_window 32 + limit: 5 33 + interval: '15 minutes' 34 + 35 + # Contact form 36 + contact_form: 37 + policy: fixed_window 38 + limit: 3 39 + interval: '1 hour' 40 + 41 + # Expensive operations 42 + export: 43 + policy: token_bucket 44 + limit: 10 45 + rate: { interval: '1 hour', amount: 5 } 46 + ``` 47 + 48 + ## Rate Limiting Algorithms 49 + 50 + ### Fixed Window 51 + 52 + Simple count within time window: 53 + 54 + ```yaml 55 + login: 56 + policy: fixed_window 57 + limit: 5 58 + interval: '15 minutes' 59 + ``` 60 + 61 + ### Sliding Window 62 + 63 + Smoother rate limiting, prevents burst at window edges: 64 + 65 + ```yaml 66 + api: 67 + policy: sliding_window 68 + limit: 100 69 + interval: '1 hour' 70 + ``` 71 + 72 + ### Token Bucket 73 + 74 + Allows bursts while maintaining average rate: 75 + 76 + ```yaml 77 + export: 78 + policy: token_bucket 79 + limit: 10 # Bucket size (max burst) 80 + rate: 81 + interval: '1 hour' # Refill interval 82 + amount: 5 # Tokens added per interval 83 + ``` 84 + 85 + ## Policies 86 + 87 + - **fixed_window** — simplest; allows bursts at window edges. 88 + - **sliding_window** — `75% * previous_window_hits + current_hits`. The 89 + `anchor_at` option (8.1+ — verify) aligns windows to a calendar instant. 90 + - **token_bucket** — tokens refilled at a rate; tolerates bursts up to bucket size. 91 + - **compound** — combines several limiters; *all* must accept. 92 + 93 + ```yaml 94 + framework: 95 + rate_limiter: 96 + contact_form: 97 + policy: compound 98 + limiters: [two_per_minute, five_per_hour] 99 + rolling_api: 100 + policy: sliding_window 101 + limit: 100 102 + interval: '1 hour' 103 + anchor_at: '00:00' # 8.1+ — verify 104 + ``` 105 + 106 + ## Using Rate Limiters 107 + 108 + ### As a controller attribute (8.1+ — verify) 109 + 110 + The simplest path: declare the named limiter on the action. Returns 111 + `429 Too Many Requests` with a `Retry-After` header automatically. 112 + 113 + ```php 114 + use Symfony\Component\ExpressionLanguage\Expression; 115 + use Symfony\Component\HttpKernel\Attribute\RateLimit; 116 + 117 + #[RateLimit('api')] // default key = IP + method + path 118 + public function index(): JsonResponse {} 119 + 120 + #[RateLimit('api', methods: ['POST', 'PUT', 'PATCH', 'DELETE'])] 121 + public function edit(): JsonResponse {} 122 + 123 + #[RateLimit('per_account', key: new Expression('request.request.get("email")'))] 124 + public function resetPassword(): Response {} 125 + 126 + #[RateLimit('per_account', key: fn (array $args, Request $request): string => $request->query->get('email'))] 127 + public function resetViaLink(): Response {} 128 + 129 + #[RateLimit('api', tokens: 5)] // consume 5 tokens 130 + public function export(): JsonResponse {} 131 + ``` 132 + 133 + Stack multiple `#[RateLimit]` attributes — all must pass. 134 + 135 + ### In Controllers (injected service) 136 + 137 + Typehint `RateLimiterFactoryInterface` (not the concrete `RateLimiterFactory`) 138 + and select the named limiter with `#[Target]`: 139 + 140 + ```php 141 + <?php 142 + // src/Controller/ApiController.php 143 + 144 + namespace App\Controller; 145 + 146 + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 147 + use Symfony\Component\DependencyInjection\Attribute\Target; 148 + use Symfony\Component\HttpFoundation\JsonResponse; 149 + use Symfony\Component\HttpFoundation\Request; 150 + use Symfony\Component\HttpFoundation\Response; 151 + use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; 152 + use Symfony\Component\Routing\Attribute\Route; 153 + 154 + class ApiController extends AbstractController 155 + { 156 + public function __construct( 157 + #[Target('authenticated_api')] 158 + private RateLimiterFactoryInterface $authenticatedApiLimiter, 159 + #[Target('anonymous_api')] 160 + private RateLimiterFactoryInterface $anonymousApiLimiter, 161 + ) {} 162 + 163 + #[Route('/api/data', methods: ['GET'])] 164 + public function getData(Request $request): Response 165 + { 166 + // Choose limiter based on authentication 167 + $limiter = $this->getUser() 168 + ? $this->authenticatedApiLimiter->create($this->getUser()->getUserIdentifier()) 169 + : $this->anonymousApiLimiter->create($request->getClientIp()); 170 + 171 + $limit = $limiter->consume(); 172 + 173 + if (!$limit->isAccepted()) { 174 + return new JsonResponse( 175 + ['error' => 'Too many requests. Please try again later.'], 176 + Response::HTTP_TOO_MANY_REQUESTS, 177 + [ 178 + 'X-RateLimit-Remaining' => $limit->getRemainingTokens(), 179 + 'X-RateLimit-Retry-After' => $limit->getRetryAfter()->getTimestamp(), 180 + 'Retry-After' => $limit->getRetryAfter()->getTimestamp() - time(), 181 + ] 182 + ); 183 + } 184 + 185 + // Add rate limit headers 186 + $response = new JsonResponse(['data' => '...']); 187 + $response->headers->set('X-RateLimit-Remaining', $limit->getRemainingTokens()); 188 + $response->headers->set('X-RateLimit-Limit', $limit->getLimit()); 189 + 190 + return $response; 191 + } 192 + } 193 + ``` 194 + 195 + ### In Services 196 + 197 + ```php 198 + <?php 199 + // src/Service/ExportService.php 200 + 201 + namespace App\Service; 202 + 203 + use Symfony\Component\RateLimiter\RateLimiterFactory; 204 + 205 + class ExportService 206 + { 207 + public function __construct( 208 + private RateLimiterFactory $exportLimiter, 209 + ) {} 210 + 211 + public function export(User $user): string 212 + { 213 + $limiter = $this->exportLimiter->create($user->getId()); 214 + $limit = $limiter->consume(); 215 + 216 + if (!$limit->isAccepted()) { 217 + throw new TooManyRequestsException( 218 + 'Export limit reached. Please wait.', 219 + $limit->getRetryAfter() 220 + ); 221 + } 222 + 223 + return $this->generateExport($user); 224 + } 225 + } 226 + ``` 227 + 228 + ### Login Rate Limiting 229 + 230 + ```php 231 + <?php 232 + // src/Security/LoginRateLimiter.php 233 + 234 + namespace App\Security; 235 + 236 + use Symfony\Component\HttpFoundation\Request; 237 + use Symfony\Component\RateLimiter\RateLimiterFactory; 238 + use Symfony\Component\Security\Http\RateLimiter\AbstractRequestRateLimiter; 239 + 240 + class LoginRateLimiter extends AbstractRequestRateLimiter 241 + { 242 + public function __construct( 243 + private RateLimiterFactory $loginLimiter, 244 + ) {} 245 + 246 + protected function getLimiters(Request $request): array 247 + { 248 + // Rate limit by IP + username combination 249 + $username = $request->request->get('_username', ''); 250 + $ip = $request->getClientIp(); 251 + 252 + return [ 253 + $this->loginLimiter->create($ip), 254 + $this->loginLimiter->create($username . $ip), 255 + ]; 256 + } 257 + } 258 + ``` 259 + 260 + Configure in security: 261 + 262 + ```yaml 263 + # config/packages/security.yaml 264 + security: 265 + firewalls: 266 + main: 267 + form_login: 268 + login_path: login 269 + check_path: login 270 + login_throttling: 271 + limiter: login 272 + ``` 273 + 274 + ## Event Subscriber for Global Rate Limiting 275 + 276 + ```php 277 + <?php 278 + // src/EventSubscriber/RateLimitSubscriber.php 279 + 280 + namespace App\EventSubscriber; 281 + 282 + use Symfony\Component\EventDispatcher\EventSubscriberInterface; 283 + use Symfony\Component\HttpFoundation\JsonResponse; 284 + use Symfony\Component\HttpFoundation\Response; 285 + use Symfony\Component\HttpKernel\Event\RequestEvent; 286 + use Symfony\Component\HttpKernel\KernelEvents; 287 + use Symfony\Component\RateLimiter\RateLimiterFactory; 288 + 289 + class RateLimitSubscriber implements EventSubscriberInterface 290 + { 291 + public function __construct( 292 + private RateLimiterFactory $apiLimiter, 293 + ) {} 294 + 295 + public static function getSubscribedEvents(): array 296 + { 297 + return [ 298 + KernelEvents::REQUEST => ['onRequest', 10], 299 + ]; 300 + } 301 + 302 + public function onRequest(RequestEvent $event): void 303 + { 304 + $request = $event->getRequest(); 305 + 306 + // Only rate limit API routes 307 + if (!str_starts_with($request->getPathInfo(), '/api/')) { 308 + return; 309 + } 310 + 311 + $limiter = $this->apiLimiter->create($request->getClientIp()); 312 + $limit = $limiter->consume(); 313 + 314 + if (!$limit->isAccepted()) { 315 + $event->setResponse(new JsonResponse( 316 + ['error' => 'Rate limit exceeded'], 317 + Response::HTTP_TOO_MANY_REQUESTS, 318 + ['Retry-After' => $limit->getRetryAfter()->getTimestamp() - time()] 319 + )); 320 + } 321 + } 322 + } 323 + ``` 324 + 325 + ## Reserve Tokens (Blocking) 326 + 327 + Wait for tokens instead of rejecting: 328 + 329 + ```php 330 + $limiter = $this->exportLimiter->create($user->getId()); 331 + 332 + // Will block until token is available (max 30 seconds) 333 + $reservation = $limiter->reserve(1, 30); 334 + 335 + // Wait for the reservation 336 + $reservation->wait(); 337 + 338 + // Proceed with rate-limited operation 339 + $this->generateExport($user); 340 + ``` 341 + 342 + ## Testing 343 + 344 + ```php 345 + <?php 346 + 347 + use Symfony\Component\RateLimiter\RateLimiterFactory; 348 + use Symfony\Component\RateLimiter\Storage\InMemoryStorage; 349 + 350 + class RateLimitTest extends TestCase 351 + { 352 + public function testRateLimitEnforced(): void 353 + { 354 + // Create limiter with in-memory storage for testing 355 + $factory = new RateLimiterFactory([ 356 + 'id' => 'test', 357 + 'policy' => 'fixed_window', 358 + 'limit' => 3, 359 + 'interval' => '1 minute', 360 + ], new InMemoryStorage()); 361 + 362 + $limiter = $factory->create('user_123'); 363 + 364 + // First 3 requests should succeed 365 + for ($i = 0; $i < 3; $i++) { 366 + $this->assertTrue($limiter->consume()->isAccepted()); 367 + } 368 + 369 + // 4th request should fail 370 + $this->assertFalse($limiter->consume()->isAccepted()); 371 + } 372 + } 373 + ``` 374 + 375 + ## Best Practices 376 + 377 + 1. **Different limits by role**: More for authenticated users 378 + 2. **Compound keys**: IP + user for login attempts 379 + 3. **Return headers**: X-RateLimit-Remaining, Retry-After 380 + 4. **Sliding window** for APIs - smoother limiting 381 + 5. **Token bucket** for burst tolerance 382 + 6. **Redis storage** for distributed systems 383 + 384 + 385 + ## Skill Operating Checklist 386 + 387 + ### Design checklist 388 + - Confirm operation boundaries and invariants first. 389 + - Minimize scope while preserving contract correctness. 390 + - Test both happy path and negative path behavior. 391 + 392 + ### Validation commands 393 + - php bin/console messenger:consume --limit=1 394 + - php bin/console messenger:failed:show 395 + - ./vendor/bin/phpunit --filter=Messenger 396 + 397 + ### Failure modes to test 398 + - Invalid payload or forbidden actor. 399 + - Boundary values / not-found cases. 400 + - Retry or partial-failure behavior for async flows. 401 +
+38
.agents/skills/symfony-runner-selection/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-runner-selection 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Select and configure the appropriate command runner based on Docker Compose standard, Symfony Docker (FrankenPHP), or host environment 9 + --- 10 + 11 + # Runner Selection (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `docs/complexity-tiers.md`
+39
.agents/skills/symfony-strategy-pattern/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-strategy-pattern 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Implement the Strategy pattern with Symfony's tagged services for runtime algorithm selection and extensibility 9 + --- 10 + 11 + # Strategy Pattern (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+390
.agents/skills/symfony-strategy-pattern/reference.md
··· 1 + # Reference 2 + 3 + # Strategy Pattern with Tagged Services 4 + 5 + ## The Pattern 6 + 7 + Strategy allows selecting an algorithm at runtime. In Symfony, use tagged services for clean implementation. 8 + 9 + ## Example: Payment Processors 10 + 11 + ### Define Interface 12 + 13 + ```php 14 + <?php 15 + // src/Payment/PaymentProcessorInterface.php 16 + 17 + namespace App\Payment; 18 + 19 + interface PaymentProcessorInterface 20 + { 21 + public function supports(string $method): bool; 22 + public function process(Payment $payment): PaymentResult; 23 + public function refund(Payment $payment, int $amount): RefundResult; 24 + } 25 + ``` 26 + 27 + ### Implementations 28 + 29 + ```php 30 + <?php 31 + // src/Payment/Processor/StripeProcessor.php 32 + 33 + namespace App\Payment\Processor; 34 + 35 + use App\Payment\PaymentProcessorInterface; 36 + use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; 37 + 38 + #[AutoconfigureTag('app.payment_processor')] 39 + class StripeProcessor implements PaymentProcessorInterface 40 + { 41 + public function __construct( 42 + private StripeClient $stripe, 43 + ) {} 44 + 45 + public function supports(string $method): bool 46 + { 47 + return in_array($method, ['card', 'stripe'], true); 48 + } 49 + 50 + public function process(Payment $payment): PaymentResult 51 + { 52 + $charge = $this->stripe->charges->create([ 53 + 'amount' => $payment->getAmount(), 54 + 'currency' => $payment->getCurrency(), 55 + 'source' => $payment->getToken(), 56 + ]); 57 + 58 + return new PaymentResult( 59 + success: $charge->status === 'succeeded', 60 + transactionId: $charge->id, 61 + ); 62 + } 63 + 64 + public function refund(Payment $payment, int $amount): RefundResult 65 + { 66 + // Stripe refund implementation 67 + } 68 + } 69 + 70 + // src/Payment/Processor/PayPalProcessor.php 71 + 72 + #[AutoconfigureTag('app.payment_processor')] 73 + class PayPalProcessor implements PaymentProcessorInterface 74 + { 75 + public function supports(string $method): bool 76 + { 77 + return $method === 'paypal'; 78 + } 79 + 80 + public function process(Payment $payment): PaymentResult 81 + { 82 + // PayPal implementation 83 + } 84 + 85 + public function refund(Payment $payment, int $amount): RefundResult 86 + { 87 + // PayPal refund implementation 88 + } 89 + } 90 + 91 + // src/Payment/Processor/BankTransferProcessor.php 92 + 93 + #[AutoconfigureTag('app.payment_processor')] 94 + class BankTransferProcessor implements PaymentProcessorInterface 95 + { 96 + public function supports(string $method): bool 97 + { 98 + return $method === 'bank_transfer'; 99 + } 100 + 101 + public function process(Payment $payment): PaymentResult 102 + { 103 + // Bank transfer - create pending payment 104 + return new PaymentResult( 105 + success: true, 106 + transactionId: uniqid('bt_'), 107 + pending: true, 108 + ); 109 + } 110 + 111 + public function refund(Payment $payment, int $amount): RefundResult 112 + { 113 + // Bank transfer refund 114 + } 115 + } 116 + ``` 117 + 118 + ### Strategy Manager 119 + 120 + ```php 121 + <?php 122 + // src/Payment/PaymentService.php 123 + 124 + namespace App\Payment; 125 + 126 + use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; 127 + 128 + class PaymentService 129 + { 130 + /** 131 + * @param iterable<PaymentProcessorInterface> $processors 132 + */ 133 + public function __construct( 134 + #[AutowireIterator('app.payment_processor')] 135 + private iterable $processors, 136 + ) {} 137 + 138 + public function process(Payment $payment, string $method): PaymentResult 139 + { 140 + $processor = $this->getProcessor($method); 141 + 142 + return $processor->process($payment); 143 + } 144 + 145 + public function refund(Payment $payment, int $amount): RefundResult 146 + { 147 + $processor = $this->getProcessor($payment->getMethod()); 148 + 149 + return $processor->refund($payment, $amount); 150 + } 151 + 152 + public function getSupportedMethods(): array 153 + { 154 + $methods = []; 155 + 156 + foreach ($this->processors as $processor) { 157 + // Each processor reports what it supports 158 + } 159 + 160 + return $methods; 161 + } 162 + 163 + private function getProcessor(string $method): PaymentProcessorInterface 164 + { 165 + foreach ($this->processors as $processor) { 166 + if ($processor->supports($method)) { 167 + return $processor; 168 + } 169 + } 170 + 171 + throw new UnsupportedPaymentMethodException($method); 172 + } 173 + } 174 + ``` 175 + 176 + ## Example: Export Formats 177 + 178 + ```php 179 + <?php 180 + // src/Export/ExporterInterface.php 181 + 182 + namespace App\Export; 183 + 184 + use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; 185 + 186 + #[AutoconfigureTag('app.exporter')] 187 + interface ExporterInterface 188 + { 189 + public static function getFormat(): string; 190 + public function export(array $data): string; 191 + public function getContentType(): string; 192 + public function getFileExtension(): string; 193 + } 194 + 195 + // src/Export/CsvExporter.php 196 + 197 + class CsvExporter implements ExporterInterface 198 + { 199 + public static function getFormat(): string 200 + { 201 + return 'csv'; 202 + } 203 + 204 + public function export(array $data): string 205 + { 206 + $output = fopen('php://temp', 'r+'); 207 + 208 + if (!empty($data)) { 209 + fputcsv($output, array_keys($data[0])); 210 + foreach ($data as $row) { 211 + fputcsv($output, $row); 212 + } 213 + } 214 + 215 + rewind($output); 216 + return stream_get_contents($output); 217 + } 218 + 219 + public function getContentType(): string 220 + { 221 + return 'text/csv'; 222 + } 223 + 224 + public function getFileExtension(): string 225 + { 226 + return 'csv'; 227 + } 228 + } 229 + 230 + // src/Export/JsonExporter.php 231 + 232 + class JsonExporter implements ExporterInterface 233 + { 234 + public static function getFormat(): string 235 + { 236 + return 'json'; 237 + } 238 + 239 + public function export(array $data): string 240 + { 241 + return json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); 242 + } 243 + 244 + public function getContentType(): string 245 + { 246 + return 'application/json'; 247 + } 248 + 249 + public function getFileExtension(): string 250 + { 251 + return 'json'; 252 + } 253 + } 254 + 255 + // src/Export/XlsxExporter.php 256 + 257 + class XlsxExporter implements ExporterInterface 258 + { 259 + public static function getFormat(): string 260 + { 261 + return 'xlsx'; 262 + } 263 + 264 + public function export(array $data): string 265 + { 266 + // PhpSpreadsheet implementation 267 + } 268 + 269 + public function getContentType(): string 270 + { 271 + return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; 272 + } 273 + 274 + public function getFileExtension(): string 275 + { 276 + return 'xlsx'; 277 + } 278 + } 279 + ``` 280 + 281 + ### Export Service 282 + 283 + ```php 284 + <?php 285 + // src/Export/ExportService.php 286 + 287 + namespace App\Export; 288 + 289 + use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; 290 + use Symfony\Component\DependencyInjection\ServiceLocator; 291 + 292 + class ExportService 293 + { 294 + public function __construct( 295 + #[TaggedLocator('app.exporter', defaultIndexMethod: 'getFormat')] 296 + private ServiceLocator $exporters, 297 + ) {} 298 + 299 + public function export(array $data, string $format): ExportResult 300 + { 301 + if (!$this->exporters->has($format)) { 302 + throw new UnsupportedFormatException($format); 303 + } 304 + 305 + /** @var ExporterInterface $exporter */ 306 + $exporter = $this->exporters->get($format); 307 + 308 + return new ExportResult( 309 + content: $exporter->export($data), 310 + contentType: $exporter->getContentType(), 311 + filename: 'export.' . $exporter->getFileExtension(), 312 + ); 313 + } 314 + 315 + public function getAvailableFormats(): array 316 + { 317 + return array_keys($this->exporters->getProvidedServices()); 318 + } 319 + } 320 + ``` 321 + 322 + ## Priority in Tagged Services 323 + 324 + ```php 325 + #[AutoconfigureTag('app.payment_processor', ['priority' => 10])] 326 + class StripeProcessor implements PaymentProcessorInterface 327 + { 328 + // Higher priority = checked first 329 + } 330 + 331 + #[AutoconfigureTag('app.payment_processor', ['priority' => 0])] 332 + class FallbackProcessor implements PaymentProcessorInterface 333 + { 334 + // Lower priority = fallback 335 + } 336 + ``` 337 + 338 + ## Testing 339 + 340 + ```php 341 + class PaymentServiceTest extends TestCase 342 + { 343 + public function testSelectsCorrectProcessor(): void 344 + { 345 + $stripe = $this->createMock(PaymentProcessorInterface::class); 346 + $stripe->method('supports')->willReturnCallback( 347 + fn($m) => $m === 'card' 348 + ); 349 + 350 + $paypal = $this->createMock(PaymentProcessorInterface::class); 351 + $paypal->method('supports')->willReturnCallback( 352 + fn($m) => $m === 'paypal' 353 + ); 354 + 355 + $service = new PaymentService([$stripe, $paypal]); 356 + 357 + // Verify correct processor is selected 358 + $stripe->expects($this->once())->method('process'); 359 + $service->process($payment, 'card'); 360 + } 361 + } 362 + ``` 363 + 364 + ## Best Practices 365 + 366 + 1. **Interface first**: Define clear contract 367 + 2. **AutoconfigureTag**: On interface or each implementation 368 + 3. **Service locator**: For direct access by key 369 + 4. **Iterator**: When checking all strategies 370 + 5. **Priority**: Control evaluation order 371 + 6. **Fallback**: Include a default strategy 372 + 373 + 374 + ## Skill Operating Checklist 375 + 376 + ### Design checklist 377 + - Confirm operation boundaries and invariants first. 378 + - Minimize scope while preserving contract correctness. 379 + - Test both happy path and negative path behavior. 380 + 381 + ### Validation commands 382 + - rg --files 383 + - composer validate 384 + - ./vendor/bin/phpstan analyse 385 + 386 + ### Failure modes to test 387 + - Invalid payload or forbidden actor. 388 + - Boundary values / not-found cases. 389 + - Retry or partial-failure behavior for async flows. 390 +
+42
.agents/skills/symfony-symfony-cache/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-symfony-cache 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Implement caching with the Symfony Cache component; configure pools, use tags for invalidation, prevent stampede 12 + --- 13 + 14 + # Symfony Cache (Symfony) 15 + 16 + ## Use when 17 + - Implementing asynchronous workflows with Messenger/Scheduler/Cache. 18 + - Stabilizing retries and failure transports. 19 + 20 + ## Default workflow 21 + 1. Define async contract and delivery semantics. 22 + 2. Implement idempotent handlers and routing strategy. 23 + 3. Configure retries, failure transport, and observability. 24 + 4. Validate success/failure replay scenarios. 25 + 26 + ## Guardrails 27 + - Assume at-least-once delivery, not exactly-once. 28 + - Keep handlers deterministic and side-effect aware. 29 + - Surface poison-message handling strategy. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Async config/handlers updated. 37 + - Retry/failure policy decisions. 38 + - Operational validation evidence. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+402
.agents/skills/symfony-symfony-cache/reference.md
··· 1 + # Reference 2 + 3 + # Symfony Cache 4 + 5 + ## Installation 6 + 7 + ```bash 8 + composer require symfony/cache 9 + ``` 10 + 11 + ## Basic Usage 12 + 13 + ### Inject Cache 14 + 15 + ```php 16 + <?php 17 + 18 + use Symfony\Contracts\Cache\CacheInterface; 19 + use Symfony\Contracts\Cache\ItemInterface; 20 + 21 + class ProductService 22 + { 23 + public function __construct( 24 + private CacheInterface $cache, 25 + ) {} 26 + 27 + public function getProduct(int $id): Product 28 + { 29 + return $this->cache->get("product_{$id}", function (ItemInterface $item) use ($id) { 30 + $item->expiresAfter(3600); // 1 hour 31 + 32 + return $this->repository->find($id); 33 + }); 34 + } 35 + } 36 + ``` 37 + 38 + ### Delete Cache 39 + 40 + ```php 41 + $this->cache->delete("product_{$id}"); 42 + ``` 43 + 44 + ## Configuration 45 + 46 + ```yaml 47 + # config/packages/cache.yaml 48 + framework: 49 + cache: 50 + # Default cache adapter 51 + app: cache.adapter.redis 52 + system: cache.adapter.system 53 + 54 + # Define pools 55 + pools: 56 + cache.products: 57 + adapter: cache.adapter.redis 58 + default_lifetime: 3600 59 + 60 + cache.api_responses: 61 + adapter: cache.adapter.filesystem 62 + default_lifetime: 300 63 + 64 + cache.sessions: 65 + adapter: cache.adapter.redis 66 + default_lifetime: 86400 67 + ``` 68 + 69 + ## Cache Adapters 70 + 71 + ### Redis 72 + 73 + ```yaml 74 + # .env 75 + REDIS_URL=redis://localhost:6379 76 + 77 + # config/packages/cache.yaml 78 + framework: 79 + cache: 80 + app: cache.adapter.redis 81 + default_redis_provider: '%env(REDIS_URL)%' 82 + ``` 83 + 84 + ### Filesystem 85 + 86 + ```yaml 87 + framework: 88 + cache: 89 + app: cache.adapter.filesystem 90 + ``` 91 + 92 + ### APCu (In-memory) 93 + 94 + ```yaml 95 + framework: 96 + cache: 97 + app: cache.adapter.apcu 98 + ``` 99 + 100 + ### Chained (Multi-tier) 101 + 102 + ```yaml 103 + framework: 104 + cache: 105 + pools: 106 + cache.products: 107 + adapters: 108 + - cache.adapter.apcu # Fast, local 109 + - cache.adapter.redis # Shared, persistent 110 + ``` 111 + 112 + ## Cache Pools 113 + 114 + ### Inject Specific Pool 115 + 116 + ```php 117 + <?php 118 + 119 + use Symfony\Component\DependencyInjection\Attribute\Autowire; 120 + use Symfony\Contracts\Cache\CacheInterface; 121 + 122 + class ProductService 123 + { 124 + public function __construct( 125 + #[Autowire(service: 'cache.products')] 126 + private CacheInterface $cache, 127 + ) {} 128 + } 129 + ``` 130 + 131 + ### PSR-6 Interface 132 + 133 + ```php 134 + <?php 135 + 136 + use Psr\Cache\CacheItemPoolInterface; 137 + 138 + class LegacyService 139 + { 140 + public function __construct( 141 + private CacheItemPoolInterface $cache, 142 + ) {} 143 + 144 + public function getData(string $key): mixed 145 + { 146 + $item = $this->cache->getItem($key); 147 + 148 + if (!$item->isHit()) { 149 + $item->set($this->fetchData()); 150 + $item->expiresAfter(3600); 151 + $this->cache->save($item); 152 + } 153 + 154 + return $item->get(); 155 + } 156 + } 157 + ``` 158 + 159 + ## Cache Tags 160 + 161 + Tags allow invalidating groups of cache items: 162 + 163 + ```php 164 + <?php 165 + 166 + use Symfony\Contracts\Cache\TagAwareCacheInterface; 167 + use Symfony\Contracts\Cache\ItemInterface; 168 + 169 + class ProductService 170 + { 171 + public function __construct( 172 + private TagAwareCacheInterface $cache, 173 + ) {} 174 + 175 + public function getProduct(int $id): Product 176 + { 177 + return $this->cache->get("product_{$id}", function (ItemInterface $item) use ($id) { 178 + $item->expiresAfter(3600); 179 + $item->tag(['products', "product_{$id}", "category_{$categoryId}"]); 180 + 181 + return $this->repository->find($id); 182 + }); 183 + } 184 + 185 + public function getProductsByCategory(int $categoryId): array 186 + { 187 + return $this->cache->get("category_{$categoryId}_products", function (ItemInterface $item) use ($categoryId) { 188 + $item->tag(['products', "category_{$categoryId}"]); 189 + 190 + return $this->repository->findByCategory($categoryId); 191 + }); 192 + } 193 + 194 + public function invalidateProduct(int $id): void 195 + { 196 + // Invalidate specific product 197 + $this->cache->invalidateTags(["product_{$id}"]); 198 + } 199 + 200 + public function invalidateCategory(int $categoryId): void 201 + { 202 + // Invalidate all products in category 203 + $this->cache->invalidateTags(["category_{$categoryId}"]); 204 + } 205 + 206 + public function invalidateAllProducts(): void 207 + { 208 + // Invalidate all product caches 209 + $this->cache->invalidateTags(['products']); 210 + } 211 + } 212 + ``` 213 + 214 + ### Configuration for Tag-Aware Cache 215 + 216 + ```yaml 217 + framework: 218 + cache: 219 + pools: 220 + cache.products: 221 + adapter: cache.adapter.redis 222 + tags: true # generic tag support 223 + # On Redis, prefer the dedicated tag-aware adapter for efficient 224 + # tag storage and invalidation: 225 + cache.catalog: 226 + adapter: cache.adapter.redis_tag_aware 227 + ``` 228 + 229 + `TagAwareCacheInterface::invalidateTags([...])` (shown above) drops every item 230 + carrying one of those tags. 231 + 232 + ## Early Expiration (anti-stampede) 233 + 234 + To avoid a cache stampede when a hot key expires under concurrent load, ask the 235 + callback to recompute *before* expiry. The probabilistic `$beta` (1.0 is a sane 236 + default) decides when a request recomputes early; others keep serving the stale 237 + value. 238 + 239 + ```php 240 + use Symfony\Contracts\Cache\ItemInterface; 241 + 242 + $value = $this->cache->get('home_feed', function (ItemInterface $item) { 243 + $item->expiresAfter(3600); 244 + return $this->buildExpensiveFeed(); 245 + }, beta: 1.0); 246 + ``` 247 + 248 + For true zero-latency refresh, offload the recompute to Messenger via 249 + `Symfony\Component\Cache\Messenger\EarlyExpirationHandler` + a callback service 250 + implementing `CallbackInterface`, so the early-expiration recomputation runs in 251 + a worker instead of the web request. 252 + 253 + ## Marshaller per pool (8.1+ — verify) 254 + 255 + A pool can serialize/compress its values with a custom marshaller: 256 + 257 + ```yaml 258 + framework: 259 + cache: 260 + pools: 261 + cache.products: 262 + adapter: cache.adapter.redis 263 + tags: true 264 + # e.g. cache.default_marshaller, or a service compressing/encrypting payloads 265 + # marshaller: App\Cache\MyMarshaller 266 + ``` 267 + 268 + ## HTTP Cache 269 + 270 + ### Response Caching 271 + 272 + ```php 273 + <?php 274 + 275 + use Symfony\Component\HttpFoundation\Response; 276 + 277 + class ProductController 278 + { 279 + #[Route('/products/{id}')] 280 + public function show(Product $product): Response 281 + { 282 + $response = $this->render('product/show.html.twig', [ 283 + 'product' => $product, 284 + ]); 285 + 286 + // Public cache (CDN, proxy) 287 + $response->setPublic(); 288 + $response->setMaxAge(3600); 289 + $response->setSharedMaxAge(3600); 290 + 291 + // ETag for validation 292 + $response->setEtag(md5($response->getContent())); 293 + 294 + return $response; 295 + } 296 + } 297 + ``` 298 + 299 + ### Cache-Control Headers 300 + 301 + ```php 302 + $response->headers->set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); 303 + 304 + // Or using methods 305 + $response->setPublic(); 306 + $response->setPrivate(); 307 + $response->setMaxAge(3600); // Browser cache 308 + $response->setSharedMaxAge(3600); // CDN/proxy cache 309 + $response->setExpires(new \DateTime('+1 hour')); 310 + ``` 311 + 312 + ## Cache Attributes 313 + 314 + ```php 315 + <?php 316 + 317 + use Symfony\Component\HttpKernel\Attribute\Cache; 318 + 319 + class ProductController 320 + { 321 + #[Route('/products/{id}')] 322 + #[Cache(public: true, maxage: 3600, smaxage: 3600)] 323 + public function show(Product $product): Response 324 + { 325 + return $this->render('product/show.html.twig', [ 326 + 'product' => $product, 327 + ]); 328 + } 329 + } 330 + ``` 331 + 332 + ## Cache Warmup 333 + 334 + ```php 335 + <?php 336 + // src/Cache/ProductCacheWarmer.php 337 + 338 + use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; 339 + 340 + class ProductCacheWarmer implements CacheWarmerInterface 341 + { 342 + public function __construct( 343 + private ProductRepository $products, 344 + private CacheInterface $cache, 345 + ) {} 346 + 347 + public function warmUp(string $cacheDir, ?string $buildDir = null): array 348 + { 349 + foreach ($this->products->findPopular(100) as $product) { 350 + $this->cache->get("product_{$product->getId()}", fn() => $product); 351 + } 352 + 353 + return []; 354 + } 355 + 356 + public function isOptional(): bool 357 + { 358 + return true; 359 + } 360 + } 361 + ``` 362 + 363 + ## Clear Cache 364 + 365 + ```bash 366 + # Clear all caches 367 + bin/console cache:clear 368 + 369 + # Clear specific pool 370 + bin/console cache:pool:clear cache.products 371 + 372 + # Clear by tag 373 + bin/console cache:pool:invalidate-tags cache.products products 374 + ``` 375 + 376 + ## Best Practices 377 + 378 + 1. **Use tags**: For flexible invalidation 379 + 2. **Set TTLs**: Don't cache forever 380 + 3. **Warm critical caches**: Pre-populate on deploy 381 + 4. **Monitor hit rates**: Track cache effectiveness 382 + 5. **Chain adapters**: Fast local + shared persistent 383 + 6. **Invalidate precisely**: Don't clear everything 384 + 385 + 386 + ## Skill Operating Checklist 387 + 388 + ### Design checklist 389 + - Confirm operation boundaries and invariants first. 390 + - Minimize scope while preserving contract correctness. 391 + - Test both happy path and negative path behavior. 392 + 393 + ### Validation commands 394 + - php bin/console messenger:consume --limit=1 395 + - php bin/console messenger:failed:show 396 + - ./vendor/bin/phpunit --filter=Messenger 397 + 398 + ### Failure modes to test 399 + - Invalid payload or forbidden actor. 400 + - Boundary values / not-found cases. 401 + - Retry or partial-failure behavior for async flows. 402 +
+41
.agents/skills/symfony-symfony-messenger/SKILL.md
··· 1 + --- 2 + name: symfony-symfony-messenger 3 + allowed-tools: 4 + - Read 5 + - Write 6 + - Edit 7 + - Bash 8 + - Glob 9 + - Grep 10 + description: Async message handling with Symfony Messenger; configure transports (RabbitMQ, Redis, Doctrine); implement handlers, middleware, and retry strategies 11 + --- 12 + 13 + # Symfony Messenger (Symfony) 14 + 15 + ## Use when 16 + - Implementing asynchronous workflows with Messenger/Scheduler/Cache. 17 + - Stabilizing retries and failure transports. 18 + 19 + ## Default workflow 20 + 1. Define async contract and delivery semantics. 21 + 2. Implement idempotent handlers and routing strategy. 22 + 3. Configure retries, failure transport, and observability. 23 + 4. Validate success/failure replay scenarios. 24 + 25 + ## Guardrails 26 + - Assume at-least-once delivery, not exactly-once. 27 + - Keep handlers deterministic and side-effect aware. 28 + - Surface poison-message handling strategy. 29 + 30 + ## Progressive disclosure 31 + - Use this file for execution posture and risk controls. 32 + - Open references when deep implementation details are needed. 33 + 34 + ## Output contract 35 + - Async config/handlers updated. 36 + - Retry/failure policy decisions. 37 + - Operational validation evidence. 38 + 39 + ## References 40 + - `reference.md` 41 + - `docs/complexity-tiers.md`
+240
.agents/skills/symfony-symfony-messenger/reference.md
··· 1 + # Symfony Messenger Reference (Symfony) 2 + 3 + Use this reference for implementation details and review criteria specific to `symfony-messenger`. 4 + 5 + ## Message + Handler 6 + 7 + A message is a plain, serializable data class. The handler is a service with 8 + `#[AsMessageHandler]` and a type-hinted `__invoke()`. 9 + 10 + ```php 11 + <?php 12 + // src/Message/SendWelcomeEmail.php 13 + 14 + namespace App\Message; 15 + 16 + final readonly class SendWelcomeEmail 17 + { 18 + public function __construct( 19 + public int $userId, // pass the ID, never the entity (see below) 20 + ) {} 21 + } 22 + ``` 23 + 24 + ```php 25 + <?php 26 + // src/MessageHandler/SendWelcomeEmailHandler.php 27 + 28 + namespace App\MessageHandler; 29 + 30 + use App\Message\SendWelcomeEmail; 31 + use App\Repository\UserRepository; 32 + use Symfony\Component\Mailer\MailerInterface; 33 + use Symfony\Component\Messenger\Attribute\AsMessageHandler; 34 + 35 + #[AsMessageHandler] 36 + final readonly class SendWelcomeEmailHandler 37 + { 38 + public function __construct( 39 + private UserRepository $users, 40 + private MailerInterface $mailer, 41 + ) {} 42 + 43 + public function __invoke(SendWelcomeEmail $message): void 44 + { 45 + // Refetch the entity from its ID inside the handler. 46 + $user = $this->users->find($message->userId) 47 + ?? throw new \RuntimeException("User {$message->userId} not found"); 48 + 49 + // ... send the email 50 + } 51 + } 52 + ``` 53 + 54 + Dispatch from anywhere with `MessageBusInterface`: 55 + 56 + ```php 57 + $bus->dispatch(new SendWelcomeEmail($user->getId())); 58 + ``` 59 + 60 + ## Routing 61 + 62 + ### Via attribute on the message class 63 + 64 + ```php 65 + <?php 66 + 67 + namespace App\Message; 68 + 69 + use Symfony\Component\Messenger\Attribute\AsMessage; 70 + 71 + #[AsMessage('async')] 72 + // Multiple transports: 73 + #[AsMessage(['async', 'audit'])] 74 + final readonly class SendWelcomeEmail 75 + { 76 + public function __construct(public int $userId) {} 77 + } 78 + ``` 79 + 80 + ### Via YAML 81 + 82 + ```yaml 83 + # config/packages/messenger.yaml 84 + framework: 85 + messenger: 86 + transports: 87 + async: '%env(MESSENGER_TRANSPORT_DSN)%' 88 + routing: 89 + 'App\Message\SendWelcomeEmail': async 90 + 'App\Message\*': async # wildcard (suffix only) 91 + '*': async # default route for unmatched messages 92 + ``` 93 + 94 + ## Transports 95 + 96 + ```yaml 97 + framework: 98 + messenger: 99 + transports: 100 + # Doctrine (table-backed queue) 101 + async: 'doctrine://default?table_name=messenger_messages&queue_name=default' 102 + 103 + # Redis 104 + redis: 'redis://localhost:6379/messages' 105 + 106 + # AMQP (RabbitMQ) 107 + amqp: '%env(MESSENGER_TRANSPORT_DSN)%' # amqp://guest:guest@localhost:5672/%2f/messages 108 + 109 + # Synchronous — handled in-process, no worker (good for dev) 110 + sync: 'sync://' 111 + 112 + # In-memory — for tests; messages collected, never delivered 113 + in_memory: 'in-memory://' 114 + ``` 115 + 116 + Doctrine on PostgreSQL supports smart LISTEN/NOTIFY (`use_notify`, default true) **(8.1+)**. 117 + 118 + ## Retry strategy & failure transport 119 + 120 + ```yaml 121 + framework: 122 + messenger: 123 + failure_transport: failed 124 + transports: 125 + async: 126 + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' 127 + retry_strategy: 128 + max_retries: 3 129 + delay: 1000 # ms before first retry 130 + multiplier: 2 # exponential backoff 131 + max_delay: 60000 132 + jitter: 0.1 133 + service: null # custom RetryStrategyInterface 134 + failed: 'doctrine://default?queue_name=failed' 135 + ``` 136 + 137 + After `max_retries` is exhausted, the message moves to the `failed` transport. 138 + See the `messenger-retry-failures` skill for exception types and the 139 + `messenger:failed:*` commands. 140 + 141 + ## Multiple buses (command / query) 142 + 143 + ```yaml 144 + framework: 145 + messenger: 146 + default_bus: command.bus 147 + buses: 148 + command.bus: ~ 149 + query.bus: ~ 150 + routing: 151 + 'App\Command\*': command.bus 152 + 'App\Query\*': query.bus 153 + ``` 154 + 155 + ```php 156 + use Symfony\Component\DependencyInjection\Attribute\Autowire; 157 + use Symfony\Component\Messenger\MessageBusInterface; 158 + 159 + public function __construct( 160 + #[Autowire('@command.bus')] private MessageBusInterface $commandBus, 161 + #[Autowire('@query.bus')] private MessageBusInterface $queryBus, 162 + ) {} 163 + ``` 164 + 165 + See the `cqrs-and-handlers` skill for the full CQRS pattern. 166 + 167 + ## Consuming messages 168 + 169 + ```bash 170 + php bin/console messenger:consume async -vv 171 + php bin/console messenger:consume async_high async_low # priority order 172 + php bin/console messenger:consume --all --exclude-receivers=async_low 173 + php bin/console messenger:consume async --limit=10 --memory-limit=128M --time-limit=3600 174 + php bin/console messenger:stats 175 + php bin/console debug:messenger 176 + ``` 177 + 178 + ## Critical rules 179 + 180 + - **Pass entity IDs, never entity objects.** Doctrine entities don't serialize 181 + cleanly (proxies, detached state). Refetch by ID inside the handler. 182 + - **Make handlers idempotent.** A message may be redelivered. Use a stable 183 + idempotency key (derived from the business event) and check whether the work 184 + was already done before acting. 185 + 186 + ```php 187 + public function __invoke(ProcessOrder $message): void 188 + { 189 + $order = $this->orders->find($message->orderId); 190 + if ($order->getStatus() === OrderStatus::Processed) { 191 + return; // already done — success, don't throw 192 + } 193 + // ... process, then persist the new status 194 + } 195 + ``` 196 + 197 + ## Recent features (Symfony 8.1+) 198 + 199 + > Marked 8.1+ — verify the exact minimum version before relying on it. 200 + 201 + - `PriorityStamp` for message-level prioritization (AMQP/Beanstalkd): 202 + ```php 203 + use Symfony\Component\Messenger\Envelope; 204 + use Symfony\Component\Messenger\Stamp\PriorityStamp; 205 + 206 + $bus->dispatch((new Envelope($message))->with(new PriorityStamp(255))); 207 + ``` 208 + - `messenger:consume scheduler_.*` — regex receiver names. 209 + - `--fetch-size=N` — batch prefetch from the transport. 210 + - `--no-reset=N` — reset stateful services every N messages. 211 + - Graceful shutdown via PCNTL signals: 212 + ```yaml 213 + framework: 214 + messenger: 215 + stop_worker_on_signals: [SIGTERM, SIGINT, SIGUSR1] 216 + ``` 217 + Trigger a rolling restart on deploy with `php bin/console messenger:stop-workers`. 218 + - `ListableReceiverInterface` (Redis): `->all(10)`, `->find($id)`. 219 + - Per-transport `rate_limiter`. 220 + - Decode failures are now routed through the retry/failure pipeline instead of 221 + being silently dropped. 222 + 223 + 224 + ## Skill Operating Checklist 225 + 226 + ### Design checklist 227 + - Confirm operation boundaries and invariants first. 228 + - Minimize scope while preserving contract correctness. 229 + - Test both happy path and negative path behavior. 230 + 231 + ### Validation commands 232 + - php bin/console messenger:consume --limit=1 233 + - php bin/console messenger:failed:show 234 + - ./vendor/bin/phpunit --filter=Messenger 235 + 236 + ### Failure modes to test 237 + - Invalid payload or forbidden actor. 238 + - Boundary values / not-found cases. 239 + - Retry or partial-failure behavior for async flows. 240 +
+42
.agents/skills/symfony-symfony-scheduler/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-symfony-scheduler 4 + allowed-tools: 5 + - Read 6 + - Write 7 + - Edit 8 + - Bash 9 + - Glob 10 + - Grep 11 + description: Schedule recurring tasks with the Symfony Scheduler component (native since 7.x); define schedules, triggers, and integrate with Messenger 12 + --- 13 + 14 + # Symfony Scheduler (Symfony) 15 + 16 + ## Use when 17 + - Implementing asynchronous workflows with Messenger/Scheduler/Cache. 18 + - Stabilizing retries and failure transports. 19 + 20 + ## Default workflow 21 + 1. Define async contract and delivery semantics. 22 + 2. Implement idempotent handlers and routing strategy. 23 + 3. Configure retries, failure transport, and observability. 24 + 4. Validate success/failure replay scenarios. 25 + 26 + ## Guardrails 27 + - Assume at-least-once delivery, not exactly-once. 28 + - Keep handlers deterministic and side-effect aware. 29 + - Surface poison-message handling strategy. 30 + 31 + ## Progressive disclosure 32 + - Use this file for execution posture and risk controls. 33 + - Open references when deep implementation details are needed. 34 + 35 + ## Output contract 36 + - Async config/handlers updated. 37 + - Retry/failure policy decisions. 38 + - Operational validation evidence. 39 + 40 + ## References 41 + - `reference.md` 42 + - `docs/complexity-tiers.md`
+352
.agents/skills/symfony-symfony-scheduler/reference.md
··· 1 + # Reference 2 + 3 + # Symfony Scheduler 4 + 5 + Available in Symfony 7.1+ as a native component. 6 + 7 + ## Installation 8 + 9 + ```bash 10 + composer require symfony/scheduler 11 + ``` 12 + 13 + ## Basic Schedule 14 + 15 + ### Define a Schedule 16 + 17 + ```php 18 + <?php 19 + // src/Scheduler/DefaultScheduleProvider.php 20 + 21 + namespace App\Scheduler; 22 + 23 + use App\Message\CleanupExpiredSessions; 24 + use App\Message\GenerateDailyReport; 25 + use App\Message\SyncInventory; 26 + use Symfony\Component\Scheduler\Attribute\AsSchedule; 27 + use Symfony\Component\Scheduler\RecurringMessage; 28 + use Symfony\Component\Scheduler\Schedule; 29 + use Symfony\Component\Scheduler\ScheduleProviderInterface; 30 + 31 + #[AsSchedule('default')] 32 + class DefaultScheduleProvider implements ScheduleProviderInterface 33 + { 34 + public function getSchedule(): Schedule 35 + { 36 + return (new Schedule()) 37 + // Every 5 minutes 38 + ->add(RecurringMessage::every('5 minutes', new SyncInventory())) 39 + 40 + // Daily at 2 AM 41 + ->add(RecurringMessage::every('1 day', new GenerateDailyReport()) 42 + ->from(new \DateTimeImmutable('02:00'))) 43 + 44 + // Every hour 45 + ->add(RecurringMessage::every('1 hour', new CleanupExpiredSessions())) 46 + 47 + // Cron expression 48 + ->add(RecurringMessage::cron('0 */6 * * *', new ProcessQueuedEmails())) 49 + ; 50 + } 51 + } 52 + ``` 53 + 54 + ### Message Classes 55 + 56 + ```php 57 + <?php 58 + // src/Message/GenerateDailyReport.php 59 + 60 + namespace App\Message; 61 + 62 + final class GenerateDailyReport 63 + { 64 + public function __construct( 65 + public readonly ?\DateTimeImmutable $forDate = null, 66 + ) {} 67 + } 68 + 69 + // src/MessageHandler/GenerateDailyReportHandler.php 70 + 71 + namespace App\MessageHandler; 72 + 73 + use App\Message\GenerateDailyReport; 74 + use Symfony\Component\Messenger\Attribute\AsMessageHandler; 75 + 76 + #[AsMessageHandler] 77 + class GenerateDailyReportHandler 78 + { 79 + public function __invoke(GenerateDailyReport $message): void 80 + { 81 + $date = $message->forDate ?? new \DateTimeImmutable('yesterday'); 82 + 83 + $this->reportService->generate($date); 84 + $this->logger->info('Daily report generated', ['date' => $date->format('Y-m-d')]); 85 + } 86 + } 87 + ``` 88 + 89 + ## Trigger Types 90 + 91 + ### Time-Based Triggers 92 + 93 + ```php 94 + use Symfony\Component\Scheduler\RecurringMessage; 95 + use Symfony\Component\Scheduler\Trigger\CronExpressionTrigger; 96 + 97 + // Every X interval 98 + RecurringMessage::every('5 minutes', new MyMessage()) 99 + RecurringMessage::every('1 hour', new MyMessage()) 100 + RecurringMessage::every('1 day', new MyMessage()) 101 + RecurringMessage::every('1 week', new MyMessage()) 102 + 103 + // Cron expressions 104 + RecurringMessage::cron('*/5 * * * *', new MyMessage()) // Every 5 minutes 105 + RecurringMessage::cron('0 * * * *', new MyMessage()) // Every hour 106 + RecurringMessage::cron('0 0 * * *', new MyMessage()) // Daily at midnight 107 + RecurringMessage::cron('0 0 * * 0', new MyMessage()) // Weekly on Sunday 108 + RecurringMessage::cron('0 0 1 * *', new MyMessage()) // Monthly on 1st 109 + 110 + // With timezone 111 + RecurringMessage::cron('0 9 * * 1-5', new MyMessage()) 112 + ->timezone(new \DateTimeZone('Europe/Paris')) 113 + 114 + // Starting from specific time 115 + RecurringMessage::every('1 day', new MyMessage()) 116 + ->from(new \DateTimeImmutable('06:00')) 117 + 118 + // Until specific time 119 + RecurringMessage::every('1 hour', new MyMessage()) 120 + ->until(new \DateTimeImmutable('2024-12-31')) 121 + ``` 122 + 123 + ### Custom Trigger 124 + 125 + ```php 126 + <?php 127 + // src/Scheduler/Trigger/BusinessHoursTrigger.php 128 + 129 + namespace App\Scheduler\Trigger; 130 + 131 + use Symfony\Component\Scheduler\Trigger\TriggerInterface; 132 + 133 + class BusinessHoursTrigger implements TriggerInterface 134 + { 135 + public function __construct( 136 + private TriggerInterface $inner, 137 + ) {} 138 + 139 + public function __toString(): string 140 + { 141 + return 'business_hours(' . $this->inner . ')'; 142 + } 143 + 144 + public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable 145 + { 146 + $next = $this->inner->getNextRunDate($run); 147 + 148 + if ($next === null) { 149 + return null; 150 + } 151 + 152 + // Skip weekends 153 + while ((int) $next->format('N') >= 6) { 154 + $next = $next->modify('+1 day')->setTime(9, 0); 155 + } 156 + 157 + // Only between 9 AM and 6 PM 158 + $hour = (int) $next->format('H'); 159 + if ($hour < 9) { 160 + $next = $next->setTime(9, 0); 161 + } elseif ($hour >= 18) { 162 + $next = $next->modify('+1 day')->setTime(9, 0); 163 + } 164 + 165 + return $next; 166 + } 167 + } 168 + ``` 169 + 170 + ## Multiple Schedules 171 + 172 + ```php 173 + <?php 174 + // src/Scheduler/MaintenanceScheduleProvider.php 175 + 176 + #[AsSchedule('maintenance')] 177 + class MaintenanceScheduleProvider implements ScheduleProviderInterface 178 + { 179 + public function getSchedule(): Schedule 180 + { 181 + return (new Schedule()) 182 + ->add(RecurringMessage::cron('0 3 * * *', new DatabaseBackup())) 183 + ->add(RecurringMessage::cron('0 4 * * 0', new CleanupLogs())) 184 + ; 185 + } 186 + } 187 + ``` 188 + 189 + Run specific schedule: 190 + 191 + ```bash 192 + bin/console messenger:consume scheduler_maintenance 193 + ``` 194 + 195 + ## Running the Scheduler 196 + 197 + ### As Messenger Transport 198 + 199 + The scheduler creates a special transport: 200 + 201 + ```bash 202 + # Run the default schedule 203 + bin/console messenger:consume scheduler_default 204 + 205 + # Run specific schedule 206 + bin/console messenger:consume scheduler_maintenance 207 + 208 + # With worker options 209 + bin/console messenger:consume scheduler_default --time-limit=3600 210 + ``` 211 + 212 + ### Supervisor Configuration 213 + 214 + ```ini 215 + [program:scheduler] 216 + command=php /var/www/app/bin/console messenger:consume scheduler_default --time-limit=3600 217 + user=www-data 218 + numprocs=1 219 + autostart=true 220 + autorestart=true 221 + startretries=10 222 + stderr_logfile=/var/log/scheduler.err.log 223 + stdout_logfile=/var/log/scheduler.out.log 224 + ``` 225 + 226 + ## Stateful Schedules 227 + 228 + Track last run and prevent overlap: 229 + 230 + ```php 231 + <?php 232 + 233 + use Symfony\Component\Lock\LockFactory; 234 + use Symfony\Component\Scheduler\Schedule; 235 + 236 + #[AsSchedule('default')] 237 + class DefaultScheduleProvider implements ScheduleProviderInterface 238 + { 239 + public function __construct( 240 + private LockFactory $lockFactory, 241 + private CacheInterface $cache, 242 + ) {} 243 + 244 + public function getSchedule(): Schedule 245 + { 246 + return (new Schedule()) 247 + ->stateful($this->cache) // Remember last run times 248 + ->lock($this->lockFactory->createLock('scheduler')) // Prevent overlap 249 + ->add(RecurringMessage::every('1 hour', new HeavyTask())) 250 + ; 251 + } 252 + } 253 + ``` 254 + 255 + ## Testing Schedules 256 + 257 + ```php 258 + <?php 259 + 260 + use Symfony\Component\Scheduler\RecurringMessage; 261 + 262 + class ScheduleTest extends TestCase 263 + { 264 + public function testDailyReportScheduledCorrectly(): void 265 + { 266 + $provider = new DefaultScheduleProvider(); 267 + $schedule = $provider->getSchedule(); 268 + 269 + $messages = $schedule->getRecurringMessages(); 270 + 271 + // Find the daily report message 272 + $dailyReport = array_filter( 273 + iterator_to_array($messages), 274 + fn($m) => $m->getMessage() instanceof GenerateDailyReport 275 + ); 276 + 277 + $this->assertCount(1, $dailyReport); 278 + 279 + // Check next run time 280 + $message = reset($dailyReport); 281 + $trigger = $message->getTrigger(); 282 + $nextRun = $trigger->getNextRunDate(new \DateTimeImmutable()); 283 + 284 + $this->assertEquals('02', $nextRun->format('H')); 285 + } 286 + } 287 + ``` 288 + 289 + ## Monitoring 290 + 291 + ```php 292 + <?php 293 + // src/EventSubscriber/SchedulerMonitoringSubscriber.php 294 + 295 + use Symfony\Component\EventDispatcher\EventSubscriberInterface; 296 + use Symfony\Component\Scheduler\Event\PostRunEvent; 297 + use Symfony\Component\Scheduler\Event\PreRunEvent; 298 + 299 + class SchedulerMonitoringSubscriber implements EventSubscriberInterface 300 + { 301 + public static function getSubscribedEvents(): array 302 + { 303 + return [ 304 + PreRunEvent::class => 'onPreRun', 305 + PostRunEvent::class => 'onPostRun', 306 + ]; 307 + } 308 + 309 + public function onPreRun(PreRunEvent $event): void 310 + { 311 + $this->logger->info('Starting scheduled task', [ 312 + 'message' => get_class($event->getMessage()), 313 + ]); 314 + } 315 + 316 + public function onPostRun(PostRunEvent $event): void 317 + { 318 + $this->logger->info('Completed scheduled task', [ 319 + 'message' => get_class($event->getMessage()), 320 + 'duration' => $event->getDuration(), 321 + ]); 322 + } 323 + } 324 + ``` 325 + 326 + ## Best Practices 327 + 328 + 1. **Stateful schedules**: Use cache to track last run 329 + 2. **Lock heavy tasks**: Prevent overlapping executions 330 + 3. **Supervisor**: Use for production reliability 331 + 4. **Monitor execution**: Log start/end times 332 + 5. **Idempotent tasks**: Safe to re-run if needed 333 + 6. **Timezone awareness**: Be explicit about timezones 334 + 335 + 336 + ## Skill Operating Checklist 337 + 338 + ### Design checklist 339 + - Confirm operation boundaries and invariants first. 340 + - Minimize scope while preserving contract correctness. 341 + - Test both happy path and negative path behavior. 342 + 343 + ### Validation commands 344 + - php bin/console messenger:consume --limit=1 345 + - php bin/console messenger:failed:show 346 + - ./vendor/bin/phpunit --filter=Messenger 347 + 348 + ### Failure modes to test 349 + - Invalid payload or forbidden actor. 350 + - Boundary values / not-found cases. 351 + - Retry or partial-failure behavior for async flows. 352 +
+41
.agents/skills/symfony-symfony-voters/SKILL.md
··· 1 + --- 2 + name: symfony-symfony-voters 3 + allowed-tools: 4 + - Read 5 + - Write 6 + - Edit 7 + - Bash 8 + - Glob 9 + - Grep 10 + description: Implement granular authorization with Symfony Voters; decouple permission logic from controllers; test authorization separately 11 + --- 12 + 13 + # Symfony Voters (Symfony) 14 + 15 + ## Use when 16 + - Hardening access-control or validation boundaries. 17 + - Aligning voters/security expressions with domain rules. 18 + 19 + ## Default workflow 20 + 1. Map actor/resource/action decision matrix. 21 + 2. Implement voter/constraint logic at the right boundary. 22 + 3. Wire checks at controllers and API operations. 23 + 4. Test allowed/forbidden/invalid paths comprehensively. 24 + 25 + ## Guardrails 26 + - Avoid policy logic duplication across layers. 27 + - Do not leak privileged state via error detail. 28 + - Preserve explicit deny behavior for sensitive actions. 29 + 30 + ## Progressive disclosure 31 + - Use this file for execution posture and risk controls. 32 + - Open references when deep implementation details are needed. 33 + 34 + ## Output contract 35 + - Security boundary updates. 36 + - Integration points enforcing decisions. 37 + - Negative-path test results. 38 + 39 + ## References 40 + - `reference.md` 41 + - `docs/complexity-tiers.md`
+154
.agents/skills/symfony-symfony-voters/reference.md
··· 1 + # Symfony Voters Reference (Symfony) 2 + 3 + Use this reference for implementation details and review criteria specific to `symfony-voters`. 4 + 5 + ## Anatomy of a voter 6 + 7 + Extend `Voter` and implement `supports()` and `voteOnAttribute()`. 8 + 9 + ```php 10 + <?php 11 + // src/Security/Voter/PostVoter.php 12 + 13 + namespace App\Security\Voter; 14 + 15 + use App\Entity\Post; 16 + use App\Entity\User; 17 + use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; 18 + use Symfony\Component\Security\Core\Authorization\Voter\Vote; 19 + use Symfony\Component\Security\Core\Authorization\Voter\Voter; 20 + 21 + final class PostVoter extends Voter 22 + { 23 + public const VIEW = 'POST_VIEW'; 24 + public const EDIT = 'POST_EDIT'; 25 + 26 + protected function supports(string $attribute, mixed $subject): bool 27 + { 28 + return in_array($attribute, [self::VIEW, self::EDIT], true) 29 + && $subject instanceof Post; 30 + } 31 + 32 + // The optional `?Vote $vote = null` 4th argument lets the voter attach 33 + // human-readable reasons (surfaced via access_decision()). 34 + // Symfony 7.3+/8.x — recent; verify the minimum version. Keeping the 35 + // signature with `?Vote $vote = null` is backward-compatible. 36 + protected function voteOnAttribute( 37 + string $attribute, 38 + mixed $subject, 39 + TokenInterface $token, 40 + ?Vote $vote = null, 41 + ): bool { 42 + $user = $token->getUser(); 43 + if (!$user instanceof User) { 44 + $vote?->addReason('The user is not authenticated.'); 45 + return false; 46 + } 47 + 48 + /** @var Post $subject */ 49 + return match ($attribute) { 50 + self::VIEW => $subject->isPublished() || $subject->getAuthor() === $user, 51 + self::EDIT => $this->canEdit($subject, $user, $vote), 52 + default => false, 53 + }; 54 + } 55 + 56 + private function canEdit(Post $post, User $user, ?Vote $vote): bool 57 + { 58 + if ($post->getAuthor() !== $user) { 59 + $vote?->addReason('Only the author may edit this post.'); 60 + $vote?->extraData['author_id'] = $post->getAuthor()->getId(); 61 + return false; 62 + } 63 + return true; 64 + } 65 + } 66 + ``` 67 + 68 + ## Using voters 69 + 70 + Controller attribute (preferred) — the second arg names the request attribute 71 + holding the subject: 72 + 73 + ```php 74 + use Symfony\Component\Security\Http\Attribute\IsGranted; 75 + 76 + #[IsGranted(PostVoter::EDIT, 'post', message: 'You cannot edit this post.', statusCode: 403)] 77 + public function edit(Post $post): Response { /* ... */ } 78 + ``` 79 + 80 + Imperatively in a controller: 81 + 82 + ```php 83 + $this->denyAccessUnlessGranted(PostVoter::EDIT, $post); 84 + ``` 85 + 86 + ## Priority 87 + 88 + `Voter` already implements `CacheableVoterInterface` (via `supports()`), so 89 + `isGranted()` only calls voters whose `supportsType()`/`supportsAttribute()` 90 + match. To order voters explicitly: 91 + 92 + ```php 93 + use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; 94 + 95 + #[AsTaggedItem(priority: 10)] // higher priority runs first 96 + final class PostVoter extends Voter {} 97 + ``` 98 + 99 + ## Checking a role *inside* a voter 100 + 101 + Never call `Security::isGranted()` inside a voter — it runs with the wrong token 102 + context and can recurse. Inject `AccessDecisionManagerInterface` and use the 103 + token you were handed: 104 + 105 + ```php 106 + use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; 107 + 108 + public function __construct( 109 + private readonly AccessDecisionManagerInterface $accessDecisionManager, 110 + ) {} 111 + 112 + // inside voteOnAttribute(): 113 + if ($this->accessDecisionManager->decide($token, ['ROLE_SUPER_ADMIN'])) { 114 + return true; // admins bypass ownership checks 115 + } 116 + ``` 117 + 118 + ## Access decision strategies 119 + 120 + ```yaml 121 + # config/packages/security.yaml 122 + security: 123 + access_decision_manager: 124 + strategy: affirmative # affirmative (default) | consensus | unanimous | priority 125 + allow_if_all_abstain: false 126 + ``` 127 + 128 + | Strategy | Grants access when… | 129 + |----------|---------------------| 130 + | `affirmative` (default) | any voter grants | 131 + | `consensus` | more voters grant than deny | 132 + | `unanimous` | no voter denies | 133 + | `priority` | the first non-abstaining voter decides | 134 + 135 + Custom strategy: `strategy_service` implementing `AccessDecisionStrategyInterface`. 136 + 137 + 138 + ## Skill Operating Checklist 139 + 140 + ### Design checklist 141 + - Confirm operation boundaries and invariants first. 142 + - Minimize scope while preserving contract correctness. 143 + - Test both happy path and negative path behavior. 144 + 145 + ### Validation commands 146 + - ./vendor/bin/phpunit --filter=Voter 147 + - php bin/console debug:container security 148 + - ./vendor/bin/phpstan analyse 149 + 150 + ### Failure modes to test 151 + - Invalid payload or forbidden actor. 152 + - Boundary values / not-found cases. 153 + - Retry or partial-failure behavior for async flows. 154 +
+41
.agents/skills/symfony-tdd-with-phpunit/SKILL.md
··· 1 + --- 2 + name: symfony-tdd-with-phpunit 3 + allowed-tools: 4 + - Read 5 + - Write 6 + - Edit 7 + - Bash 8 + - Glob 9 + - Grep 10 + description: Apply RED-GREEN-REFACTOR with PHPUnit 10/11 for Symfony; KernelTestCase/WebTestCase, attributes (#[Test]/#[DataProvider]), Foundry 11 + --- 12 + 13 + # Tdd With Phpunit (Symfony) 14 + 15 + ## Use when 16 + - Building regression-safe behavior with TDD/functional/e2e tests. 17 + - Converting bug reports into executable failing tests. 18 + 19 + ## Default workflow 20 + 1. Write failing test for target behavior and one boundary case. 21 + 2. Implement minimal code to pass. 22 + 3. Refactor while preserving green suite. 23 + 4. Broaden coverage for invalid/unauthorized/not-found paths. 24 + 25 + ## Guardrails 26 + - Prefer deterministic fixtures/builders. 27 + - Assert observable behavior, not internal implementation. 28 + - Keep tests isolated and stable in CI. 29 + 30 + ## Progressive disclosure 31 + - Use this file for execution posture and risk controls. 32 + - Open references when deep implementation details are needed. 33 + 34 + ## Output contract 35 + - RED/GREEN/REFACTOR trace. 36 + - Test files changed and executed commands. 37 + - Coverage and confidence notes. 38 + 39 + ## References 40 + - `reference.md` 41 + - `docs/complexity-tiers.md`
+222
.agents/skills/symfony-tdd-with-phpunit/reference.md
··· 1 + # Tdd With Phpunit Reference (Symfony) 2 + 3 + Use this reference for implementation details and review criteria specific to `tdd-with-phpunit`. 4 + 5 + > **Version applicability** — Targets **PHPUnit 10/11** with Symfony 7.4 LTS / 8.x. 6 + > PHPUnit 10+ uses **PHP attributes** (`#[Test]`, `#[DataProvider]`, `#[CoversClass]`) 7 + > — the old `/** @test @dataProvider */` annotations are deprecated/removed. 8 + > `static::getContainer()` replaces legacy `self::$container`. Console test helpers and 9 + > 422 assertions are Symfony 8.1+ where noted. 10 + 11 + ## The TDD cycle 12 + 13 + ``` 14 + RED → write a failing test that expresses the desired behavior 15 + GREEN → write the minimum code to make it pass 16 + REFACTOR → improve structure with the test as a safety net (stay green) 17 + ``` 18 + 19 + Repeat per behavior. Never write production code without a failing test first. 20 + 21 + ## Test base classes 22 + 23 + | Base class | Use for | 24 + | --- | --- | 25 + | `TestCase` (PHPUnit) | Pure logic — no kernel, no container, fastest. | 26 + | `KernelTestCase` | Integration — boot kernel, real container/services. | 27 + | `WebTestCase` | Functional — simulate full HTTP requests. | 28 + 29 + ## Attributes (PHPUnit 10+) 30 + 31 + Annotations are out; use attributes: 32 + 33 + ```php 34 + use PHPUnit\Framework\TestCase; 35 + use PHPUnit\Framework\Attributes\Test; 36 + use PHPUnit\Framework\Attributes\DataProvider; 37 + use PHPUnit\Framework\Attributes\CoversClass; 38 + 39 + #[CoversClass(PriceCalculator::class)] 40 + final class PriceCalculatorTest extends TestCase 41 + { 42 + #[Test] 43 + public function it_applies_vat(): void 44 + { 45 + $calc = new PriceCalculator(); 46 + self::assertSame(120.0, $calc->withVat(100.0, 0.20)); 47 + } 48 + 49 + #[Test] 50 + #[DataProvider('vatCases')] 51 + public function it_applies_various_rates(float $net, float $rate, float $expected): void 52 + { 53 + self::assertSame($expected, (new PriceCalculator())->withVat($net, $rate)); 54 + } 55 + 56 + public static function vatCases(): \Generator 57 + { 58 + yield 'standard' => [100.0, 0.20, 120.0]; 59 + yield 'reduced' => [100.0, 0.055, 105.5]; 60 + yield 'zero rate' => [100.0, 0.0, 100.0]; 61 + } 62 + } 63 + ``` 64 + 65 + ## RED — write the failing test first 66 + 67 + ### Unit (no kernel) 68 + 69 + ```php 70 + #[Test] 71 + public function it_rejects_an_empty_order(): void 72 + { 73 + $this->expectException(\InvalidArgumentException::class); 74 + $this->expectExceptionMessage('Order must have at least one item'); 75 + 76 + (new OrderService($this->createMock(EntityManagerInterface::class))) 77 + ->createOrder($user, []); 78 + } 79 + ``` 80 + 81 + ### Integration (KernelTestCase + real container) 82 + 83 + ```php 84 + use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 85 + 86 + final class OrderServiceTest extends KernelTestCase 87 + { 88 + #[Test] 89 + public function it_creates_an_order(): void 90 + { 91 + self::bootKernel(); 92 + $service = static::getContainer()->get(OrderService::class); 93 + 94 + $order = $service->createOrder($user, [['productId' => 1, 'quantity' => 2]]); 95 + 96 + self::assertCount(1, $order->getItems()); 97 + } 98 + } 99 + ``` 100 + 101 + ## GREEN — minimum code to pass 102 + 103 + ```php 104 + final class OrderService 105 + { 106 + public function __construct(private EntityManagerInterface $em) {} 107 + 108 + public function createOrder(User $user, array $items): Order 109 + { 110 + if (empty($items)) { 111 + throw new \InvalidArgumentException('Order must have at least one item'); 112 + } 113 + // ... build + persist + flush, nothing speculative 114 + return $order; 115 + } 116 + } 117 + ``` 118 + 119 + ## REFACTOR — stay green 120 + 121 + Extract collaborators, introduce value objects, move queries to repositories. Re-run the 122 + suite after each change. 123 + 124 + ## Mocking services in the test container 125 + 126 + In `KernelTestCase`/`WebTestCase` you can swap a real service for a test double via the 127 + test container: 128 + 129 + ```php 130 + $mock = $this->createMock(NewsRepositoryInterface::class); 131 + $mock->method('findActive')->willReturn([]); 132 + 133 + static::getContainer()->set(NewsRepositoryInterface::class, $mock); 134 + ``` 135 + 136 + For **non-shared** services, pass a closure (Symfony 8.1+) so a fresh instance is returned 137 + each time: 138 + 139 + ```php 140 + static::getContainer()->set(Mailer::class, fn(): Mailer => $mock); 141 + ``` 142 + 143 + > Prefer real services + real DB for repositories (see the `functional-tests` skill). 144 + > Reserve mocks for true external boundaries (HTTP clients, third-party SDKs). 145 + 146 + ## Testing console commands 147 + 148 + Classic approach with `CommandTester`: 149 + 150 + ```php 151 + use Symfony\Component\Console\Tester\CommandTester; 152 + 153 + $command = (new Application(self::$kernel))->find('app:import'); 154 + $tester = new CommandTester($command); 155 + $tester->execute(['file' => 'data.csv']); 156 + 157 + $tester->assertCommandIsSuccessful(); 158 + self::assertStringContainsString('Imported', $tester->getDisplay()); 159 + ``` 160 + 161 + Symfony **8.1+** adds a `runCommand()` helper returning an `ExecutionResult`: 162 + 163 + ```php 164 + final class ImportCommandTest extends KernelTestCase 165 + { 166 + #[Test] 167 + public function it_imports(): void 168 + { 169 + $result = static::runCommand('app:import data.csv'); 170 + 171 + $this->assertCommandIsSuccessful($result); // 8.1+ assertion 172 + self::assertStringContainsString('Imported', $result->getDisplay()); 173 + } 174 + } 175 + ``` 176 + 177 + Related console assertions (8.1+): `assertCommandIsSuccessful`, `assertCommandFailed`, 178 + `assertCommandResultEquals`. 179 + 180 + ## HTTP / form status assertions 181 + 182 + When a controller renders an invalid form (Symfony 8.0+ passes the form, not the view), 183 + the response is **HTTP 422**: 184 + 185 + ```php 186 + #[Test] 187 + public function it_rejects_invalid_submission(): void 188 + { 189 + $client = static::createClient(); 190 + $client->request('POST', '/register', ['email' => 'not-an-email']); 191 + 192 + $this->assertResponseIsUnprocessable(); // 422 193 + $this->assertSelectorTextContains('.form-error', 'valid email'); 194 + } 195 + ``` 196 + 197 + ## Review criteria 198 + 199 + - A failing test exists before the production change (RED first). 200 + - Tests use PHPUnit 10+ **attributes** (`#[Test]`, `#[DataProvider]`, `#[CoversClass]`), not annotations. 201 + - Correct base class: `TestCase` (logic) / `KernelTestCase` (services) / `WebTestCase` (HTTP). 202 + - Services fetched via `static::getContainer()`; mocks only at external boundaries. 203 + - Console tests use `runCommand()` + `assertCommandIsSuccessful` (8.1+) or `CommandTester`. 204 + - Invalid form/HTTP cases assert 422 via `assertResponseIsUnprocessable()`. 205 + 206 + 207 + ## Skill Operating Checklist 208 + 209 + ### Design checklist 210 + - Confirm operation boundaries and invariants first. 211 + - Minimize scope while preserving contract correctness. 212 + - Test both happy path and negative path behavior. 213 + 214 + ### Validation commands 215 + - ./vendor/bin/phpunit --filter=... 216 + - ./vendor/bin/phpunit 217 + - ./vendor/bin/pest --filter=... 218 + 219 + ### Failure modes to test 220 + - Invalid payload or forbidden actor. 221 + - Boundary values / not-found cases. 222 + - Retry or partial-failure behavior for async flows.
+39
.agents/skills/symfony-test-doubles-mocking/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-test-doubles-mocking 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Create test doubles with PHPUnit mocks for isolated unit testing in Symfony 9 + --- 10 + 11 + # Test Doubles Mocking (Symfony) 12 + 13 + ## Use when 14 + - Building regression-safe behavior with TDD/functional/e2e tests. 15 + - Converting bug reports into executable failing tests. 16 + 17 + ## Default workflow 18 + 1. Write failing test for target behavior and one boundary case. 19 + 2. Implement minimal code to pass. 20 + 3. Refactor while preserving green suite. 21 + 4. Broaden coverage for invalid/unauthorized/not-found paths. 22 + 23 + ## Guardrails 24 + - Prefer deterministic fixtures/builders. 25 + - Assert observable behavior, not internal implementation. 26 + - Keep tests isolated and stable in CI. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - RED/GREEN/REFACTOR trace. 34 + - Test files changed and executed commands. 35 + - Coverage and confidence notes. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+323
.agents/skills/symfony-test-doubles-mocking/reference.md
··· 1 + # Reference 2 + 3 + # Test Doubles and Mocking 4 + 5 + ## Types of Test Doubles 6 + 7 + | Type | Purpose | 8 + |------|---------| 9 + | **Dummy** | Passed but never used | 10 + | **Stub** | Returns predetermined values | 11 + | **Mock** | Verifies interactions | 12 + | **Spy** | Records calls for later verification | 13 + | **Fake** | Working implementation (simplified) | 14 + 15 + ## PHPUnit Mocks 16 + 17 + ### Basic Mock 18 + 19 + ```php 20 + <?php 21 + 22 + use App\Service\PaymentGateway; 23 + use App\Service\OrderService; 24 + use PHPUnit\Framework\TestCase; 25 + 26 + class OrderServiceTest extends TestCase 27 + { 28 + public function testProcessPayment(): void 29 + { 30 + // Create mock 31 + $gateway = $this->createMock(PaymentGateway::class); 32 + 33 + // Configure return value 34 + $gateway->method('charge') 35 + ->willReturn(new PaymentResult(success: true, transactionId: 'tx_123')); 36 + 37 + $service = new OrderService($gateway); 38 + $result = $service->processPayment(1000, 'EUR'); 39 + 40 + $this->assertTrue($result->isSuccessful()); 41 + } 42 + } 43 + ``` 44 + 45 + ### Mock with Expectations 46 + 47 + ```php 48 + public function testChargesCorrectAmount(): void 49 + { 50 + $gateway = $this->createMock(PaymentGateway::class); 51 + 52 + // Expect specific call 53 + $gateway->expects($this->once()) 54 + ->method('charge') 55 + ->with( 56 + $this->equalTo(1000), 57 + $this->equalTo('EUR') 58 + ) 59 + ->willReturn(new PaymentResult(success: true)); 60 + 61 + $service = new OrderService($gateway); 62 + $service->processPayment(1000, 'EUR'); 63 + } 64 + ``` 65 + 66 + ### Consecutive Returns 67 + 68 + ```php 69 + public function testRetriesOnFailure(): void 70 + { 71 + $gateway = $this->createMock(PaymentGateway::class); 72 + 73 + $gateway->expects($this->exactly(2)) 74 + ->method('charge') 75 + ->willReturnOnConsecutiveCalls( 76 + new PaymentResult(success: false), // First call fails 77 + new PaymentResult(success: true) // Second succeeds 78 + ); 79 + 80 + $service = new OrderService($gateway); 81 + $result = $service->processPaymentWithRetry(1000, 'EUR'); 82 + 83 + $this->assertTrue($result->isSuccessful()); 84 + } 85 + ``` 86 + 87 + ### Throwing Exceptions 88 + 89 + ```php 90 + public function testHandlesGatewayError(): void 91 + { 92 + $gateway = $this->createMock(PaymentGateway::class); 93 + 94 + $gateway->method('charge') 95 + ->willThrowException(new GatewayException('Connection timeout')); 96 + 97 + $service = new OrderService($gateway); 98 + 99 + $this->expectException(PaymentFailedException::class); 100 + $service->processPayment(1000, 'EUR'); 101 + } 102 + ``` 103 + 104 + ### Callback for Complex Logic 105 + 106 + ```php 107 + public function testDynamicResponse(): void 108 + { 109 + $repository = $this->createMock(ProductRepository::class); 110 + 111 + $repository->method('find') 112 + ->willReturnCallback(function (int $id) { 113 + if ($id === 1) { 114 + return new Product(id: 1, name: 'Product 1'); 115 + } 116 + return null; 117 + }); 118 + 119 + $service = new ProductService($repository); 120 + 121 + $this->assertNotNull($service->getProduct(1)); 122 + $this->assertNull($service->getProduct(999)); 123 + } 124 + ``` 125 + 126 + ## Prophecy (Alternative) 127 + 128 + Prophecy provides a different syntax, often considered more readable. 129 + 130 + ```php 131 + <?php 132 + 133 + use Prophecy\PhpUnit\ProphecyTrait; 134 + 135 + class OrderServiceTest extends TestCase 136 + { 137 + use ProphecyTrait; 138 + 139 + public function testProcessPayment(): void 140 + { 141 + // Create prophecy 142 + $gateway = $this->prophesize(PaymentGateway::class); 143 + 144 + // Stub method 145 + $gateway->charge(1000, 'EUR') 146 + ->willReturn(new PaymentResult(success: true)); 147 + 148 + // Reveal to get actual mock 149 + $service = new OrderService($gateway->reveal()); 150 + 151 + $result = $service->processPayment(1000, 'EUR'); 152 + $this->assertTrue($result->isSuccessful()); 153 + } 154 + 155 + public function testCallsGatewayOnce(): void 156 + { 157 + $gateway = $this->prophesize(PaymentGateway::class); 158 + 159 + // Expect call 160 + $gateway->charge(1000, 'EUR') 161 + ->shouldBeCalledOnce() 162 + ->willReturn(new PaymentResult(success: true)); 163 + 164 + $service = new OrderService($gateway->reveal()); 165 + $service->processPayment(1000, 'EUR'); 166 + } 167 + } 168 + ``` 169 + 170 + ## Mocking Symfony Services 171 + 172 + ### EntityManager 173 + 174 + ```php 175 + public function testPersistsEntity(): void 176 + { 177 + $em = $this->createMock(EntityManagerInterface::class); 178 + 179 + $em->expects($this->once()) 180 + ->method('persist') 181 + ->with($this->isInstanceOf(User::class)); 182 + 183 + $em->expects($this->once()) 184 + ->method('flush'); 185 + 186 + $service = new UserService($em); 187 + $service->createUser('test@example.com'); 188 + } 189 + ``` 190 + 191 + ### Repository 192 + 193 + ```php 194 + public function testFindsUser(): void 195 + { 196 + $user = new User(); 197 + $user->setEmail('test@example.com'); 198 + 199 + $repository = $this->createMock(UserRepository::class); 200 + $repository->method('findOneByEmail') 201 + ->with('test@example.com') 202 + ->willReturn($user); 203 + 204 + $service = new UserService($repository); 205 + $found = $service->findByEmail('test@example.com'); 206 + 207 + $this->assertSame($user, $found); 208 + } 209 + ``` 210 + 211 + ### MessageBus 212 + 213 + ```php 214 + public function testDispatchesMessage(): void 215 + { 216 + $bus = $this->createMock(MessageBusInterface::class); 217 + 218 + $bus->expects($this->once()) 219 + ->method('dispatch') 220 + ->with($this->callback(function ($message) { 221 + return $message instanceof SendWelcomeEmail 222 + && $message->userId === 123; 223 + })) 224 + ->willReturn(new Envelope(new \stdClass())); 225 + 226 + $service = new RegistrationService($bus); 227 + $service->register(123, 'test@example.com'); 228 + } 229 + ``` 230 + 231 + ## Partial Mocks 232 + 233 + Mock only some methods: 234 + 235 + ```php 236 + public function testPartialMock(): void 237 + { 238 + $service = $this->getMockBuilder(OrderService::class) 239 + ->setConstructorArgs([$this->gateway]) 240 + ->onlyMethods(['sendNotification']) // Only mock this 241 + ->getMock(); 242 + 243 + $service->method('sendNotification') 244 + ->willReturn(true); 245 + 246 + // Real processPayment, mocked sendNotification 247 + $service->processPayment(1000, 'EUR'); 248 + } 249 + ``` 250 + 251 + ## Fakes (Working Implementations) 252 + 253 + ```php 254 + <?php 255 + // tests/Fake/InMemoryUserRepository.php 256 + 257 + class InMemoryUserRepository implements UserRepositoryInterface 258 + { 259 + private array $users = []; 260 + 261 + public function save(User $user): void 262 + { 263 + $this->users[$user->getId()] = $user; 264 + } 265 + 266 + public function find(int $id): ?User 267 + { 268 + return $this->users[$id] ?? null; 269 + } 270 + 271 + public function findByEmail(string $email): ?User 272 + { 273 + foreach ($this->users as $user) { 274 + if ($user->getEmail() === $email) { 275 + return $user; 276 + } 277 + } 278 + return null; 279 + } 280 + } 281 + ``` 282 + 283 + Usage: 284 + 285 + ```php 286 + public function testCreatesUser(): void 287 + { 288 + $repository = new InMemoryUserRepository(); 289 + $service = new UserService($repository); 290 + 291 + $user = $service->createUser('test@example.com'); 292 + 293 + $this->assertNotNull($repository->findByEmail('test@example.com')); 294 + } 295 + ``` 296 + 297 + ## Best Practices 298 + 299 + 1. **Mock dependencies, not the SUT**: Don't mock the class you're testing 300 + 2. **Use interfaces**: Mock interfaces, not concrete classes 301 + 3. **One mock assertion per test**: Keep tests focused 302 + 4. **Prefer stubs over mocks**: Only verify when behavior matters 303 + 5. **Use fakes for repositories**: More realistic tests 304 + 6. **Don't over-mock**: Integration tests have value too 305 + 306 + 307 + ## Skill Operating Checklist 308 + 309 + ### Design checklist 310 + - Confirm operation boundaries and invariants first. 311 + - Minimize scope while preserving contract correctness. 312 + - Test both happy path and negative path behavior. 313 + 314 + ### Validation commands 315 + - ./vendor/bin/phpunit --filter=... 316 + - ./vendor/bin/phpunit 317 + - ./vendor/bin/pest --filter=... 318 + 319 + ### Failure modes to test 320 + - Invalid payload or forbidden actor. 321 + - Boundary values / not-found cases. 322 + - Retry or partial-failure behavior for async flows. 323 +
+39
.agents/skills/symfony-twig-components/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-twig-components 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Build reusable UI components with Symfony UX Twig Components (props, slots, anonymous components, CVA) for clean templates 9 + --- 10 + 11 + # Twig Components (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+443
.agents/skills/symfony-twig-components/reference.md
··· 1 + # Reference 2 + 3 + # Twig Components (Symfony UX) 4 + 5 + ## Installation 6 + 7 + ```bash 8 + composer require symfony/ux-twig-component 9 + # For reactive components 10 + composer require symfony/ux-live-component 11 + ``` 12 + 13 + ## Basic Twig Component 14 + 15 + ### Create Component Class 16 + 17 + ```php 18 + <?php 19 + // src/Twig/Components/Alert.php 20 + 21 + namespace App\Twig\Components; 22 + 23 + use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; 24 + 25 + #[AsTwigComponent] 26 + class Alert 27 + { 28 + public string $type = 'info'; 29 + public string $message; 30 + public bool $dismissible = false; 31 + } 32 + ``` 33 + 34 + ### Create Template 35 + 36 + ```twig 37 + {# templates/components/Alert.html.twig #} 38 + <div class="alert alert-{{ type }}{% if dismissible %} alert-dismissible{% endif %}" role="alert"> 39 + {{ message }} 40 + {% if dismissible %} 41 + <button type="button" class="btn-close" data-bs-dismiss="alert"></button> 42 + {% endif %} 43 + </div> 44 + ``` 45 + 46 + ### Use Component 47 + 48 + ```twig 49 + {# In any template #} 50 + <twig:Alert type="success" message="Operation completed!" /> 51 + <twig:Alert type="danger" message="An error occurred" dismissible /> 52 + ``` 53 + 54 + ## Component with Slots 55 + 56 + ### Component Class 57 + 58 + ```php 59 + <?php 60 + // src/Twig/Components/Card.php 61 + 62 + namespace App\Twig\Components; 63 + 64 + use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; 65 + 66 + #[AsTwigComponent] 67 + class Card 68 + { 69 + public ?string $title = null; 70 + public string $class = ''; 71 + } 72 + ``` 73 + 74 + ### Template with Slots 75 + 76 + ```twig 77 + {# templates/components/Card.html.twig #} 78 + <div class="card {{ class }}"> 79 + {% if title %} 80 + <div class="card-header"> 81 + <h5 class="card-title">{{ title }}</h5> 82 + </div> 83 + {% endif %} 84 + 85 + <div class="card-body"> 86 + {% block content %}{% endblock %} 87 + </div> 88 + 89 + {% if block('footer') is not empty %} 90 + <div class="card-footer"> 91 + {% block footer %}{% endblock %} 92 + </div> 93 + {% endif %} 94 + </div> 95 + ``` 96 + 97 + ### Usage 98 + 99 + ```twig 100 + <twig:Card title="User Profile"> 101 + <twig:block name="content"> 102 + <p>Name: {{ user.name }}</p> 103 + <p>Email: {{ user.email }}</p> 104 + </twig:block> 105 + 106 + <twig:block name="footer"> 107 + <a href="{{ path('user_edit', {id: user.id}) }}" class="btn btn-primary">Edit</a> 108 + </twig:block> 109 + </twig:Card> 110 + ``` 111 + 112 + ## Component with Logic 113 + 114 + ```php 115 + <?php 116 + // src/Twig/Components/UserCard.php 117 + 118 + namespace App\Twig\Components; 119 + 120 + use App\Entity\User; 121 + use App\Repository\PostRepository; 122 + use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; 123 + 124 + #[AsTwigComponent] 125 + class UserCard 126 + { 127 + public User $user; 128 + 129 + public function __construct( 130 + private PostRepository $postRepository, 131 + ) {} 132 + 133 + public function getPostCount(): int 134 + { 135 + return $this->postRepository->countByAuthor($this->user); 136 + } 137 + 138 + public function getRecentPosts(): array 139 + { 140 + return $this->postRepository->findRecentByAuthor($this->user, 3); 141 + } 142 + } 143 + ``` 144 + 145 + ```twig 146 + {# templates/components/UserCard.html.twig #} 147 + <div class="user-card"> 148 + <h3>{{ user.name }}</h3> 149 + <p>{{ this.postCount }} posts</p> 150 + 151 + <ul> 152 + {% for post in this.recentPosts %} 153 + <li>{{ post.title }}</li> 154 + {% endfor %} 155 + </ul> 156 + </div> 157 + ``` 158 + 159 + ## readonly caveat 160 + 161 + Do **not** mark a Twig component class or its public props as `readonly`: props 162 + are assigned *after* instantiation, so a readonly public prop throws. Inject 163 + services as `private readonly`, but keep public props mutable. 164 + 165 + ```php 166 + #[AsTwigComponent] 167 + class Alert 168 + { 169 + public function __construct( 170 + private readonly LoggerInterface $logger, // services: readonly OK 171 + ) {} 172 + 173 + public string $type = 'info'; // props: must stay mutable, no readonly 174 + public string $message = ''; 175 + } 176 + ``` 177 + 178 + If the class must be `readonly`, assign the props inside `mount()` instead. 179 + 180 + ## Anonymous Components 181 + 182 + A component with no PHP class — the name comes from the template path. Declare 183 + its props with `{% props %}`: 184 + 185 + ```twig 186 + {# templates/components/Button.html.twig #} 187 + {% props variant = 'primary', label %} 188 + <button class="btn btn-{{ variant }}" {{ attributes }}>{{ label }}</button> 189 + ``` 190 + 191 + ```twig 192 + <twig:Button variant="danger" label="Delete" type="submit" /> 193 + ``` 194 + 195 + ## CVA (Class Variant Authority) 196 + 197 + Manage Tailwind/utility class variants with `html_cva` (from 198 + `twig/html-extra` >= 3.12) and merge conflicting classes with `|tailwind_merge`: 199 + 200 + ```twig 201 + {# templates/components/Badge.html.twig #} 202 + {% set badge = html_cva( 203 + base: 'inline-flex items-center rounded px-2 py-1 text-xs font-medium', 204 + variants: { 205 + color: { gray: 'bg-gray-100 text-gray-800', red: 'bg-red-100 text-red-800' }, 206 + size: { sm: 'text-xs', md: 'text-sm' }, 207 + } 208 + ) %} 209 + <span class="{{ badge.apply({color, size}, attributes.render('class'))|tailwind_merge }}"> 210 + {{ label }} 211 + </span> 212 + ``` 213 + 214 + ## Testing Components 215 + 216 + ```php 217 + use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 218 + use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents; 219 + 220 + final class AlertTest extends KernelTestCase 221 + { 222 + use InteractsWithTwigComponents; 223 + 224 + public function testMount(): void 225 + { 226 + $component = $this->mountTwigComponent(name: 'Alert', data: ['message' => 'Hi']); 227 + self::assertSame('info', $component->type); 228 + } 229 + 230 + public function testRender(): void 231 + { 232 + $rendered = $this->renderTwigComponent(name: 'Alert', data: ['message' => 'Hi']); 233 + self::assertStringContainsString('Hi', (string) $rendered); 234 + } 235 + } 236 + ``` 237 + 238 + ## Live Components (Reactive) 239 + 240 + ### Counter Example 241 + 242 + ```php 243 + <?php 244 + // src/Twig/Components/Counter.php 245 + 246 + namespace App\Twig\Components; 247 + 248 + use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; 249 + use Symfony\UX\LiveComponent\Attribute\LiveProp; 250 + use Symfony\UX\LiveComponent\Attribute\LiveAction; 251 + use Symfony\UX\LiveComponent\DefaultActionTrait; 252 + 253 + #[AsLiveComponent] 254 + class Counter 255 + { 256 + use DefaultActionTrait; 257 + 258 + #[LiveProp(writable: true)] 259 + public int $count = 0; 260 + 261 + #[LiveAction] 262 + public function increment(): void 263 + { 264 + $this->count++; 265 + } 266 + 267 + #[LiveAction] 268 + public function decrement(): void 269 + { 270 + $this->count--; 271 + } 272 + } 273 + ``` 274 + 275 + ```twig 276 + {# templates/components/Counter.html.twig #} 277 + <div {{ attributes }}> 278 + <span>Count: {{ count }}</span> 279 + <button data-action="live#action" data-live-action-param="decrement">-</button> 280 + <button data-action="live#action" data-live-action-param="increment">+</button> 281 + </div> 282 + ``` 283 + 284 + ### Search Component 285 + 286 + ```php 287 + <?php 288 + // src/Twig/Components/ProductSearch.php 289 + 290 + namespace App\Twig\Components; 291 + 292 + use App\Repository\ProductRepository; 293 + use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; 294 + use Symfony\UX\LiveComponent\Attribute\LiveProp; 295 + use Symfony\UX\LiveComponent\DefaultActionTrait; 296 + 297 + #[AsLiveComponent] 298 + class ProductSearch 299 + { 300 + use DefaultActionTrait; 301 + 302 + #[LiveProp(writable: true, url: true)] 303 + public string $query = ''; 304 + 305 + #[LiveProp(writable: true)] 306 + public int $page = 1; 307 + 308 + public function __construct( 309 + private ProductRepository $products, 310 + ) {} 311 + 312 + public function getProducts(): array 313 + { 314 + if (strlen($this->query) < 2) { 315 + return []; 316 + } 317 + 318 + return $this->products->search($this->query, $this->page); 319 + } 320 + } 321 + ``` 322 + 323 + ```twig 324 + {# templates/components/ProductSearch.html.twig #} 325 + <div {{ attributes }}> 326 + <input 327 + type="search" 328 + data-model="query" 329 + placeholder="Search products..." 330 + class="form-control" 331 + > 332 + 333 + <div class="results mt-3"> 334 + {% for product in this.products %} 335 + <div class="product-card"> 336 + <h4>{{ product.name }}</h4> 337 + <p>{{ product.price|format_currency('EUR') }}</p> 338 + </div> 339 + {% else %} 340 + {% if query|length >= 2 %} 341 + <p>No products found.</p> 342 + {% endif %} 343 + {% endfor %} 344 + </div> 345 + </div> 346 + ``` 347 + 348 + ### Form Component 349 + 350 + ```php 351 + <?php 352 + // src/Twig/Components/ContactForm.php 353 + 354 + namespace App\Twig\Components; 355 + 356 + use App\Form\ContactType; 357 + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 358 + use Symfony\Component\Form\FormInterface; 359 + use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; 360 + use Symfony\UX\LiveComponent\Attribute\LiveProp; 361 + use Symfony\UX\LiveComponent\Attribute\LiveAction; 362 + use Symfony\UX\LiveComponent\ComponentWithFormTrait; 363 + use Symfony\UX\LiveComponent\DefaultActionTrait; 364 + 365 + #[AsLiveComponent] 366 + class ContactForm extends AbstractController 367 + { 368 + use ComponentWithFormTrait; 369 + use DefaultActionTrait; 370 + 371 + #[LiveProp] 372 + public bool $submitted = false; 373 + 374 + protected function instantiateForm(): FormInterface 375 + { 376 + return $this->createForm(ContactType::class); 377 + } 378 + 379 + #[LiveAction] 380 + public function submit(): void 381 + { 382 + $this->submitForm(); 383 + 384 + if ($this->getForm()->isValid()) { 385 + $data = $this->getForm()->getData(); 386 + // Process form... 387 + $this->submitted = true; 388 + } 389 + } 390 + } 391 + ``` 392 + 393 + ```twig 394 + {# templates/components/ContactForm.html.twig #} 395 + <div {{ attributes }}> 396 + {% if submitted %} 397 + <div class="alert alert-success">Thank you for your message!</div> 398 + {% else %} 399 + {{ form_start(form) }} 400 + {{ form_row(form.name) }} 401 + {{ form_row(form.email) }} 402 + {{ form_row(form.message) }} 403 + 404 + <button 405 + type="submit" 406 + data-action="live#action" 407 + data-live-action-param="submit" 408 + class="btn btn-primary" 409 + > 410 + Send 411 + </button> 412 + {{ form_end(form) }} 413 + {% endif %} 414 + </div> 415 + ``` 416 + 417 + ## Best Practices 418 + 419 + 1. **Keep components focused**: Single responsibility 420 + 2. **Use slots for flexibility**: Allow content injection 421 + 3. **LiveProp for state**: Mark writable props explicitly 422 + 4. **Debounce search**: Use `data-model="debounce(300)|query"` 423 + 5. **URL sync**: Use `url: true` for bookmarkable state 424 + 6. **Test components**: Unit test the PHP class 425 + 426 + 427 + ## Skill Operating Checklist 428 + 429 + ### Design checklist 430 + - Confirm operation boundaries and invariants first. 431 + - Minimize scope while preserving contract correctness. 432 + - Test both happy path and negative path behavior. 433 + 434 + ### Validation commands 435 + - rg --files 436 + - composer validate 437 + - ./vendor/bin/phpstan analyse 438 + 439 + ### Failure modes to test 440 + - Invalid payload or forbidden actor. 441 + - Boundary values / not-found cases. 442 + - Retry or partial-failure behavior for async flows. 443 +
+38
.agents/skills/symfony-using-symfony-superpowers/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-using-symfony-superpowers 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Entry point for Symfony Superpowers - lightweight workflow guidance and command map 9 + --- 10 + 11 + # Using Symfony Superpowers (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `docs/complexity-tiers.md`
+39
.agents/skills/symfony-value-objects-and-dtos/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-value-objects-and-dtos 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Design Value Objects for domain concepts and DTOs for data transfer with proper immutability (readonly) and validation 9 + --- 10 + 11 + # Value Objects And Dtos (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+447
.agents/skills/symfony-value-objects-and-dtos/reference.md
··· 1 + # Reference 2 + 3 + # Value Objects and DTOs in Symfony 4 + 5 + ## Value Objects 6 + 7 + Value Objects are immutable objects defined by their attributes, not identity. 8 + 9 + ### Money Value Object 10 + 11 + ```php 12 + <?php 13 + // src/Domain/ValueObject/Money.php 14 + 15 + namespace App\Domain\ValueObject; 16 + 17 + final readonly class Money 18 + { 19 + private function __construct( 20 + private int $amount, // In cents 21 + private string $currency, 22 + ) { 23 + if ($amount < 0) { 24 + throw new \InvalidArgumentException('Amount cannot be negative'); 25 + } 26 + if (strlen($currency) !== 3) { 27 + throw new \InvalidArgumentException('Currency must be ISO 4217 code'); 28 + } 29 + } 30 + 31 + public static function of(int $amount, string $currency): self 32 + { 33 + return new self($amount, strtoupper($currency)); 34 + } 35 + 36 + public static function EUR(int $amount): self 37 + { 38 + return new self($amount, 'EUR'); 39 + } 40 + 41 + public static function zero(string $currency = 'EUR'): self 42 + { 43 + return new self(0, strtoupper($currency)); 44 + } 45 + 46 + public function add(self $other): self 47 + { 48 + $this->assertSameCurrency($other); 49 + return new self($this->amount + $other->amount, $this->currency); 50 + } 51 + 52 + public function subtract(self $other): self 53 + { 54 + $this->assertSameCurrency($other); 55 + return new self($this->amount - $other->amount, $this->currency); 56 + } 57 + 58 + public function multiply(float $multiplier): self 59 + { 60 + return new self((int) round($this->amount * $multiplier), $this->currency); 61 + } 62 + 63 + public function getAmount(): int 64 + { 65 + return $this->amount; 66 + } 67 + 68 + public function getCurrency(): string 69 + { 70 + return $this->currency; 71 + } 72 + 73 + public function format(): string 74 + { 75 + return number_format($this->amount / 100, 2) . ' ' . $this->currency; 76 + } 77 + 78 + public function equals(self $other): bool 79 + { 80 + return $this->amount === $other->amount 81 + && $this->currency === $other->currency; 82 + } 83 + 84 + public function isGreaterThan(self $other): bool 85 + { 86 + $this->assertSameCurrency($other); 87 + return $this->amount > $other->amount; 88 + } 89 + 90 + public function isZero(): bool 91 + { 92 + return $this->amount === 0; 93 + } 94 + 95 + private function assertSameCurrency(self $other): void 96 + { 97 + if ($this->currency !== $other->currency) { 98 + throw new \InvalidArgumentException( 99 + "Cannot operate on different currencies: {$this->currency} vs {$other->currency}" 100 + ); 101 + } 102 + } 103 + } 104 + ``` 105 + 106 + ### Email Value Object 107 + 108 + ```php 109 + <?php 110 + // src/Domain/ValueObject/Email.php 111 + 112 + namespace App\Domain\ValueObject; 113 + 114 + final readonly class Email 115 + { 116 + private function __construct( 117 + private string $value, 118 + ) { 119 + if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { 120 + throw new \InvalidArgumentException("Invalid email: {$value}"); 121 + } 122 + } 123 + 124 + public static function fromString(string $email): self 125 + { 126 + return new self(strtolower(trim($email))); 127 + } 128 + 129 + public function getValue(): string 130 + { 131 + return $this->value; 132 + } 133 + 134 + public function getDomain(): string 135 + { 136 + return substr($this->value, strpos($this->value, '@') + 1); 137 + } 138 + 139 + public function equals(self $other): bool 140 + { 141 + return $this->value === $other->value; 142 + } 143 + 144 + public function __toString(): string 145 + { 146 + return $this->value; 147 + } 148 + } 149 + ``` 150 + 151 + ### Address Value Object 152 + 153 + ```php 154 + <?php 155 + // src/Domain/ValueObject/Address.php 156 + 157 + namespace App\Domain\ValueObject; 158 + 159 + final readonly class Address 160 + { 161 + public function __construct( 162 + public string $street, 163 + public string $city, 164 + public string $postalCode, 165 + public string $country, 166 + public ?string $state = null, 167 + ) { 168 + if (empty($street) || empty($city) || empty($postalCode) || empty($country)) { 169 + throw new \InvalidArgumentException('Address fields cannot be empty'); 170 + } 171 + } 172 + 173 + public function withStreet(string $street): self 174 + { 175 + return new self($street, $this->city, $this->postalCode, $this->country, $this->state); 176 + } 177 + 178 + public function format(): string 179 + { 180 + $parts = [$this->street, $this->postalCode . ' ' . $this->city]; 181 + 182 + if ($this->state) { 183 + $parts[] = $this->state; 184 + } 185 + 186 + $parts[] = $this->country; 187 + 188 + return implode("\n", $parts); 189 + } 190 + 191 + public function equals(self $other): bool 192 + { 193 + return $this->street === $other->street 194 + && $this->city === $other->city 195 + && $this->postalCode === $other->postalCode 196 + && $this->country === $other->country 197 + && $this->state === $other->state; 198 + } 199 + } 200 + ``` 201 + 202 + ## Doctrine Embeddables 203 + 204 + Store Value Objects in database: 205 + 206 + ```php 207 + <?php 208 + // src/Domain/ValueObject/Money.php 209 + 210 + use Doctrine\ORM\Mapping as ORM; 211 + 212 + #[ORM\Embeddable] 213 + final readonly class Money 214 + { 215 + #[ORM\Column(type: 'integer')] 216 + private int $amount; 217 + 218 + #[ORM\Column(type: 'string', length: 3)] 219 + private string $currency; 220 + 221 + // ... rest of class 222 + } 223 + 224 + // src/Entity/Order.php 225 + 226 + #[ORM\Entity] 227 + class Order 228 + { 229 + #[ORM\Embedded(class: Money::class)] 230 + private Money $total; 231 + 232 + public function getTotal(): Money 233 + { 234 + return $this->total; 235 + } 236 + } 237 + ``` 238 + 239 + ## DTOs (Data Transfer Objects) 240 + 241 + DTOs carry data between layers without behavior. 242 + 243 + ### Input DTO 244 + 245 + ```php 246 + <?php 247 + // src/Dto/CreateOrderInput.php 248 + 249 + namespace App\Dto; 250 + 251 + use Symfony\Component\Validator\Constraints as Assert; 252 + 253 + final readonly class CreateOrderInput 254 + { 255 + public function __construct( 256 + #[Assert\NotBlank] 257 + #[Assert\Positive] 258 + public int $customerId, 259 + 260 + #[Assert\NotBlank] 261 + #[Assert\Count(min: 1, minMessage: 'Order must have at least one item')] 262 + #[Assert\Valid] 263 + public array $items, 264 + 265 + #[Assert\Length(max: 20)] 266 + public ?string $couponCode = null, 267 + ) {} 268 + } 269 + 270 + // src/Dto/OrderItemInput.php 271 + 272 + final readonly class OrderItemInput 273 + { 274 + public function __construct( 275 + #[Assert\NotBlank] 276 + #[Assert\Positive] 277 + public int $productId, 278 + 279 + #[Assert\NotBlank] 280 + #[Assert\Positive] 281 + #[Assert\LessThanOrEqual(100)] 282 + public int $quantity, 283 + ) {} 284 + } 285 + ``` 286 + 287 + ### Output DTO 288 + 289 + ```php 290 + <?php 291 + // src/Dto/OrderOutput.php 292 + 293 + namespace App\Dto; 294 + 295 + use App\Entity\Order; 296 + 297 + final readonly class OrderOutput 298 + { 299 + public function __construct( 300 + public string $id, 301 + public int $customerId, 302 + public array $items, 303 + public MoneyOutput $total, 304 + public string $status, 305 + public string $createdAt, 306 + ) {} 307 + 308 + public static function fromEntity(Order $order): self 309 + { 310 + return new self( 311 + id: $order->getId(), 312 + customerId: $order->getCustomer()->getId(), 313 + items: array_map( 314 + fn($item) => OrderItemOutput::fromEntity($item), 315 + $order->getItems()->toArray() 316 + ), 317 + total: MoneyOutput::fromValueObject($order->getTotal()), 318 + status: $order->getStatus()->value, 319 + createdAt: $order->getCreatedAt()->format('c'), 320 + ); 321 + } 322 + } 323 + 324 + // src/Dto/MoneyOutput.php 325 + 326 + final readonly class MoneyOutput 327 + { 328 + public function __construct( 329 + public int $amount, 330 + public string $currency, 331 + public string $formatted, 332 + ) {} 333 + 334 + public static function fromValueObject(Money $money): self 335 + { 336 + return new self( 337 + amount: $money->getAmount(), 338 + currency: $money->getCurrency(), 339 + formatted: $money->format(), 340 + ); 341 + } 342 + } 343 + ``` 344 + 345 + ### API Platform Integration 346 + 347 + ```php 348 + <?php 349 + // src/Entity/Order.php 350 + 351 + use ApiPlatform\Metadata\ApiResource; 352 + use ApiPlatform\Metadata\Post; 353 + use App\Dto\CreateOrderInput; 354 + use App\Dto\OrderOutput; 355 + 356 + #[ApiResource( 357 + operations: [ 358 + new Post( 359 + input: CreateOrderInput::class, 360 + output: OrderOutput::class, 361 + processor: CreateOrderProcessor::class, 362 + ), 363 + ], 364 + )] 365 + class Order { /* ... */ } 366 + ``` 367 + 368 + ## Serializer Attributes 369 + 370 + Prefer attributes on the DTO over external mapping files. The attributes live in 371 + the `Symfony\Component\Serializer\Attribute\*` namespace (renamed from the old 372 + `Annotation\*` namespace in Symfony 7.0): 373 + 374 + ```php 375 + <?php 376 + 377 + namespace App\Dto; 378 + 379 + use Symfony\Component\Serializer\Attribute\Groups; 380 + use Symfony\Component\Serializer\Attribute\SerializedName; 381 + use Symfony\Component\Serializer\Attribute\Ignore; 382 + 383 + final readonly class OrderOutput 384 + { 385 + public function __construct( 386 + #[Groups(['order:read'])] 387 + public string $id, 388 + 389 + #[Groups(['order:read'])] 390 + #[SerializedName('customer_id')] 391 + public int $customerId, 392 + 393 + #[Ignore] 394 + public ?string $internalNote = null, 395 + ) {} 396 + } 397 + ``` 398 + 399 + ## Serializer Configuration (mapping files) 400 + 401 + ```yaml 402 + # config/packages/serializer.yaml 403 + framework: 404 + serializer: 405 + mapping: 406 + paths: 407 + - '%kernel.project_dir%/config/serializer' 408 + ``` 409 + 410 + ```yaml 411 + # config/serializer/Money.yaml 412 + App\Domain\ValueObject\Money: 413 + attributes: 414 + amount: 415 + groups: ['read'] 416 + currency: 417 + groups: ['read'] 418 + ``` 419 + 420 + ## Best Practices 421 + 422 + 1. **Value Objects are immutable**: Return new instances 423 + 2. **Validate in constructor**: Fail fast 424 + 3. **Use readonly**: `readonly` properties (PHP 8.1+) or whole `readonly class` (PHP 8.2+) 425 + 4. **Equality by value**: Implement equals() method 426 + 5. **DTOs are simple**: No behavior, just data 427 + 6. **Separate Input/Output**: Different validation needs 428 + 7. **Use Embeddables**: Store VOs in database naturally 429 + 430 + 431 + ## Skill Operating Checklist 432 + 433 + ### Design checklist 434 + - Confirm operation boundaries and invariants first. 435 + - Minimize scope while preserving contract correctness. 436 + - Test both happy path and negative path behavior. 437 + 438 + ### Validation commands 439 + - rg --files 440 + - composer validate 441 + - ./vendor/bin/phpstan analyse 442 + 443 + ### Failure modes to test 444 + - Invalid payload or forbidden actor. 445 + - Boundary values / not-found cases. 446 + - Retry or partial-failure behavior for async flows. 447 +
+39
.agents/skills/symfony-writing-plans/SKILL.md
··· 1 + --- 2 + 3 + name: symfony-writing-plans 4 + allowed-tools: 5 + - Read 6 + - Glob 7 + - Grep 8 + description: Create structured implementation plans for Symfony features with clear steps, dependencies, and acceptance criteria 9 + --- 10 + 11 + # Writing Plans (Symfony) 12 + 13 + ## Use when 14 + - Refining architecture/workflows/context handling in Symfony projects. 15 + - Planning and executing medium/complex changes safely. 16 + 17 + ## Default workflow 18 + 1. Establish current boundaries, constraints, and coupling points. 19 + 2. Propose smallest coherent architectural adjustment. 20 + 3. Execute in checkpoints with validation at each stage. 21 + 4. Summarize tradeoffs and follow-up backlog. 22 + 23 + ## Guardrails 24 + - Use existing project patterns by default. 25 + - Avoid broad refactors without explicit need. 26 + - Keep decision log clear and auditable. 27 + 28 + ## Progressive disclosure 29 + - Use this file for execution posture and risk controls. 30 + - Open references when deep implementation details are needed. 31 + 32 + ## Output contract 33 + - Architecture/workflow changes. 34 + - Checkpoint validation outcomes. 35 + - Residual risks and next steps. 36 + 37 + ## References 38 + - `reference.md` 39 + - `docs/complexity-tiers.md`
+227
.agents/skills/symfony-writing-plans/reference.md
··· 1 + # Reference 2 + 3 + # Writing Implementation Plans 4 + 5 + Transform brainstorming results into actionable implementation plans. 6 + 7 + ## Plan Structure 8 + 9 + ### 1. Overview 10 + 11 + ```markdown 12 + # Implementation Plan: [Feature Name] 13 + 14 + ## Summary 15 + [1-2 sentence description of what we're building] 16 + 17 + ## Scope 18 + - IN: [What's included] 19 + - OUT: [What's explicitly excluded] 20 + 21 + ## Dependencies 22 + - [Required packages] 23 + - [Existing services/entities to modify] 24 + - [External services] 25 + ``` 26 + 27 + ### 2. Technical Design 28 + 29 + ```markdown 30 + ## Entities 31 + 32 + ### New Entity: Order 33 + ```php 34 + #[ORM\Entity] 35 + class Order 36 + { 37 + #[ORM\Id] 38 + #[ORM\Column(type: 'uuid')] 39 + private Uuid $id; 40 + 41 + #[ORM\ManyToOne(targetEntity: User::class)] 42 + private User $customer; 43 + 44 + #[ORM\Column(type: 'string', enumType: OrderStatus::class)] 45 + private OrderStatus $status; 46 + 47 + #[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order')] 48 + private Collection $items; 49 + } 50 + ``` 51 + 52 + ### Modified Entity: User 53 + - Add `orders` OneToMany relation 54 + ``` 55 + 56 + ### 3. Services & Handlers 57 + 58 + ```markdown 59 + ## Services 60 + 61 + ### OrderService 62 + - `createOrder(User $user, array $items): Order` 63 + - `calculateTotal(Order $order): Money` 64 + - `validateStock(Order $order): bool` 65 + 66 + ### Message Handlers 67 + 68 + ### ProcessOrderHandler 69 + - Triggered by: `ProcessOrder` message 70 + - Actions: 71 + 1. Validate stock 72 + 2. Reserve items 73 + 3. Dispatch `OrderProcessed` event 74 + ``` 75 + 76 + ### 4. API Endpoints 77 + 78 + ```markdown 79 + ## API Endpoints 80 + 81 + ### POST /api/orders 82 + - Request: `{items: [{productId, quantity}]}` 83 + - Response: `201 Created` with Order resource 84 + - Security: `ROLE_USER` 85 + 86 + ### GET /api/orders/{id} 87 + - Response: Order resource 88 + - Security: Owner or `ROLE_ADMIN` 89 + ``` 90 + 91 + ### 5. Implementation Steps 92 + 93 + ```markdown 94 + ## Implementation Steps 95 + 96 + ### Phase 1: Foundation 97 + 1. [ ] Create `OrderStatus` enum 98 + 2. [ ] Create `Order` entity with migrations 99 + 3. [ ] Create `OrderItem` entity with migrations 100 + 4. [ ] Add relation to `User` entity 101 + 5. [ ] Run migrations 102 + 103 + ### Phase 2: Business Logic 104 + 6. [ ] Create `OrderService` 105 + 7. [ ] Create `ProcessOrder` message 106 + 8. [ ] Create `ProcessOrderHandler` 107 + 9. [ ] Write unit tests for service 108 + 109 + ### Phase 3: API 110 + 10. [ ] Configure API Platform resource 111 + 11. [ ] Add security voters 112 + 12. [ ] Write functional tests 113 + 114 + ### Phase 4: Integration 115 + 13. [ ] Connect to payment service 116 + 14. [ ] Add email notifications 117 + 15. [ ] End-to-end testing 118 + ``` 119 + 120 + ### 6. Acceptance Criteria 121 + 122 + ```markdown 123 + ## Acceptance Criteria 124 + 125 + - [ ] User can create order with multiple items 126 + - [ ] Order total is calculated correctly 127 + - [ ] Stock is validated before processing 128 + - [ ] Only owner can view their orders 129 + - [ ] Admin can view all orders 130 + - [ ] Order status transitions are validated 131 + - [ ] Email sent on order confirmation 132 + ``` 133 + 134 + ### 7. Risks & Mitigations 135 + 136 + ```markdown 137 + ## Risks 138 + 139 + | Risk | Probability | Impact | Mitigation | 140 + |------|-------------|--------|------------| 141 + | Stock race condition | Medium | High | Use database locking | 142 + | Payment failure | Low | High | Implement retry with Messenger | 143 + | Performance on large orders | Low | Medium | Batch processing | 144 + ``` 145 + 146 + ## Plan Templates 147 + 148 + ### CRUD Feature 149 + 150 + ```markdown 151 + # Plan: [Entity] CRUD 152 + 153 + ## Entities 154 + - [Entity] with fields: [list] 155 + 156 + ## Steps 157 + 1. [ ] Create entity + migration 158 + 2. [ ] Create Foundry factory 159 + 3. [ ] Configure API Platform resource 160 + 4. [ ] Add validation constraints 161 + 5. [ ] Add security voter 162 + 6. [ ] Write tests 163 + ``` 164 + 165 + ### Background Job Feature 166 + 167 + ```markdown 168 + # Plan: [Job Name] 169 + 170 + ## Messages 171 + - [MessageName]: [trigger description] 172 + 173 + ## Handlers 174 + - [HandlerName]: [processing steps] 175 + 176 + ## Steps 177 + 1. [ ] Create message class 178 + 2. [ ] Create handler 179 + 3. [ ] Configure routing in messenger.yaml 180 + 4. [ ] Add retry strategy 181 + 5. [ ] Write tests with in-memory transport 182 + ``` 183 + 184 + ### Integration Feature 185 + 186 + ```markdown 187 + # Plan: [Service] Integration 188 + 189 + ## External Service 190 + - API: [URL/docs] 191 + - Auth: [method] 192 + - Rate limits: [limits] 193 + 194 + ## Steps 195 + 1. [ ] Create HTTP client service 196 + 2. [ ] Create DTOs for requests/responses 197 + 3. [ ] Implement retry logic 198 + 4. [ ] Add circuit breaker 199 + 5. [ ] Write tests with mocked responses 200 + ``` 201 + 202 + ## Best Practices 203 + 204 + 1. **Atomic steps**: Each step should be completable independently 205 + 2. **Test-first**: Include test steps before implementation 206 + 3. **Dependencies clear**: Note which steps depend on others 207 + 4. **Reviewable**: Plan should be reviewable by team 208 + 5. **Time-boxed**: Add rough complexity indicators (S/M/L) 209 + 210 + 211 + ## Skill Operating Checklist 212 + 213 + ### Design checklist 214 + - Confirm operation boundaries and invariants first. 215 + - Minimize scope while preserving contract correctness. 216 + - Test both happy path and negative path behavior. 217 + 218 + ### Validation commands 219 + - rg --files 220 + - composer validate 221 + - ./vendor/bin/phpstan analyse 222 + 223 + ### Failure modes to test 224 + - Invalid payload or forbidden actor. 225 + - Boundary values / not-found cases. 226 + - Retry or partial-failure behavior for async flows. 227 +
+56
.agents/skills/telemetry-capture/SKILL.md
··· 1 + --- 2 + name: telemetry-capture 3 + description: "Capture and inspect the raw OTel telemetry (spans, logs, metrics) a dagger CLI invocation emits, using the hack/otlpdump tool. Use when debugging telemetry emission, verifying spans/log records/attributes reach the exporter, checking streaming progress records (dagger.io/progress.*), or diagnosing why something doesn't render in the TUI or Dagger Cloud. Triggers on: capture telemetry, OTLP, otlpdump, debug spans, debug telemetry, progress records, telemetry not showing up." 4 + --- 5 + 6 + # Telemetry Capture 7 + 8 + `hack/otlpdump` is a tiny OTLP receiver that dumps every span, log record, and metric a dagger CLI invocation exports to a JSONL file for inspection with `jq`. Use it to see ground truth when the TUI (or Cloud) isn't showing what you expect: the question "was it emitted?" separates emitter bugs from rendering bugs. 9 + 10 + ## Capture 11 + 12 + **Step 1: Start the receiver** (run in background; it serves until killed): 13 + 14 + ```bash 15 + go run ./hack/otlpdump -addr 127.0.0.1:43180 -out /tmp/telemetry.jsonl 16 + ``` 17 + 18 + **Step 2: Run dagger pointed at it.** The generic endpoint var alone only delivers traces — logs and metrics need their explicit vars too: 19 + 20 + ```bash 21 + env OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:43180 \ 22 + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://127.0.0.1:43180/v1/logs \ 23 + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://127.0.0.1:43180/v1/metrics \ 24 + OTEL_EXPORTER_OTLP_TRACES_LIVE=1 \ 25 + ./hack/with-dev ./bin/dagger ... 26 + ``` 27 + 28 + `OTEL_EXPORTER_OTLP_TRACES_LIVE=1` makes spans export on start as well as end, so you see in-flight state, matching what live consumers receive. 29 + 30 + ## Inspect 31 + 32 + Each JSONL line has a `.kind` of `span`, `log`, or `metric`. Useful queries: 33 + 34 + ```bash 35 + # all span names 36 + jq -r 'select(.kind=="span") | .name' /tmp/telemetry.jsonl | sort -u 37 + 38 + # spans by name prefix, with ids (first 8 hex chars locate children/logs) 39 + jq -r 'select(.kind=="span") | select(.name|startswith("pulling")) | [.spanId[0:8], .name] | @tsv' /tmp/telemetry.jsonl 40 + 41 + # streaming progress records (the dagger.io/progress.* convention, 42 + # engine/telemetryattrs/attrs.go), grouped per span 43 + jq -r 'select(.kind=="log" and (.attrs["dagger.io/progress.item"]//empty)!="") | 44 + [.spanId[0:8], .attrs["dagger.io/progress.item"], .attrs["dagger.io/progress.current"], .attrs["dagger.io/progress.total"]] | @tsv' /tmp/telemetry.jsonl 45 + 46 + # log records attached to a specific span 47 + jq -r 'select(.kind=="log" and .spanId[0:8]=="<prefix>") | .body' /tmp/telemetry.jsonl 48 + ``` 49 + 50 + 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). 51 + 52 + ## Gotchas 53 + 54 + - 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). 55 + - 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). 56 + - 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.
+159
.agents/skills/tui-qa/SKILL.md
··· 1 + --- 2 + name: tui-qa 3 + 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. 4 + --- 5 + 6 + # TUI QA 7 + 8 + Use this skill for operator-style QA of terminal applications. 9 + 10 + 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. 11 + 12 + ## Backend 13 + 14 + This skill requires `asciinema`. 15 + 16 + Artifact meanings: 17 + 18 + - `.cast`: source of truth for timing and terminal behavior 19 + - `final.txt`: final rendered screen as plain text 20 + - `snapshots/*.txt`: rendered screen at specific timestamps 21 + - `.raw`: optional low-level byte stream for terminal-control debugging 22 + - `report.json`: machine-readable timings, findings, and artifact paths 23 + - `report.md`: short human-readable QA summary 24 + 25 + 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`. 26 + 27 + ## Workflow 28 + 29 + 1. State the operator expectation before recording. 30 + 31 + - expected first visible response 32 + - expected milestones 33 + - expected final screen or files written 34 + - acceptable delays 35 + 36 + 1. Record and analyze in one step when possible: 37 + 38 + ```bash 39 + python3 skills/tui-qa/scripts/tui_qa.py run \ 40 + --name workspace-install \ 41 + --command 'dagger install github.com/dagger/dagger/modules/wolfi@main' \ 42 + --workdir /path/to/repo \ 43 + --snapshot-at 0.5 \ 44 + --snapshot-at 2 \ 45 + --milestone 'Initialized workspace' \ 46 + --milestone 'Installed module' 47 + ``` 48 + 49 + 1. Review: 50 + 51 + - `report.md` for the summary 52 + - `snapshots/*.txt` for point-in-time screens 53 + - `session.cast` with `asciinema play` when timing or redraw behavior matters 54 + 55 + 1. Classify issues: 56 + 57 + - content bug 58 + - timing bug 59 + - hard hang: no output for too long 60 + - semantic hang: output continues but no meaningful milestone progress 61 + - polish issue 62 + 63 + ## Commands 64 + 65 + `record` 66 + 67 + - Use for manual or interactive sessions. 68 + - Stores `session.cast` and `meta.json`. 69 + - If the current shell is not attached to a tty, the helper automatically records in headless mode. 70 + 71 + ```bash 72 + python3 skills/tui-qa/scripts/tui_qa.py record \ 73 + --name interactive-playground 74 + ``` 75 + 76 + ```bash 77 + python3 skills/tui-qa/scripts/tui_qa.py record \ 78 + --name module-init \ 79 + --command 'dagger sdk install go && dagger module init go demo' \ 80 + --workdir /tmp/playground 81 + ``` 82 + 83 + `snapshot` 84 + 85 + - Produces a plain-text screen at a chosen timestamp. 86 + - The helper truncates the cast at the last full event at or before `--at`, then converts that truncated cast to text. 87 + 88 + ```bash 89 + python3 skills/tui-qa/scripts/tui_qa.py snapshot \ 90 + .qa/tui/module-init-20260404-120000/session.cast \ 91 + --at 1.25 92 + ``` 93 + 94 + `analyze` 95 + 96 + - Parses the event stream. 97 + - Generates `final.txt`, snapshots, `report.json`, and `report.md`. 98 + - Detects startup delay and hard hangs from periods with no output. 99 + - If milestones are supplied, also reports semantic-hang candidates. 100 + 101 + Default thresholds: 102 + 103 + - startup warning: 2s 104 + - startup failure: 5s 105 + - idle warning: 10s 106 + - idle failure: 30s 107 + - semantic-hang warning: 120s 108 + 109 + `run` 110 + 111 + - Runs `record`, then `analyze`. 112 + - This is the default path for non-interactive QA. 113 + 114 + ## Guidance 115 + 116 + - Treat `.cast` as the authority for timing. 117 + - Treat `final.txt` as the authority for final rendered text. 118 + - When low-level terminal control bytes matter, export `.raw` directly with `asciinema convert -f raw session.cast session.raw`. 119 + - Use milestones for higher-level progress checks. Without milestones, semantic-hang analysis is intentionally reported as not evaluated. 120 + - Input events do not count as progress. 121 + - 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. 122 + - For commands that write files, inspect the filesystem after the run in addition to the terminal artifacts. 123 + 124 + ## Examples 125 + 126 + Batch CLI: 127 + 128 + ```bash 129 + python3 skills/tui-qa/scripts/tui_qa.py run \ 130 + --name help-output \ 131 + --command 'dagger --help' \ 132 + --snapshot-at 0.1 133 + ``` 134 + 135 + Progress UI: 136 + 137 + ```bash 138 + python3 skills/tui-qa/scripts/tui_qa.py run \ 139 + --name generate \ 140 + --command 'dagger generate' \ 141 + --milestone 'Generated' \ 142 + --milestone 'done' 143 + ``` 144 + 145 + Manual recording, then analysis: 146 + 147 + ```bash 148 + python3 skills/tui-qa/scripts/tui_qa.py record --name manual-flow 149 + python3 skills/tui-qa/scripts/tui_qa.py analyze .qa/tui/manual-flow-*/session.cast 150 + ``` 151 + 152 + Specific screen sample: 153 + 154 + ```bash 155 + python3 skills/tui-qa/scripts/tui_qa.py snapshot \ 156 + .qa/tui/generate-20260404-120000/session.cast \ 157 + --at 12.3 \ 158 + --label before-finish 159 + ```
+4
.agents/skills/tui-qa/agents/openai.yaml
··· 1 + interface: 2 + display_name: "TUI QA" 3 + short_description: "QA terminal output, timing, and hangs" 4 + default_prompt: "Use $tui-qa to record a terminal session, capture timestamped screen snapshots, and report output, delay, and hang issues."
+954
.agents/skills/tui-qa/scripts/tui_qa.py
··· 1 + #!/usr/bin/env python3 2 + 3 + from __future__ import annotations 4 + 5 + import argparse 6 + import json 7 + import os 8 + import re 9 + import shutil 10 + import subprocess 11 + import sys 12 + import tempfile 13 + from dataclasses import dataclass 14 + from datetime import datetime 15 + from pathlib import Path 16 + from typing import Any 17 + 18 + 19 + DEFAULT_STARTUP_WARN = 2.0 20 + DEFAULT_STARTUP_FAIL = 5.0 21 + DEFAULT_IDLE_WARN = 10.0 22 + DEFAULT_IDLE_FAIL = 30.0 23 + DEFAULT_SEMANTIC_WARN = 120.0 24 + SNAPSHOT_EPSILON = 0.000001 25 + 26 + ANSI_ESCAPE_RE = re.compile( 27 + r""" 28 + \x1B 29 + (?: 30 + \[[0-?]*[ -/]*[@-~] 31 + |\][^\x07]*(?:\x07|\x1B\\) 32 + |[@-Z\\-_] 33 + ) 34 + """, 35 + re.VERBOSE, 36 + ) 37 + 38 + 39 + @dataclass 40 + class CastEvent: 41 + delta: float 42 + kind: str 43 + data: Any 44 + raw: list[Any] 45 + time: float 46 + 47 + 48 + def main() -> int: 49 + parser = build_parser() 50 + args = parser.parse_args() 51 + try: 52 + return args.func(args) 53 + except QaError as exc: 54 + print(f"error: {exc}", file=sys.stderr) 55 + return 1 56 + 57 + 58 + class QaError(RuntimeError): 59 + pass 60 + 61 + 62 + def build_parser() -> argparse.ArgumentParser: 63 + parser = argparse.ArgumentParser( 64 + description="Record and analyze terminal QA sessions with asciinema.", 65 + ) 66 + subparsers = parser.add_subparsers(dest="command", required=True) 67 + 68 + record = subparsers.add_parser("record", help="Record a terminal session to a cast file.") 69 + add_common_record_args(record) 70 + record.set_defaults(func=cmd_record) 71 + 72 + snapshot = subparsers.add_parser( 73 + "snapshot", 74 + help="Render the visible terminal screen at a given timestamp.", 75 + ) 76 + snapshot.add_argument("cast", type=Path, help="Path to an asciinema cast file.") 77 + snapshot.add_argument("--at", type=float, required=True, help="Timestamp in seconds.") 78 + snapshot.add_argument("--output", type=Path, help="Output path for the text snapshot.") 79 + snapshot.add_argument("--label", help="Optional label for the snapshot file name.") 80 + snapshot.set_defaults(func=cmd_snapshot) 81 + 82 + analyze = subparsers.add_parser("analyze", help="Analyze a recorded cast.") 83 + analyze.add_argument("cast", type=Path, help="Path to an asciinema cast file.") 84 + analyze.add_argument( 85 + "--snapshot-at", 86 + action="append", 87 + default=[], 88 + type=float, 89 + help="Additional timestamp to snapshot. Repeat as needed.", 90 + ) 91 + analyze.add_argument( 92 + "--milestone", 93 + action="append", 94 + default=[], 95 + help="Regex describing a meaningful progress milestone. Repeat as needed.", 96 + ) 97 + analyze.add_argument( 98 + "--startup-warn", 99 + type=float, 100 + default=DEFAULT_STARTUP_WARN, 101 + help="Warn if first output takes at least this many seconds.", 102 + ) 103 + analyze.add_argument( 104 + "--startup-fail", 105 + type=float, 106 + default=DEFAULT_STARTUP_FAIL, 107 + help="Fail if first output takes at least this many seconds.", 108 + ) 109 + analyze.add_argument( 110 + "--idle-warn", 111 + type=float, 112 + default=DEFAULT_IDLE_WARN, 113 + help="Warn on no-output gaps of at least this many seconds.", 114 + ) 115 + analyze.add_argument( 116 + "--idle-fail", 117 + type=float, 118 + default=DEFAULT_IDLE_FAIL, 119 + help="Fail on no-output gaps of at least this many seconds.", 120 + ) 121 + analyze.add_argument( 122 + "--semantic-warn", 123 + type=float, 124 + default=DEFAULT_SEMANTIC_WARN, 125 + help="Warn when output continues without milestone progress for this long.", 126 + ) 127 + analyze.add_argument( 128 + "--output-dir", 129 + type=Path, 130 + help="Directory for generated analysis artifacts. Defaults to the cast directory.", 131 + ) 132 + analyze.set_defaults(func=cmd_analyze) 133 + 134 + run = subparsers.add_parser( 135 + "run", 136 + help="Record a terminal session and analyze it in one step.", 137 + ) 138 + add_common_record_args(run) 139 + run.add_argument( 140 + "--snapshot-at", 141 + action="append", 142 + default=[], 143 + type=float, 144 + help="Additional timestamp to snapshot. Repeat as needed.", 145 + ) 146 + run.add_argument( 147 + "--milestone", 148 + action="append", 149 + default=[], 150 + help="Regex describing a meaningful progress milestone. Repeat as needed.", 151 + ) 152 + run.add_argument("--startup-warn", type=float, default=DEFAULT_STARTUP_WARN) 153 + run.add_argument("--startup-fail", type=float, default=DEFAULT_STARTUP_FAIL) 154 + run.add_argument("--idle-warn", type=float, default=DEFAULT_IDLE_WARN) 155 + run.add_argument("--idle-fail", type=float, default=DEFAULT_IDLE_FAIL) 156 + run.add_argument("--semantic-warn", type=float, default=DEFAULT_SEMANTIC_WARN) 157 + run.set_defaults(func=cmd_run) 158 + 159 + return parser 160 + 161 + 162 + def add_common_record_args(parser: argparse.ArgumentParser) -> None: 163 + parser.add_argument("--name", required=True, help="Artifact name prefix.") 164 + parser.add_argument( 165 + "--command", 166 + help="Command to run inside the recording. Omit for an interactive session.", 167 + ) 168 + parser.add_argument( 169 + "--artifact-dir", 170 + type=Path, 171 + help="Artifact directory. Defaults to .qa/tui/<name>-<timestamp>.", 172 + ) 173 + parser.add_argument( 174 + "--capture-input", 175 + action="store_true", 176 + help="Record keyboard input as well as output.", 177 + ) 178 + parser.add_argument( 179 + "--workdir", 180 + type=Path, 181 + help="Working directory for the recorded command.", 182 + ) 183 + parser.add_argument( 184 + "--window-size", 185 + help="Override terminal size as COLSxROWS.", 186 + ) 187 + parser.add_argument( 188 + "--idle-time-limit", 189 + type=float, 190 + help="Optional playback idle-time cap stored in the cast metadata.", 191 + ) 192 + parser.add_argument( 193 + "--headless", 194 + action="store_true", 195 + help="Force headless recording mode.", 196 + ) 197 + 198 + 199 + def cmd_record(args: argparse.Namespace) -> int: 200 + artifacts = ensure_artifact_dir(args.name, args.artifact_dir) 201 + result = record_session( 202 + artifact_dir=artifacts, 203 + name=args.name, 204 + command=args.command, 205 + workdir=args.workdir, 206 + capture_input=args.capture_input, 207 + window_size=args.window_size, 208 + idle_time_limit=args.idle_time_limit, 209 + headless=args.headless, 210 + ) 211 + print(result["cast"]) 212 + if result.get("command_exit_code") not in (None, 0): 213 + print( 214 + f"recorded command exit code: {result['command_exit_code']}", 215 + file=sys.stderr, 216 + ) 217 + return 0 218 + 219 + 220 + def cmd_snapshot(args: argparse.Namespace) -> int: 221 + cast_path = args.cast.resolve() 222 + if not cast_path.is_file(): 223 + raise QaError(f"cast not found: {cast_path}") 224 + output_path = args.output 225 + if output_path is None: 226 + label = sanitize_label(args.label or f"at-{format_seconds(args.at)}") 227 + output_path = cast_path.parent / "snapshots" / f"{label}.txt" 228 + render_snapshot(cast_path, args.at, output_path) 229 + print(output_path.resolve()) 230 + return 0 231 + 232 + 233 + def cmd_analyze(args: argparse.Namespace) -> int: 234 + report = analyze_cast( 235 + cast_path=args.cast.resolve(), 236 + output_dir=(args.output_dir.resolve() if args.output_dir else None), 237 + snapshot_times=list(args.snapshot_at), 238 + milestone_patterns=list(args.milestone), 239 + startup_warn=args.startup_warn, 240 + startup_fail=args.startup_fail, 241 + idle_warn=args.idle_warn, 242 + idle_fail=args.idle_fail, 243 + semantic_warn=args.semantic_warn, 244 + ) 245 + print(report["report_md"]) 246 + return 0 247 + 248 + 249 + def cmd_run(args: argparse.Namespace) -> int: 250 + artifacts = ensure_artifact_dir(args.name, args.artifact_dir) 251 + record_session( 252 + artifact_dir=artifacts, 253 + name=args.name, 254 + command=args.command, 255 + workdir=args.workdir, 256 + capture_input=args.capture_input, 257 + window_size=args.window_size, 258 + idle_time_limit=args.idle_time_limit, 259 + headless=args.headless, 260 + ) 261 + report = analyze_cast( 262 + cast_path=artifacts / "session.cast", 263 + output_dir=artifacts, 264 + snapshot_times=list(args.snapshot_at), 265 + milestone_patterns=list(args.milestone), 266 + startup_warn=args.startup_warn, 267 + startup_fail=args.startup_fail, 268 + idle_warn=args.idle_warn, 269 + idle_fail=args.idle_fail, 270 + semantic_warn=args.semantic_warn, 271 + ) 272 + summary = report["summary"] 273 + print(f"artifacts: {artifacts}") 274 + print(f"verdict: {summary['verdict']}") 275 + print(f"report: {report['report_md']}") 276 + return 0 277 + 278 + 279 + def ensure_artifact_dir(name: str, artifact_dir: Path | None) -> Path: 280 + if artifact_dir is None: 281 + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") 282 + artifact_dir = Path(".qa") / "tui" / f"{sanitize_label(name)}-{stamp}" 283 + artifact_dir = artifact_dir.resolve() 284 + artifact_dir.mkdir(parents=True, exist_ok=True) 285 + (artifact_dir / "snapshots").mkdir(parents=True, exist_ok=True) 286 + return artifact_dir 287 + 288 + 289 + def record_session( 290 + *, 291 + artifact_dir: Path, 292 + name: str, 293 + command: str | None, 294 + workdir: Path | None, 295 + capture_input: bool, 296 + window_size: str | None, 297 + idle_time_limit: float | None, 298 + headless: bool, 299 + ) -> dict[str, Any]: 300 + asciinema = shutil.which("asciinema") 301 + if not asciinema: 302 + raise QaError("asciinema is required but was not found in PATH") 303 + 304 + cast_path = artifact_dir / "session.cast" 305 + meta_path = artifact_dir / "meta.json" 306 + workdir_path = workdir.resolve() if workdir else None 307 + should_headless = headless or not (sys.stdin.isatty() and sys.stdout.isatty()) 308 + effective_window_size = window_size or infer_window_size() 309 + 310 + cmd = [asciinema, "record", "--quiet", "--overwrite", "--return"] 311 + if command: 312 + cmd.extend(["--command", command]) 313 + if capture_input: 314 + cmd.append("--capture-input") 315 + if idle_time_limit is not None: 316 + cmd.extend(["--idle-time-limit", str(idle_time_limit)]) 317 + if should_headless: 318 + cmd.append("--headless") 319 + if effective_window_size: 320 + cmd.extend(["--window-size", effective_window_size]) 321 + cmd.extend(["--title", name, str(cast_path)]) 322 + 323 + started_at = datetime.now().astimezone().isoformat() 324 + result = subprocess.run( 325 + cmd, 326 + cwd=str(workdir_path) if workdir_path else None, 327 + text=True, 328 + capture_output=True, 329 + check=False, 330 + ) 331 + finished_at = datetime.now().astimezone().isoformat() 332 + 333 + if not cast_path.exists(): 334 + stderr = result.stderr.strip() 335 + if stderr: 336 + raise QaError(stderr) 337 + raise QaError("recording failed before a cast file was produced") 338 + 339 + meta = { 340 + "name": name, 341 + "command": command, 342 + "workdir": str(workdir_path) if workdir_path else None, 343 + "artifact_dir": str(artifact_dir), 344 + "cast": str(cast_path), 345 + "capture_input": capture_input, 346 + "headless": should_headless, 347 + "window_size": effective_window_size, 348 + "idle_time_limit": idle_time_limit, 349 + "started_at": started_at, 350 + "finished_at": finished_at, 351 + "command_exit_code": result.returncode, 352 + "stderr": result.stderr, 353 + } 354 + meta_path.write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n") 355 + return meta 356 + 357 + 358 + def analyze_cast( 359 + *, 360 + cast_path: Path, 361 + output_dir: Path | None, 362 + snapshot_times: list[float], 363 + milestone_patterns: list[str], 364 + startup_warn: float, 365 + startup_fail: float, 366 + idle_warn: float, 367 + idle_fail: float, 368 + semantic_warn: float, 369 + ) -> dict[str, Any]: 370 + if not cast_path.is_file(): 371 + raise QaError(f"cast not found: {cast_path}") 372 + output_dir = (output_dir or cast_path.parent).resolve() 373 + output_dir.mkdir(parents=True, exist_ok=True) 374 + snapshots_dir = output_dir / "snapshots" 375 + snapshots_dir.mkdir(parents=True, exist_ok=True) 376 + 377 + header, events = load_cast(cast_path) 378 + total_duration = events[-1].time if events else 0.0 379 + output_events = [event for event in events if event.kind == "o"] 380 + output_times = [event.time for event in output_events] 381 + first_output_time = output_times[0] if output_times else None 382 + last_output_time = output_times[-1] if output_times else None 383 + exit_event = next((event for event in events if event.kind == "x"), None) 384 + exit_time = exit_event.time if exit_event else total_duration 385 + 386 + final_txt = output_dir / "final.txt" 387 + convert_cast_to_text(cast_path, final_txt) 388 + 389 + thresholds = { 390 + "startup_warn": startup_warn, 391 + "startup_fail": startup_fail, 392 + "idle_warn": idle_warn, 393 + "idle_fail": idle_fail, 394 + "semantic_warn": semantic_warn, 395 + } 396 + 397 + findings: list[dict[str, Any]] = [] 398 + startup_duration = first_output_time if first_output_time is not None else total_duration 399 + startup_severity = severity_for_duration(startup_duration, startup_warn, startup_fail) 400 + if startup_severity != "pass": 401 + findings.append( 402 + finding( 403 + startup_severity, 404 + "startup_delay", 405 + f"First output took {format_seconds(startup_duration)}.", 406 + ) 407 + ) 408 + 409 + gaps = build_no_output_gaps(output_times, total_duration) 410 + idle_gaps = [] 411 + max_no_output_gap = 0.0 412 + longest_gap = None 413 + for gap in gaps: 414 + max_no_output_gap = max(max_no_output_gap, gap["duration"]) 415 + if longest_gap is None or gap["duration"] > longest_gap["duration"]: 416 + longest_gap = gap 417 + if gap["kind"] == "startup": 418 + continue 419 + gap_severity = severity_for_duration(gap["duration"], idle_warn, idle_fail) 420 + gap["severity"] = gap_severity 421 + if gap_severity != "pass": 422 + findings.append( 423 + finding( 424 + gap_severity, 425 + "idle_gap", 426 + ( 427 + f"No output for {format_seconds(gap['duration'])} " 428 + f"between {format_seconds(gap['start'])} and {format_seconds(gap['end'])}." 429 + ), 430 + ) 431 + ) 432 + idle_gaps.append(gap) 433 + 434 + milestones = evaluate_milestones(output_events, milestone_patterns) 435 + semantic = evaluate_semantic_hangs( 436 + output_times=output_times, 437 + total_duration=total_duration, 438 + milestones=milestones, 439 + semantic_warn=semantic_warn, 440 + ) 441 + for candidate in semantic["candidates"]: 442 + findings.append( 443 + finding( 444 + "warn", 445 + "semantic_hang", 446 + ( 447 + f"Output continued for {format_seconds(candidate['duration'])} " 448 + f"without a milestone between {format_seconds(candidate['start'])} " 449 + f"and {format_seconds(candidate['end'])}." 450 + ), 451 + ) 452 + ) 453 + 454 + meta = load_optional_json(cast_path.parent / "meta.json") 455 + if meta and meta.get("command_exit_code") not in (None, 0): 456 + findings.append( 457 + finding( 458 + "fail", 459 + "command_exit", 460 + f"Recorded command exited with status {meta['command_exit_code']}.", 461 + ) 462 + ) 463 + 464 + requested_snapshots = [ 465 + snapshot_spec(at, f"at-{format_seconds(at)}") for at in snapshot_times 466 + ] 467 + auto_snapshots = [] 468 + if first_output_time is not None: 469 + auto_snapshots.append(snapshot_spec(first_output_time, "first-output")) 470 + if longest_gap is not None and longest_gap["duration"] > 0: 471 + auto_snapshots.append(snapshot_spec(longest_gap["start"], "longest-idle-start")) 472 + if longest_gap["end"] > longest_gap["start"]: 473 + auto_snapshots.append( 474 + snapshot_spec( 475 + max(longest_gap["start"], longest_gap["end"] - SNAPSHOT_EPSILON), 476 + "longest-idle-end", 477 + ) 478 + ) 479 + auto_snapshots.append(snapshot_spec(total_duration, "final")) 480 + for index, candidate in enumerate(semantic["candidates"], start=1): 481 + auto_snapshots.append(snapshot_spec(candidate["start"], f"semantic-{index}-start")) 482 + auto_snapshots.append( 483 + snapshot_spec( 484 + max(candidate["start"], candidate["end"] - SNAPSHOT_EPSILON), 485 + f"semantic-{index}-end", 486 + ) 487 + ) 488 + 489 + snapshot_results = write_snapshots( 490 + cast_path=cast_path, 491 + snapshots_dir=snapshots_dir, 492 + final_txt=final_txt, 493 + specs=requested_snapshots + auto_snapshots, 494 + total_duration=total_duration, 495 + ) 496 + 497 + if longest_gap is not None: 498 + mark_visual_change( 499 + gap=longest_gap, 500 + snapshot_results=snapshot_results, 501 + start_label="longest-idle-start", 502 + end_label="longest-idle-end", 503 + ) 504 + for index, candidate in enumerate(semantic["candidates"], start=1): 505 + mark_visual_change( 506 + gap=candidate, 507 + snapshot_results=snapshot_results, 508 + start_label=f"semantic-{index}-start", 509 + end_label=f"semantic-{index}-end", 510 + ) 511 + 512 + summary = { 513 + "verdict": summarize_verdict(findings), 514 + "total_duration": total_duration, 515 + "time_to_first_output": first_output_time, 516 + "time_to_exit": exit_time, 517 + "output_event_count": len(output_events), 518 + "max_no_output_gap": max_no_output_gap, 519 + } 520 + 521 + report = { 522 + "cast": str(cast_path), 523 + "header": header, 524 + "meta": meta, 525 + "thresholds": thresholds, 526 + "summary": summary, 527 + "startup": { 528 + "duration": startup_duration, 529 + "severity": startup_severity, 530 + }, 531 + "idle_gaps": idle_gaps, 532 + "milestones": milestones, 533 + "semantic_hang": semantic, 534 + "snapshots": snapshot_results, 535 + "final_txt": str(final_txt), 536 + "findings": findings, 537 + "report_json": str((output_dir / "report.json").resolve()), 538 + "report_md": str((output_dir / "report.md").resolve()), 539 + } 540 + 541 + report_json_path = output_dir / "report.json" 542 + report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") 543 + report_md_path = output_dir / "report.md" 544 + report_md_path.write_text(render_markdown_report(report) + "\n") 545 + return report 546 + 547 + 548 + def build_no_output_gaps(output_times: list[float], total_duration: float) -> list[dict[str, Any]]: 549 + gaps = [] 550 + if not output_times: 551 + return [ 552 + { 553 + "kind": "startup", 554 + "start": 0.0, 555 + "end": total_duration, 556 + "duration": total_duration, 557 + } 558 + ] 559 + 560 + if output_times[0] > 0: 561 + gaps.append( 562 + { 563 + "kind": "startup", 564 + "start": 0.0, 565 + "end": output_times[0], 566 + "duration": output_times[0], 567 + } 568 + ) 569 + 570 + for start, end in zip(output_times, output_times[1:]): 571 + gaps.append( 572 + { 573 + "kind": "idle", 574 + "start": start, 575 + "end": end, 576 + "duration": end - start, 577 + } 578 + ) 579 + 580 + if total_duration > output_times[-1]: 581 + gaps.append( 582 + { 583 + "kind": "tail", 584 + "start": output_times[-1], 585 + "end": total_duration, 586 + "duration": total_duration - output_times[-1], 587 + } 588 + ) 589 + 590 + return gaps 591 + 592 + 593 + def evaluate_milestones( 594 + output_events: list[CastEvent], 595 + patterns: list[str], 596 + ) -> list[dict[str, Any]]: 597 + if not patterns: 598 + return [] 599 + 600 + compiled = [re.compile(pattern) for pattern in patterns] 601 + hits = [None] * len(compiled) 602 + transcript = "" 603 + 604 + for event in output_events: 605 + transcript += normalize_output_text(str(event.data)) 606 + for index, regex in enumerate(compiled): 607 + if hits[index] is None and regex.search(transcript): 608 + hits[index] = event.time 609 + 610 + result = [] 611 + for pattern, hit in zip(patterns, hits): 612 + result.append( 613 + { 614 + "pattern": pattern, 615 + "matched": hit is not None, 616 + "time": hit, 617 + } 618 + ) 619 + return result 620 + 621 + 622 + def evaluate_semantic_hangs( 623 + *, 624 + output_times: list[float], 625 + total_duration: float, 626 + milestones: list[dict[str, Any]], 627 + semantic_warn: float, 628 + ) -> dict[str, Any]: 629 + if not milestones: 630 + return { 631 + "status": "not_evaluated", 632 + "candidates": [], 633 + } 634 + 635 + milestone_times = sorted( 636 + milestone["time"] for milestone in milestones if milestone["matched"] and milestone["time"] is not None 637 + ) 638 + checkpoints = [] 639 + if output_times: 640 + checkpoints.append(output_times[0]) 641 + checkpoints.extend(milestone_times) 642 + checkpoints.append(total_duration) 643 + 644 + candidates = [] 645 + for start, end in zip(checkpoints, checkpoints[1:]): 646 + if end - start < semantic_warn: 647 + continue 648 + if not any(start < time <= end for time in output_times): 649 + continue 650 + candidates.append( 651 + { 652 + "start": start, 653 + "end": end, 654 + "duration": end - start, 655 + } 656 + ) 657 + 658 + return { 659 + "status": "warn" if candidates else "pass", 660 + "candidates": candidates, 661 + } 662 + 663 + 664 + def write_snapshots( 665 + *, 666 + cast_path: Path, 667 + snapshots_dir: Path, 668 + final_txt: Path, 669 + specs: list[dict[str, Any]], 670 + total_duration: float, 671 + ) -> list[dict[str, Any]]: 672 + results = [] 673 + seen_labels = set() 674 + for spec in specs: 675 + label = sanitize_label(spec["label"]) 676 + if label in seen_labels: 677 + continue 678 + seen_labels.add(label) 679 + if label == "final": 680 + path = final_txt 681 + else: 682 + path = snapshots_dir / f"{label}.txt" 683 + render_snapshot(cast_path, min(spec["time"], total_duration), path) 684 + results.append( 685 + { 686 + "label": label, 687 + "time": spec["time"], 688 + "path": str(path.resolve()), 689 + } 690 + ) 691 + return results 692 + 693 + 694 + def mark_visual_change( 695 + *, 696 + gap: dict[str, Any], 697 + snapshot_results: list[dict[str, Any]], 698 + start_label: str, 699 + end_label: str, 700 + ) -> None: 701 + start_snapshot = next((item for item in snapshot_results if item["label"] == start_label), None) 702 + end_snapshot = next((item for item in snapshot_results if item["label"] == end_label), None) 703 + if not start_snapshot or not end_snapshot: 704 + return 705 + start_text = Path(start_snapshot["path"]).read_text() 706 + end_text = Path(end_snapshot["path"]).read_text() 707 + gap["screen_changed"] = normalize_snapshot_text(start_text) != normalize_snapshot_text(end_text) 708 + gap["start_snapshot"] = start_snapshot["path"] 709 + gap["end_snapshot"] = end_snapshot["path"] 710 + 711 + 712 + def render_snapshot(cast_path: Path, at: float, output_path: Path) -> None: 713 + header, events = load_cast(cast_path) 714 + truncated = [] 715 + for event in events: 716 + if event.time <= at + SNAPSHOT_EPSILON: 717 + truncated.append(event.raw) 718 + else: 719 + break 720 + output_path.parent.mkdir(parents=True, exist_ok=True) 721 + with tempfile.NamedTemporaryFile("w", suffix=".cast", delete=False) as handle: 722 + temp_cast = Path(handle.name) 723 + handle.write(json.dumps(header) + "\n") 724 + for raw in truncated: 725 + handle.write(json.dumps(raw) + "\n") 726 + try: 727 + convert_cast_to_text(temp_cast, output_path) 728 + finally: 729 + temp_cast.unlink(missing_ok=True) 730 + 731 + 732 + def load_cast(path: Path) -> tuple[dict[str, Any], list[CastEvent]]: 733 + lines = path.read_text().splitlines() 734 + if not lines: 735 + raise QaError(f"cast is empty: {path}") 736 + try: 737 + header = json.loads(lines[0]) 738 + except json.JSONDecodeError as exc: 739 + raise QaError(f"invalid cast header in {path}: {exc}") from exc 740 + 741 + events = [] 742 + cumulative = 0.0 743 + for index, line in enumerate(lines[1:], start=2): 744 + if not line.strip(): 745 + continue 746 + try: 747 + raw = json.loads(line) 748 + except json.JSONDecodeError as exc: 749 + raise QaError(f"invalid cast event on line {index}: {exc}") from exc 750 + if not isinstance(raw, list) or len(raw) < 3: 751 + raise QaError(f"invalid cast event on line {index}: expected [delta, type, data]") 752 + delta = float(raw[0]) 753 + cumulative += delta 754 + events.append( 755 + CastEvent( 756 + delta=delta, 757 + kind=str(raw[1]), 758 + data=raw[2], 759 + raw=raw, 760 + time=cumulative, 761 + ) 762 + ) 763 + return header, events 764 + 765 + 766 + def convert_cast_to_text(input_path: Path, output_path: Path) -> None: 767 + asciinema = shutil.which("asciinema") 768 + if not asciinema: 769 + raise QaError("asciinema is required but was not found in PATH") 770 + output_path.parent.mkdir(parents=True, exist_ok=True) 771 + result = subprocess.run( 772 + [asciinema, "convert", "--overwrite", str(input_path), str(output_path)], 773 + text=True, 774 + capture_output=True, 775 + check=False, 776 + ) 777 + if result.returncode != 0: 778 + stderr = result.stderr.strip() 779 + if stderr: 780 + raise QaError(stderr) 781 + raise QaError(f"failed to convert cast to text: {input_path}") 782 + 783 + 784 + def finding(severity: str, kind: str, message: str) -> dict[str, str]: 785 + return { 786 + "severity": severity, 787 + "kind": kind, 788 + "message": message, 789 + } 790 + 791 + 792 + def severity_for_duration(duration: float, warn_after: float, fail_after: float) -> str: 793 + if duration >= fail_after: 794 + return "fail" 795 + if duration >= warn_after: 796 + return "warn" 797 + return "pass" 798 + 799 + 800 + def summarize_verdict(findings: list[dict[str, Any]]) -> str: 801 + severities = {item["severity"] for item in findings} 802 + if "fail" in severities: 803 + return "fail" 804 + if "warn" in severities: 805 + return "warn" 806 + return "pass" 807 + 808 + 809 + def snapshot_spec(at: float, label: str) -> dict[str, Any]: 810 + return { 811 + "time": max(at, 0.0), 812 + "label": label, 813 + } 814 + 815 + 816 + def load_optional_json(path: Path) -> dict[str, Any] | None: 817 + if not path.is_file(): 818 + return None 819 + try: 820 + return json.loads(path.read_text()) 821 + except json.JSONDecodeError: 822 + return None 823 + 824 + 825 + def normalize_output_text(data: str) -> str: 826 + data = ANSI_ESCAPE_RE.sub("", data) 827 + buffer: list[str] = [] 828 + for char in data: 829 + if char == "\r": 830 + buffer.append("\n") 831 + elif char == "\n" or char == "\t" or char >= " ": 832 + buffer.append(char) 833 + elif char == "\b": 834 + if buffer: 835 + buffer.pop() 836 + else: 837 + continue 838 + return "".join(buffer) 839 + 840 + 841 + def normalize_snapshot_text(text: str) -> str: 842 + return "\n".join(line.rstrip() for line in text.strip().splitlines()) 843 + 844 + 845 + def render_markdown_report(report: dict[str, Any]) -> str: 846 + lines = ["# TUI QA Report", ""] 847 + meta = report.get("meta") or {} 848 + command = meta.get("command") 849 + if command: 850 + lines.append(f"Command: `{command}`") 851 + lines.append("") 852 + lines.append(f"Cast: `{report['cast']}`") 853 + lines.append(f"Verdict: **{report['summary']['verdict']}**") 854 + lines.append("") 855 + 856 + lines.append("## Timings") 857 + lines.append("") 858 + lines.append(f"- Total duration: {format_seconds(report['summary']['total_duration'])}") 859 + lines.append( 860 + "- Time to first output: " 861 + + ( 862 + format_seconds(report['summary']['time_to_first_output']) 863 + if report['summary']['time_to_first_output'] is not None 864 + else "none" 865 + ) 866 + ) 867 + lines.append(f"- Time to exit: {format_seconds(report['summary']['time_to_exit'])}") 868 + lines.append(f"- Output events: {report['summary']['output_event_count']}") 869 + lines.append( 870 + f"- Max no-output gap: {format_seconds(report['summary']['max_no_output_gap'])}" 871 + ) 872 + lines.append("") 873 + 874 + lines.append("## Findings") 875 + lines.append("") 876 + if report["findings"]: 877 + for item in report["findings"]: 878 + lines.append(f"- {item['severity'].upper()}: {item['message']}") 879 + else: 880 + lines.append("- PASS: No warnings or failures.") 881 + lines.append("") 882 + 883 + if report["milestones"]: 884 + lines.append("## Milestones") 885 + lines.append("") 886 + for milestone in report["milestones"]: 887 + if milestone["matched"]: 888 + lines.append( 889 + f"- Matched `{milestone['pattern']}` at {format_seconds(milestone['time'])}" 890 + ) 891 + else: 892 + lines.append(f"- Missing `{milestone['pattern']}`") 893 + lines.append("") 894 + 895 + if report["semantic_hang"]["status"] == "not_evaluated": 896 + lines.append("## Semantic Hang") 897 + lines.append("") 898 + lines.append("- Not evaluated. Supply one or more `--milestone` regexes.") 899 + lines.append("") 900 + elif report["semantic_hang"]["candidates"]: 901 + lines.append("## Semantic Hang") 902 + lines.append("") 903 + for candidate in report["semantic_hang"]["candidates"]: 904 + screen_note = "" 905 + if "screen_changed" in candidate: 906 + screen_note = ( 907 + " screen changed." 908 + if candidate["screen_changed"] 909 + else " screen did not materially change." 910 + ) 911 + lines.append( 912 + "- " 913 + + ( 914 + f"{format_seconds(candidate['duration'])} without milestone progress " 915 + f"from {format_seconds(candidate['start'])} to {format_seconds(candidate['end'])};" 916 + f"{screen_note}" 917 + ) 918 + ) 919 + lines.append("") 920 + 921 + lines.append("## Artifacts") 922 + lines.append("") 923 + lines.append(f"- Final text: `{report['final_txt']}`") 924 + lines.append(f"- JSON report: `{report['report_json']}`") 925 + for snapshot in report["snapshots"]: 926 + lines.append( 927 + f"- Snapshot `{snapshot['label']}` at {format_seconds(snapshot['time'])}: " 928 + f"`{snapshot['path']}`" 929 + ) 930 + 931 + return "\n".join(lines).rstrip() 932 + 933 + 934 + def sanitize_label(label: str) -> str: 935 + return re.sub(r"[^A-Za-z0-9._-]+", "-", label).strip("-").lower() or "snapshot" 936 + 937 + 938 + def format_seconds(value: float | None) -> str: 939 + if value is None: 940 + return "none" 941 + return f"{value:.3f}s" 942 + 943 + 944 + def infer_window_size() -> str: 945 + if sys.stdout.isatty(): 946 + size = shutil.get_terminal_size(fallback=(120, 40)) 947 + return f"{size.columns}x{size.lines}" 948 + columns = os.environ.get("COLUMNS") or "120" 949 + lines = os.environ.get("LINES") or "40" 950 + return f"{columns}x{lines}" 951 + 952 + 953 + if __name__ == "__main__": 954 + sys.exit(main())
+233
skills-lock.json
··· 1 1 { 2 2 "version": 1, 3 3 "skills": { 4 + "dagger-chores": { 5 + "source": "dagger/dagger", 6 + "ref": "main", 7 + "sourceType": "github", 8 + "skillPath": "skills/dagger-chores/SKILL.md", 9 + "computedHash": "4fd04ea3490ae1ebee94e2d627640d90e2d23c3f3341ae31bf818a1f4c8a3867" 10 + }, 11 + "dagger-design-proposals": { 12 + "source": "dagger/dagger", 13 + "ref": "main", 14 + "sourceType": "github", 15 + "skillPath": "skills/dagger-design-proposals/SKILL.md", 16 + "computedHash": "2fcd51276cffed132e8b92b157473125bf0737facfe02a3ad796e537a60151be" 17 + }, 18 + "engine-debugging": { 19 + "source": "dagger/dagger", 20 + "ref": "main", 21 + "sourceType": "github", 22 + "skillPath": "skills/engine-debugging/SKILL.md", 23 + "computedHash": "9d1bb94949b6b2da0015f98a21d93b6246e4bd92e7e73f668aeefbf3f6f13bd3" 24 + }, 4 25 "frontend-design": { 5 26 "source": "anthropics/skills", 6 27 "sourceType": "github", ··· 18 39 "sourceType": "github", 19 40 "skillPath": "skills/skill-creator/SKILL.md", 20 41 "computedHash": "5ea13a6d9f0d4bb694405d79acd00cadec0d21bb138c4dd10fcf3c500cb835c2" 42 + }, 43 + "symfony-brainstorming": { 44 + "source": "MakFly/superpowers-symfony", 45 + "sourceType": "github", 46 + "skillPath": "skills/brainstorming/SKILL.md", 47 + "computedHash": "981515b64d86a2330b2f04a4019eb9b51cc255a89b4c99f06af366ac189f56d6" 48 + }, 49 + "symfony-config-env-parameters": { 50 + "source": "MakFly/superpowers-symfony", 51 + "sourceType": "github", 52 + "skillPath": "skills/config-env-parameters/SKILL.md", 53 + "computedHash": "287526c4720227b11fce4ced64bfe8873bac91e9b2b8dc9cfb897d2cbb28be4e" 54 + }, 55 + "symfony-controller-cleanup": { 56 + "source": "MakFly/superpowers-symfony", 57 + "sourceType": "github", 58 + "skillPath": "skills/controller-cleanup/SKILL.md", 59 + "computedHash": "f0beeb17ef438a1fd82318b8280ebda08232d0feb8a8c9855c3894c487b09c96" 60 + }, 61 + "symfony-cqrs-and-handlers": { 62 + "source": "MakFly/superpowers-symfony", 63 + "sourceType": "github", 64 + "skillPath": "skills/cqrs-and-handlers/SKILL.md", 65 + "computedHash": "4976bea544440ff29847a2bd9c482737c7b832f42c9c8dffa0e453da5f0b35e9" 66 + }, 67 + "symfony-daily-workflow": { 68 + "source": "MakFly/superpowers-symfony", 69 + "sourceType": "github", 70 + "skillPath": "skills/daily-workflow/SKILL.md", 71 + "computedHash": "2f6c5461645fcc674593ebdf29568c86ba10acc304fecf6c3c1b063601a432b8" 72 + }, 73 + "symfony-doctrine-batch-processing": { 74 + "source": "MakFly/superpowers-symfony", 75 + "sourceType": "github", 76 + "skillPath": "skills/doctrine-batch-processing/SKILL.md", 77 + "computedHash": "512dfce5145cc1ce7d20f177d81d93b40401d7941721fa8984a6d677054c691e" 78 + }, 79 + "symfony-doctrine-events": { 80 + "source": "MakFly/superpowers-symfony", 81 + "sourceType": "github", 82 + "skillPath": "skills/doctrine-events/SKILL.md", 83 + "computedHash": "e9ffd42dd504aa422819c6b8f9c53e36d3390aad3b1b37bf75db7f6a693afd92" 84 + }, 85 + "symfony-doctrine-fetch-modes": { 86 + "source": "MakFly/superpowers-symfony", 87 + "sourceType": "github", 88 + "skillPath": "skills/doctrine-fetch-modes/SKILL.md", 89 + "computedHash": "dfd8d1499e20da9e93a71277b33b09402f15e7b4f81eeb0b396a4f1d243a98cb" 90 + }, 91 + "symfony-doctrine-fixtures-foundry": { 92 + "source": "MakFly/superpowers-symfony", 93 + "sourceType": "github", 94 + "skillPath": "skills/doctrine-fixtures-foundry/SKILL.md", 95 + "computedHash": "19e4b0dbb990e12771bb9f8e6052cc7533f0059a89c8c54cd6c96c9250d87ffe" 96 + }, 97 + "symfony-doctrine-migrations": { 98 + "source": "MakFly/superpowers-symfony", 99 + "sourceType": "github", 100 + "skillPath": "skills/doctrine-migrations/SKILL.md", 101 + "computedHash": "284a94643f4fd3614445349f3c2f23e9e73d27502afe22ebd3fea776689c6cc1" 102 + }, 103 + "symfony-doctrine-relations": { 104 + "source": "MakFly/superpowers-symfony", 105 + "sourceType": "github", 106 + "skillPath": "skills/doctrine-relations/SKILL.md", 107 + "computedHash": "56e30f4e47d0bc3a5af54b0ca533c262138db4b526e7b52a704d72bd8c74d56e" 108 + }, 109 + "symfony-doctrine-transactions": { 110 + "source": "MakFly/superpowers-symfony", 111 + "sourceType": "github", 112 + "skillPath": "skills/doctrine-transactions/SKILL.md", 113 + "computedHash": "b97c45764b31563bb56d620f9a36bc73c8121d85511a17c901a6f8aed9dc053f" 114 + }, 115 + "symfony-e2e-panther-playwright": { 116 + "source": "MakFly/superpowers-symfony", 117 + "sourceType": "github", 118 + "skillPath": "skills/e2e-panther-playwright/SKILL.md", 119 + "computedHash": "cfdd93cda493b2d7569b3b9e04a5bc485a2501f193532b901174beb4bb4f3503" 120 + }, 121 + "symfony-effective-context": { 122 + "source": "MakFly/superpowers-symfony", 123 + "sourceType": "github", 124 + "skillPath": "skills/effective-context/SKILL.md", 125 + "computedHash": "3a06bccd9f22367617f551d29ce7fca55a573aaec1ec5df6d4cc8f95644ee940" 126 + }, 127 + "symfony-executing-plans": { 128 + "source": "MakFly/superpowers-symfony", 129 + "sourceType": "github", 130 + "skillPath": "skills/executing-plans/SKILL.md", 131 + "computedHash": "b90ec16f568c936b6715e880728a379adfd70baac6038f0ebb70963dfb408ee4" 132 + }, 133 + "symfony-form-types-validation": { 134 + "source": "MakFly/superpowers-symfony", 135 + "sourceType": "github", 136 + "skillPath": "skills/form-types-validation/SKILL.md", 137 + "computedHash": "9a7a2a4b8a05933a196bce5e3cb76e60f6bc811490a6fcf2382b5c86b8e0aab0" 138 + }, 139 + "symfony-functional-tests": { 140 + "source": "MakFly/superpowers-symfony", 141 + "sourceType": "github", 142 + "skillPath": "skills/functional-tests/SKILL.md", 143 + "computedHash": "febc85539e459c86a884dec31c0cb1efeb07e583424540c042fe6159c9f8e557" 144 + }, 145 + "symfony-interfaces-and-autowiring": { 146 + "source": "MakFly/superpowers-symfony", 147 + "sourceType": "github", 148 + "skillPath": "skills/interfaces-and-autowiring/SKILL.md", 149 + "computedHash": "2ce82c9a5d54bffa3a636ce6b6c927814c0bebdc854dcbfe74dffa8b80b054f6" 150 + }, 151 + "symfony-messenger-retry-failures": { 152 + "source": "MakFly/superpowers-symfony", 153 + "sourceType": "github", 154 + "skillPath": "skills/messenger-retry-failures/SKILL.md", 155 + "computedHash": "2b133c2d91cb77e192c48bb5616adb93b55fc5b9c4b5cc30d8c74f90be259b9a" 156 + }, 157 + "symfony-ports-and-adapters": { 158 + "source": "MakFly/superpowers-symfony", 159 + "sourceType": "github", 160 + "skillPath": "skills/ports-and-adapters/SKILL.md", 161 + "computedHash": "bc9b73a5cdd76d2ee45b81164d07613acc8ce2858e72e7fb3b6f2bde98acab62" 162 + }, 163 + "symfony-rate-limiting": { 164 + "source": "MakFly/superpowers-symfony", 165 + "sourceType": "github", 166 + "skillPath": "skills/rate-limiting/SKILL.md", 167 + "computedHash": "edc3514363335c799bf7ba70e47b5503ccfd28d046ee56eea23c2e6ccb009c32" 168 + }, 169 + "symfony-runner-selection": { 170 + "source": "MakFly/superpowers-symfony", 171 + "sourceType": "github", 172 + "skillPath": "skills/runner-selection/SKILL.md", 173 + "computedHash": "643759848bd049497617d79dcc0d8913cc50ef484016d3cd9601cac04b0de047" 174 + }, 175 + "symfony-strategy-pattern": { 176 + "source": "MakFly/superpowers-symfony", 177 + "sourceType": "github", 178 + "skillPath": "skills/strategy-pattern/SKILL.md", 179 + "computedHash": "7c4af4dc7958686bb2b5f916e59dc8a44342438a54662b23cb5fadff368e0386" 180 + }, 181 + "symfony-symfony-cache": { 182 + "source": "MakFly/superpowers-symfony", 183 + "sourceType": "github", 184 + "skillPath": "skills/symfony-cache/SKILL.md", 185 + "computedHash": "b409c61c6dede2974bbf21cc283a586326ed25b27c5bc9717dfa0f1e98f3097b" 186 + }, 187 + "symfony-symfony-messenger": { 188 + "source": "MakFly/superpowers-symfony", 189 + "sourceType": "github", 190 + "skillPath": "skills/symfony-messenger/SKILL.md", 191 + "computedHash": "ac58a2a3f19154b4e94b8f240e5a593fa828bdc40783336a0b96dfe8dc3b6bfe" 192 + }, 193 + "symfony-symfony-scheduler": { 194 + "source": "MakFly/superpowers-symfony", 195 + "sourceType": "github", 196 + "skillPath": "skills/symfony-scheduler/SKILL.md", 197 + "computedHash": "a3e9197f96e882323d480e0f63e4b90bdfbeead7f4acdb88540ee4f5abd136a4" 198 + }, 199 + "symfony-symfony-voters": { 200 + "source": "MakFly/superpowers-symfony", 201 + "sourceType": "github", 202 + "skillPath": "skills/symfony-voters/SKILL.md", 203 + "computedHash": "61aa9c4b3d9ce940864d2aeccec54a905a9d6685cc9f59625c74f546d70e8380" 204 + }, 205 + "symfony-tdd-with-phpunit": { 206 + "source": "MakFly/superpowers-symfony", 207 + "sourceType": "github", 208 + "skillPath": "skills/tdd-with-phpunit/SKILL.md", 209 + "computedHash": "71598ee5c3b73d4821b30058adaadcf5ab45e1623676f484e2bb4212fee8c431" 210 + }, 211 + "symfony-test-doubles-mocking": { 212 + "source": "MakFly/superpowers-symfony", 213 + "sourceType": "github", 214 + "skillPath": "skills/test-doubles-mocking/SKILL.md", 215 + "computedHash": "45523a2697669e3294ee63c4baffb8cecc6e3ea412065e50ea82ba9b7ed621da" 216 + }, 217 + "symfony-twig-components": { 218 + "source": "MakFly/superpowers-symfony", 219 + "sourceType": "github", 220 + "skillPath": "skills/twig-components/SKILL.md", 221 + "computedHash": "babf20588dc7b99139ff862e59916a4d105b23eed25a2eb488cdaeb39dae144b" 222 + }, 223 + "symfony-using-symfony-superpowers": { 224 + "source": "MakFly/superpowers-symfony", 225 + "sourceType": "github", 226 + "skillPath": "skills/using-symfony-superpowers/SKILL.md", 227 + "computedHash": "5de5c7c351db214020f88cbc2be56d1bef8a63c130b886e27ed441ba6b35a447" 228 + }, 229 + "symfony-value-objects-and-dtos": { 230 + "source": "MakFly/superpowers-symfony", 231 + "sourceType": "github", 232 + "skillPath": "skills/value-objects-and-dtos/SKILL.md", 233 + "computedHash": "6ee277f295b8ed5e18fefe64cb522f3bffc6415ea29c9e306b5c5b027a40f1e1" 234 + }, 235 + "symfony-writing-plans": { 236 + "source": "MakFly/superpowers-symfony", 237 + "sourceType": "github", 238 + "skillPath": "skills/writing-plans/SKILL.md", 239 + "computedHash": "8990dd5cab321e8128c76684c490eb150f4f5d4b1df14e8f6ee0177b0b92937a" 240 + }, 241 + "telemetry-capture": { 242 + "source": "dagger/dagger", 243 + "ref": "main", 244 + "sourceType": "github", 245 + "skillPath": "skills/telemetry-capture/SKILL.md", 246 + "computedHash": "d0eb55bfe6c66109e26fe237ab9f96bc06e0029bb8ebbd2c770ec28e6a8d2a1a" 247 + }, 248 + "tui-qa": { 249 + "source": "dagger/dagger", 250 + "ref": "main", 251 + "sourceType": "github", 252 + "skillPath": "skills/tui-qa/SKILL.md", 253 + "computedHash": "8787639e27d36553490bc1ba25273eba29776bd66e4dedec752413ff3821cdee" 21 254 } 22 255 } 23 256 }