diff --git a/.agents/skill-sources.json b/.agents/skill-sources.json index f97fa3c..f5ab3fe 100644 --- a/.agents/skill-sources.json +++ b/.agents/skill-sources.json @@ -41,21 +41,6 @@ "SKILL.md": "2b3223a02c386d1263e1b08343cb8eb3a53e444cdafb1d79e26c8ec2a8fcad28" } }, - { - "name": "grill-me", - "repository": "https://github.com/mattpocock/skills", - "revision": "5beb35b040e3de97e54fa9e386d7e42d15c295c2", - "path": "skills/productivity/grill-me", - "tree": "3df14e2d3a89459bf300614be9247e3ce74798f8", - "installedFrom": "daniel-tsx/fintech@95757b737fbbf3b88cdab2dc4fb16572555b0f31:.agents/skills/grill-me", - "license": "MIT", - "codexAdaptation": "Replace Claude Skill-tool call with reading ../grilling/SKILL.md; synchronization restores the upstream Claude entry point.", - "files": { - "LICENSE": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5", - "SKILL.md": "14b78da25bb1f4fd8cfb3183440a0f490e93dad0d94a390cc8e8fb0f073b50d7", - "agents/openai.yaml": "c061e39c3e0f9d865fb1b97556d485704af2a8a58f4b8221a8917a5c2074a32b" - } - }, { "name": "grilling", "repository": "https://github.com/mattpocock/skills", @@ -147,11 +132,116 @@ "references/security-rls-performance.md": "2e1faeca7d5746e246b1c78f9492fe2a9cfa41c4d7fa1e23c402d58fa18ea014", "SKILL.md": "2fd96dd9d49dee7ae7147b0425355d1ca4021d300ad7ca8972f56ac037bb84c3" } + }, + { + "name": "vercel-react-best-practices", + "repository": "https://github.com/vercel-labs/agent-skills", + "revision": "063bee94c3f4df8453406c830b0a7df0f2860278", + "path": "skills/react-best-practices", + "tree": "343db72180d05814949fef7d46be96b5c6ee86b3", + "installedFrom": "daniel-tsx/fintech@7f5e1c243f7bfba51e1928a0860c1d88188e8ab8:.agents/skills/vercel-react-best-practices", + "license": "MIT (declared upstream; no LICENSE file shipped)", + "adaptations": [ + "Omit the compiled AGENTS.md (~108 KB): a nested AGENTS.md can be auto-loaded as directory instructions by agent hosts, and SKILL.md plus rules/ carry the same rules.", + "Point SKILL.md's 'Full Compiled Document' line at the individual rules/ files instead of AGENTS.md.", + "Add a repository-authored LICENSE note because upstream ships no LICENSE file." + ], + "files": { + "LICENSE": "4af1b2ac1e60723de1c22a1d3e3daf712f46ab7b77cb90bd5f5a8b8a0474d29f", + "README.md": "9eafb2123d3b8b2aef41fba0db2648a3f4f49996f8c7c8bb2c86263befd8b48a", + "SKILL.md": "3c22de3d334db300f34dea3bff405fda8151797888d2f816d05f83c5a9f2eed3", + "metadata.json": "c4e18bac3290fbe1b471a605867d4df68b03144ea26b8befdbe8f9d8fd124354", + "rules/_sections.md": "01c59969e4e867f0708c8f8ef9c6d87fab9a07d0586244f429ff84013db8a115", + "rules/_template.md": "99df2a3ea088c6c22de2484ddc7e964d0e9923846f44c63380343ecc64455442", + "rules/advanced-effect-event-deps.md": "55ed8f9f27a1bff803d6fa55828176207714b02123cbb97ddb8a90d88024a723", + "rules/advanced-event-handler-refs.md": "a3097edeecfb2ff6851cd66ed8cdf0617c3c88180504bee824aa6e75a215bbbc", + "rules/advanced-init-once.md": "3e4dc22173eb0e2b6dcf583e3babd862b8696328f8f59aee7c4d746c5bc05f6c", + "rules/advanced-use-latest.md": "8a3f64dfe5a77d1564248faf17fe662ae75b55d497a243cb30b38eb07ccbbf9a", + "rules/async-api-routes.md": "523338540d73427dc14c0cbb19f2741ebccdf8b105a7b2c1b33d2905cf237a42", + "rules/async-cheap-condition-before-await.md": "03107c1f70a293cd58a8abc5474c5548e20ea4efdf01b6d45aae7ad0ac1cb32c", + "rules/async-defer-await.md": "f3c142d090615abdcc99d31c123be37b31f0f1ed8d9ec9f7789a3bc6883ae0fb", + "rules/async-dependencies.md": "16ef469f877e30c6b8e1e1b4bc3e527312fa3c6318cb785a4eca186ea236131a", + "rules/async-parallel.md": "6d2f841896279e976dfcdc1ac89e70771ac188baadfd43c096b5706cb838b961", + "rules/async-suspense-boundaries.md": "de05fedac2eb7ae563b887b5a424464ec3dfaf84e5b7797467ebe2a796ac8afc", + "rules/bundle-analyzable-paths.md": "62090b5c815fd71bf1133dce754f51abc16a9456d0ccd025fd627de27726e92a", + "rules/bundle-barrel-imports.md": "e648f6d54525f07a691357040ca5bff6ea3424788a91ff63812adb29622a671b", + "rules/bundle-conditional.md": "09c8259c3efb04fc0abb8e412ccbda217c222ae8118516283e571c995fe3a7d4", + "rules/bundle-defer-third-party.md": "3719fb47b191e8db4fe22686ec88448ad5af9e6838585425abbe103d0b642e37", + "rules/bundle-dynamic-imports.md": "401817a7369f315fc5a68a1095742ff7d53d0461906880dc9d64a41495ee1986", + "rules/bundle-preload.md": "d1f7cc28da7cd5ab249acd287edc5b761afcfb194e9cb62cd44c5f3543db2de2", + "rules/client-event-listeners.md": "242a873349febc1ce685e85617994784dbab92c2eaa68aed7fed5a83e7680e93", + "rules/client-localstorage-schema.md": "0fb7cdf9dc93fdf22f87e3669f953fb9d4ac9b0be42eec4a29fe9a2560610b88", + "rules/client-passive-event-listeners.md": "1f35016f9053de69e884ee9be00654e37979c2ca85f5e57764dc2626ff7acb4b", + "rules/client-swr-dedup.md": "644652c39c6cc00de8d1c77a7273612e868dc3f7edda30764164c12ec0f764a3", + "rules/js-batch-dom-css.md": "480b891b9eaf96e929dc1e964274a4828e3a78009b7d00ccd965a0d425215959", + "rules/js-cache-function-results.md": "3daaa11d24f4295cb6be8bc6f407f2ce83cc7b5bd1f0e68891ea8ab835721dc7", + "rules/js-cache-property-access.md": "73e47431e74878a927061bf0ddc7cd91a7556cb35d2573f3421e82300d9ae311", + "rules/js-cache-storage.md": "11b826b0433898c1ece2d3547010d8e77db9fb240185748c45db91493de9b6cc", + "rules/js-combine-iterations.md": "71add08aeeb43091d4ff4c0b2842cce8b4bdef8ad3e732cc034bb5a84827e746", + "rules/js-early-exit.md": "925ce5ce87f3347186ca62212f29cc6baa2b8c85a96d2720ad07c3d0abf781c0", + "rules/js-flatmap-filter.md": "9e5b166a41914d975bef7635467f8fb32cd98b5c3126c0bc6f163490812fb5f0", + "rules/js-hoist-regexp.md": "f9e9aef2f7c2307dd7310f283df85dcada45fc43e9bb941b0c3aa414dec21ec4", + "rules/js-index-maps.md": "5df1bdc2cfabb2c98abd55d26762e5c18189535c2e686184f426082e62920391", + "rules/js-length-check-first.md": "8b54a311c826b299272f29187fc58bc55772a04c4a72eeb29456ee96dc9fc624", + "rules/js-min-max-loop.md": "87dfb67e2f39df6ad8bcfb5b78c5fb364d3c07554ad188a202408d99f3763421", + "rules/js-request-idle-callback.md": "ee08f83905b4011abbc33d594033157d77561b5bbd9b36b04f0f409581ffec32", + "rules/js-set-map-lookups.md": "a7fd781a6ba9ad49065961b6f9a90ef486bf6b648390e1a08704025f98e1642b", + "rules/js-tosorted-immutable.md": "d0a5e1b0fec48a0a81397957e2f068e224329f7c42cb5feeed8aae6fa64025e8", + "rules/rendering-activity.md": "1e5e7eaf3555e61d6a2e900089c676527d26259501db5594f59544b6c664f85a", + "rules/rendering-animate-svg-wrapper.md": "9c6ae0ca7a51434e803887c64cded760956579a1452ccb80a461a03f9c937c77", + "rules/rendering-conditional-render.md": "2ec2fa23c4148285144687050c52369adba3da2fbe3d486f8d3e0aad8f06f2bc", + "rules/rendering-content-visibility.md": "64eee6d5b916fe74df33363994b27fc7f71bea3bcedc7ee04bda23107ca3e6e4", + "rules/rendering-hoist-jsx.md": "93b229560fae92005ed9a2a829064607b39b2e984e92d221d05b2d41df2b7c0e", + "rules/rendering-hydration-no-flicker.md": "dc7ab358c67c177bca6e6f360fbc935ebe4efa928c0ba3ebd3f9f3d9e2000ca3", + "rules/rendering-hydration-suppress-warning.md": "915bacb934e2927d84b37a3f3d225a47b309ac8f9e494ed2bcb66ce07c5676a2", + "rules/rendering-resource-hints.md": "351b1095d641717b930f1b2253282b7120af3023bc74e06c17675168d4286864", + "rules/rendering-script-defer-async.md": "4cea07b7875ba7fc2c7465ff23b63a2bb334f90aaf2d9293d38d67b05fdb32ab", + "rules/rendering-svg-precision.md": "ed468533f6e95f622859c884b122cf21f9f593ed6bb3d500a54de4b9f9bcb9fb", + "rules/rendering-usetransition-loading.md": "3a1249c3f13026b6f54ab5712c388df219d6af01b12aa542749acd99a46f4bc1", + "rules/rerender-defer-reads.md": "234050a77faf50cb306be10a9e15bd4421134ab5907e75f13e6d78e2bd262dc9", + "rules/rerender-dependencies.md": "17eb5830956fb56486fd3cfc7431f5849d39751730c05ad9e77dd4f0c27169c5", + "rules/rerender-derived-state-no-effect.md": "cb11ec76f50aa7b6847269f02d79b120c889a30d9ecd2c1d578f75144419c77a", + "rules/rerender-derived-state.md": "1c326bb67b01fb084eb00c8911b0a7cff54681c2315b629f88e49b64eaa6481d", + "rules/rerender-functional-setstate.md": "5e68df6b2ae8058e67f476ff1ac67bde159f5a9d18df439e46eabcdeb7b52e58", + "rules/rerender-lazy-state-init.md": "4ae844740f266fc8cbf050701230286624a2440ec5be67b9a63d9edc3c580573", + "rules/rerender-memo-with-default-value.md": "81c47476564ad8a79fc68fc442734255ca7d6829424359464503fda0a9f9a0dd", + "rules/rerender-memo.md": "1f258990c2f27ff6256b3cc5c43300631bb3f0d81f749aed08d07fcdcc131dd1", + "rules/rerender-move-effect-to-event.md": "abc2cbf167bee056743023351e96806a411d3398da8802093620aecf0721f029", + "rules/rerender-no-inline-components.md": "1cc32907a770272524ba682eef9e7efdd8ad4533b6f89b718a1fcd7d1d0eb6a3", + "rules/rerender-simple-expression-in-memo.md": "5bdcf2d1558ba5204e643c887b7b4060f89630969425651eb68e96427d97f800", + "rules/rerender-split-combined-hooks.md": "6f45d703343484ef9acef9388397f3642966ae6a5a258899a0cf1f9977cfbf6c", + "rules/rerender-transitions.md": "60f4033909a62df5e5b8c601494f9e50a562e2f8c1c2d81eac24f38142265f1c", + "rules/rerender-use-deferred-value.md": "8c3579576a61a949884167745dcfaf1d5426ba305cc61fd0a79602d70fda4909", + "rules/rerender-use-ref-transient-values.md": "f1a649af9d1d0b5c762f6c9c4c1c70c90d7df82aa10296ee87aadb78c1b76c54", + "rules/server-after-nonblocking.md": "d0b8d24a3db9f0f65f9e2bddbf230b0e03a5f60e1229d93a4a18f5e7a991c7c2", + "rules/server-auth-actions.md": "a2ca8aa102839251c7971ec784560fab943c20b36fe6f80365d4110c13260c23", + "rules/server-cache-lru.md": "1924b64561841923b88a657085097a0aeba3e0ba2d5470b9f5c15cc10d6ae70f", + "rules/server-cache-react.md": "fddeea6c870bb3da4134b61d54b7d09b83d2603f929b7547cd3dc1dc269edd5c", + "rules/server-dedup-props.md": "c2747424cdac46be62f835f245a0aa887b0090b416d28e2ac8676702645eeb37", + "rules/server-hoist-static-io.md": "5abea4dd882b13661bd5f7dfb93f9e547d5f08176c61c448ade91b210ed5d49f", + "rules/server-no-shared-module-state.md": "4d869bf1cd2f1632e5ca4c0e64b1cf0e71c5f5a0f940bc8a22174d205e735ed7", + "rules/server-parallel-fetching.md": "569a2e9fc04f9606686cd75c5894ef8781b502786a7fffe241396846c7733472", + "rules/server-parallel-nested-fetching.md": "ad5d9656984d4a959997a2367bf59cc6632297b4634a1dd13a087939d83f30b3", + "rules/server-serialization.md": "f4c7d68b29c82381baad059c4a7f09e868e71ec9c3115a26ad3ae7d24c0dfe1f" + } + }, + { + "name": "frontend-design", + "repository": "https://github.com/anthropics/skills", + "revision": "dbd4588f9e1033efb41dad4bef2f7947c8993d44", + "path": "skills/frontend-design", + "tree": "d79e2a5bb4df4a386c2adcdd9ab8709bba28c3f6", + "installedFrom": "daniel-tsx/fintech@7f5e1c243f7bfba51e1928a0860c1d88188e8ab8:.agents/skills/frontend-design", + "license": "Apache-2.0", + "files": { + "LICENSE.txt": "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594", + "SKILL.md": "d91970639e9f5c37682ac7ab60094d35f1c7c1f38d731bd56396563aee10c1d3" + } } ], "reference": { "repository": "https://github.com/daniel-tsx/fintech", - "revision": "95757b737fbbf3b88cdab2dc4fb16572555b0f31", + "revision": "7f5e1c243f7bfba51e1928a0860c1d88188e8ab8", "path": ".agents/skill-sources.json" } } diff --git a/.agents/skills/codex-review/SKILL.md b/.agents/skills/codex-review/SKILL.md new file mode 100644 index 0000000..f2ed625 --- /dev/null +++ b/.agents/skills/codex-review/SKILL.md @@ -0,0 +1,39 @@ +--- +name: codex-review +description: Run an independent cross-model review with Codex on the current task branch or design, then triage its findings. Use when AGENTS.md requires an independent reviewer (T2, CI or guard changes) or when the user asks for a Codex review; not for self-review of T0/T1 work. +--- + +# Codex review + +Codex reviews; you (the implementer) prepare the package, run it, and triage. Codex never edits files. + +## 1. Prepare +- Work is committed on your task branch inside your worktree: review a commit, not a dirty tree. +- `git fetch origin`; rebase onto `origin/main` if it moved. Record the reviewed SHA: `git rev-parse HEAD`. +- Write a short brief in `.tmp//review-brief.md`: goal, non-goals, task size, invariants and boundaries touched, acceptance criteria. Facts only: no claims that the change is correct, minimal or safe. + +## 2. Run (read-only, from the worktree root) +Write `.tmp//review-prompt.md`: the brief, then this instruction: + +> You are an independent reviewer of the changes on this branch against origin/main (git diff origin/main...HEAD). Follow AGENTS.md: its task-size table, project invariants and review rules. Read the code and tests yourself; trust no summary. Check correctness, regressions, security, auth and user ownership, concurrency, retries and leases, migrations, AI-spend and notification side effects, and test adequacy as relevant. Report findings only, ordered by severity, each with: P0/P1/P2, file:line, a concrete failing scenario, actual versus required behavior. Say explicitly if you found nothing blocking. Do not modify files. + +Design (no diff yet): replace the diff sentence with "review this design for the repository" and append the design text or its `.tmp` path. + +The prompt is read from stdin, so the same command works in Command Prompt and in a POSIX shell: +```sh +codex exec -s read-only -c model_reasoning_effort="high" -o .tmp//review-.md - < .tmp//review-prompt.md +``` +`-o` saves Codex's final message. Never overwrite an earlier review file. + +## 3. If Codex cannot run +Not installed, config or sandbox error, usage limit: report the error once, do not loop. Fall back to a fresh read-only subagent (or a fresh session) given the same brief and SHA, and label the reviewer as fallback. Your own re-read is not an independent review. + +## 4. Triage every finding +- **Accept**: reproduce it (a failing test where feasible), then fix. +- **Reject**: cite code or test evidence (file:line) showing why it does not apply. +- **Human decision**: policy, product or data-semantics questions go to the user; do not resolve them by changing requirements. + +Fix accepted findings in one wave, rerun the affected checks, and review the new SHA again. At most two correction rounds; then stop and hand the remaining findings to the user. + +## 5. Report (PR Review section) +Reviewer (Codex, model and effort, or fallback), reviewed SHA, a findings table with disposition, and open human decisions. diff --git a/.agents/skills/engineering-workflow/SKILL.md b/.agents/skills/engineering-workflow/SKILL.md new file mode 100644 index 0000000..fa56b80 --- /dev/null +++ b/.agents/skills/engineering-workflow/SKILL.md @@ -0,0 +1,43 @@ +--- +name: engineering-workflow +description: Orchestrate an explicitly requested complex or high-risk engineering task through planning, fresh independent plan review, implementation, verification, fresh independent code review, bounded correction, and human approval. Use for "run engineering workflow", "autonomous implementation with review", or similar; do not auto-trigger for routine edits or audit-only requests. +--- + +# Engineering Workflow — native-agent pilot v1.1 + +Use this skill only when an autonomous engineering workflow is expressly requested. This is an orchestration **procedure**, not a background daemon, CI service, or automatic grant of privileges. Follow repository `AGENTS.md`, project `CLAUDE.md`, higher-priority instructions, and approved product and data policies. Use existing skills (`ponytail`, `ponytail-review`, `systematic-debugging`, relevant database skill) within their appropriate phases; never override their project routing/safety boundaries. + +## Entry and mode + +1. Inspect checkout and `git status`, task owner, current code, approved decisions and verification instructions. Preserve all pre-existing changes. Establish `BASE` (current HEAD), scope, authorized actions, and exact required checks. +2. Choose the **lightest authorized mode**: `DESIGN_ONLY` for explicit design-only/dormant contract tasks; `MEDIUM` for bounded feature changes; `HIGH_RISK` for migrations, auth/ownership, ingestion and lease concurrency, recovery, AI spend, notifications and live integration. The user may narrow the mode, but a lower mode may not waive project safety gates. Simple fixes use the normal repository workflow instead. +3. Create one ignored handoff directory `.tmp/agent-workflows//` (verify with `git check-ignore`) and follow the repository's *Files and artifacts* rules. Keep only: `state.json`; `plan.md`; one immutable reviewer report per review attempt (for example `plan-review-1.md`, `code-review-1.md`; never reuse a name); `verification.json` (VERIFY evidence: commands, exit codes, counts, short failure excerpts); `final-checks.json` (FINAL_GATE evidence); and at most one compact `handoff.md` when a stage records a blocking note. Plan, verification and final-check files may be rewritten only by recording them again with the state helper, which re-digests them; any other change to a recorded file is `BLOCKED_EVIDENCE_CHANGED`. No source snapshots, diff copies, full runner JSON or duplicate logs: Git and the checkpoint fingerprint already cover them. These files are the resume source: keep them until the workflow is finalized (human approval or rejection with the handoff delivered) or the user explicitly abandons it; never clean them at a pause or at `HUMAN_APPROVAL`. Do **not** create permanent per-round reports. Never save secrets. +4. **Before any agent dispatch**, read [resume.md](references/resume.md), initialize or resume the local state ledger with `node scripts/engineering-workflow-state.mjs`, and inspect the returned guard status. Never infer completed work from a partial chat transcript or a reviewer summary. State and evidence are untrusted until checked against source, artifacts, execution logs and actual reviewer provenance. +5. Persist an atomic checkpoint after each *completed* stage, including an immutable review report path and a verified fresh reviewer context identifier for review stages. Preserve the original review records instead of overwriting a previous verdict. Persist pause reason when capacity, permissions or environment block progress. +6. On resume: recompute full nonignored source fingerprint and recorded artifact digests, preserve the correction budget and stage, and **redrive only an incomplete stage**. If evidence changed, do not reuse an approval. If a write stage is interrupted mid-edit, inspect its partial diff and finish that same stage before recording its checkpoint. If a review was interrupted before a saved verdict, dispatch a fresh reviewer; never claim an old incomplete attempt succeeded. +7. Read [planning.md](references/planning.md), then [review.md](references/review.md), [correction.md](references/correction.md), and [acceptance.md](references/acceptance.md) as their stages begin. Do not preload every reference for unrelated stages. + +## State machine and roles + +`INSPECT -> PLAN -> PLAN_REVIEW -> IMPLEMENT -> VERIFY -> CODE_REVIEW -> (CORRECT -> VERIFY -> CODE_REVIEW)* -> FINAL_GATE -> HUMAN_APPROVAL` + +- In `DESIGN_ONLY`, stop after approved `PLAN_REVIEW` at `HUMAN_APPROVAL`; **do not implement**. +- Plan review and code review must use a **fresh, isolated context/subagent** with implementation write access withheld where supported; send task brief, acceptance criteria, exact BASE and current diff/evidence, not the author's narrative or approval claim. A reviewer must independently inspect actual sources. Do not call the implementer's self-review an independent review. +- If the current host cannot spawn a genuinely separate reviewer context, mark `BLOCKED_INDEPENDENT_REVIEW` and provide the handoff for a separate session. Do not silently simulate reviewer independence in the orchestrator context. +- Implementers own code changes, tests and permissible local operations; reviewers own findings/approval only. Do not parallelize implementation agents that write overlapping files. The orchestrator coordinates the stages but may not override explicit human decisions. +- Run deterministic checks with actual commands. A model verdict cannot override failing tests or an unverified required gate. +- Limit automatic correction to **two rounds** per checkpoint (shared across plan/code corrections; the ledger counter survives interruption and rebaseline). Resolve findings as one coherent fix wave; re-review the changed revision. Escalate unresolved P0/P1, a third fix round, design/policy ambiguity, permission errors or unavailable mandatory verification to `BLOCKED` with consolidated findings. + +## Revision and approval integrity + +Record `BASE`, full tracked and untracked inventory, and a revision fingerprint before each review. Use the checkpoint helper to hash HEAD + Git index/status + bytes of every nonignored tracked/untracked worktree file; track the plan/review/verification artifacts with separate content digests. A hash of the committed SHA alone is **not** enough for dirty working trees. Review package must include staged/unstaged diff and relevant untracked deliverables; ignored temp artifacts do not count. Any material source or test change after approval invalidates the approval until review and checks rerun for the new revision. A changed ignored plan or reviewer artifact also invalidates its recorded approval. Stale approval cannot be revived by a resume or counter reset; explicit rebaseline requires new independent checks and never grants human authorization. + +No automatic commit, push, PR creation, merge, deploy, real provider calls, production database mutation, cron registration or live-provider activation without separate authorization. For StackPulse, keep Skill Updates cron registration disabled and preserve its staging gates and budget limits. + +## Output contract + +Final response: mode, checkpoint directory/state, resume guard status, correction budget, verified checkout/revision, stage reached, design/reviewer verdict, exact passed/failed/skipped/not-run checks, unresolved P0/P1/P2 issues, files changed, safety boundaries, next human decision. Distinguish *author reported*, *independently executed*, *hosted CI*, and *not tested* evidence. **`READY_FOR_HUMAN_APPROVAL` is not deployment or production approval.** + +### References and inspirations + +Original adaptation inspired by `obra/superpowers` (fresh implementer + spec/quality review, precise review packages), `Yeachan-Heo/oh-my-claudecode` (staged plan/exec/verify/fix pipeline), and the already installed `ponytail-review`, `grilling`, and `systematic-debugging` skills. No upstream skill code copied. See repository `docs/AI_WORKFLOW.md` for links and provenance. diff --git a/.agents/skills/engineering-workflow/references/acceptance.md b/.agents/skills/engineering-workflow/references/acceptance.md new file mode 100644 index 0000000..7f744c2 --- /dev/null +++ b/.agents/skills/engineering-workflow/references/acceptance.md @@ -0,0 +1,20 @@ +# Final gate and human acceptance + +## Deterministic gates + +- Freeze scope and revision (BASE + commit SHA when committed + staged/unstaged diff fingerprint + untracked file inventory). If files changed after review, invalidate and rerun applicable checks/review. +- Confirm independently approved plan and implementation (unless explicit DESIGN_ONLY), exact acceptance assertions, required passing checks, relevant DB/upgrade/concurrency tests, source/permission invariants, and complete accounted skip/failure list. +- A check marked `NOT_RUN`, `BLOCKED_ENVIRONMENT` or `AUTHOR_REPORTED` does not satisfy a mandatory gate. Hosted CI is a separate gate from local Docker/Postgres; do not claim success without its result for the exact SHA. Workflows do not push, so at `FINAL_GATE` hosted CI is reported as `NOT_RUN`: it is not a mandatory gate for `READY_FOR_HUMAN_APPROVAL`, but it must pass on the PR before merge. +- Inspect dirty worktree and ensure generated/temp/secret files are not staged. Keep review artifacts in existing `.tmp/agent-workflows//` and preserve significant tests in version control. Only durable design/contracts/operations belong in canonical docs. + +## Verdicts and permissions + +- `READY_FOR_HUMAN_APPROVAL`: all in-scope mandatory gates completed on the reviewed revision; not equivalent to commit/merge/deploy permission. +- `BLOCKED`: unresolved P0/P1, acceptance-blocking P2, policy decision, review independence unavailable, exceeded correction limit, or required tests missing. +- `DESIGN_READY_FOR_HUMAN_APPROVAL`: design-only plan independently approved; implementation NOT authorized by this verdict. + +The human receives a short, actionable summary with all open questions. No unattended push, PR creation, merge, production database mutation, cron registration, provider call or deployment. If the user later authorizes one action, that authorization does not silently extend to the others. + +## Resume integrity gate + +Before presenting final approval, run `node scripts/engineering-workflow-state.mjs resume `, inspect `state.json`, and confirm both its **current source fingerprint** and each recorded ignored artifact digest still match. Record `FINAL_GATE PASSED` only with real evidence, matching independent plan/code review, checks performed on the current revision and an accepted zero-blocker disposition. A paused stage, stale/missing artifact, changed source, or exhausted correction budget cannot become `READY_FOR_HUMAN_APPROVAL` through replay of a summary. See [resume.md](resume.md). diff --git a/.agents/skills/engineering-workflow/references/correction.md b/.agents/skills/engineering-workflow/references/correction.md new file mode 100644 index 0000000..561a734 --- /dev/null +++ b/.agents/skills/engineering-workflow/references/correction.md @@ -0,0 +1,12 @@ +# Bounded correction + +- Read reviewer findings and reproduce each accepted blocker against unchanged reviewer tests before changing implementation. Use `systematic-debugging` for unclear root causes; avoid symptom-level fixes. +- One correction agent receives the **whole actionable findings set**, plus frozen acceptance contract, exact affected code and necessary tests. Keep each wave surgical and sequential. Preserve unrelated work, original adverse reviewer assertions and historical failing baselines. +- Add permanent regression cases for real correctness defects. Do not edit the review test to force green; if reviewer test is mistaken, produce a source-backed explanation for independent adjudication. +- Re-run scoped checks plus all impacted integration suites. A correction may expose a *different* case; classify its relation to the requested contract instead of growing an unbounded whole-repo audit. +- Revision fingerprint changes after every correction. Dispatch a fresh reviewer against the **new full revision** (or a scoped fix diff plus a full baseline review package); old approval does not carry over. +- Automatic correction budget = 2 waves. P0, unapproved product/business semantics, restricted execution, repeated P1 after two waves or mandatory missing test environment -> `BLOCKED`, with one consolidated handoff to the human. Never retry the same failure without new evidence. + +## Correction rounds across resumes + +Count a correction wave when a review/verification verdict enters `CORRECT_DESIGN` or `CORRECT`, not when the agent is relaunched. The two-round budget spans design/code work within the same checkpoint and **must survive restarts or explicit rebaseline**. An interrupted writer may continue its partial diff after inspection; do not create a new correction wave or mark the stage done merely because the tool stopped. Record the completed writer stage and re-dispatch a fresh reviewer after verification. See [resume.md](resume.md). diff --git a/.agents/skills/engineering-workflow/references/planning.md b/.agents/skills/engineering-workflow/references/planning.md new file mode 100644 index 0000000..58252e1 --- /dev/null +++ b/.agents/skills/engineering-workflow/references/planning.md @@ -0,0 +1,19 @@ +# Planning and plan review + +## Planner + +1. Establish the current source-of-truth: repo instructions, git state, approved decisions, actual call paths, database constraints and existing tests. Do not treat an older proposal as present behavior. +2. Write a compact task brief: goal, risk tier, explicit non-goals, affected invariants, files/code entry points, dependencies, acceptance checks, known unknowns and expected human gates. +3. Split tightly coupled work into **sequential** checkpoints. Avoid speculative interfaces, a full-repo audit and an oversized all-at-once implementation. Reuse the current skill routing: `grilling` only for consequential unsettled choices, `ponytail` for bounded implementation. +4. For HIGH_RISK work, name the transaction or lease owner, source of truth, user-ownership checks, failure/retry model, concurrency participants, DB test matrix, AI-spend and notification exposure, and prohibited activation (cron registration, live providers). Mark unimplemented prerequisites as blockers; do not invent synthetic production authority. +5. List tests that actually prove acceptance, and the checks possible in the environment; mocks do not prove Postgres concurrency or COMMIT semantics. + +## Fresh independent plan reviewer + +Pass the brief and source-entry points; do not hand over author conclusions as facts. Verify plan against real code/SQL; challenge scope, invariants, missing rollback/replay edges, lock order, migration upgrades, auth, source trust and test feasibility. Return `PLAN_APPROVED` or `PLAN_REQUEST_CHANGES` with P0/P1/P2 findings, exact evidence and smallest correction. The planner may revise the plan in at most two rounds; material policy or human-choice conflicts become `BLOCKED_HUMAN_DECISION`. + +In `DESIGN_ONLY`, approved design is the terminal output pending human acceptance. Do **not** begin the first implementation checkpoint. Design-only review may not be reinterpreted as code or deployment authorization. + +## Checkpoint handoff + +After the plan is complete, save it under the task's ignored workspace and record `PLAN READY` with `--artifact` in the v1.1 state ledger. If a reviewer requests changes, preserve the original reviewer report and write the corrected plan as a new version of the plan file; the current digest is updated only after correction actually completes. Do not reset the task slug or correction counter when a session is interrupted; use [resume.md](resume.md). diff --git a/.agents/skills/engineering-workflow/references/resume.md b/.agents/skills/engineering-workflow/references/resume.md new file mode 100644 index 0000000..f9b1ddb --- /dev/null +++ b/.agents/skills/engineering-workflow/references/resume.md @@ -0,0 +1,75 @@ +# Checkpoint and resume protocol (v1.1) + +This protocol makes native-agent workflows **recoverable**, not asynchronous. The host must still execute an actual agent turn when resumed. An API/model usage limit cannot be automatically bypassed; no background timer or polling is implied. + +## Local authoritative handoff + +Use one ignored workspace **per task**, for example `.tmp/agent-workflows/skill-lease-retry/`. Preserve existing task workspace files; do not overwrite old reviewer evidence. The checkpoint ledger `state.json` is a recovery hint that must be verified, **not** approval or proof that an agent actually ran. Keep only the files SKILL.md step 3 allows (state, plan, reviewer reports, one compact verification summary); include no secrets or real provider payloads. + +The helper lives at `scripts/engineering-workflow-state.mjs` and needs only Node 22+ and Git. Always run at the repository root. Example: + +```sh +node scripts/engineering-workflow-state.mjs init skill-lease-retry DESIGN_ONLY +node scripts/engineering-workflow-state.mjs resume skill-lease-retry +``` + +`init` **refuses** to reset an existing task, preventing accidental loss of the correction budget. The helper checks the task directory is gitignored and captures HEAD, Git index, working tree, and every nonignored tracked/untracked file by content. Each mutation takes an exclusive short-lived `.checkpoint.lock` to reject concurrent writers; after an abrupt process crash a stale lock must be inspected and cleared manually only after verifying no writer remains. Ignored plan/review/log artifacts receive separate SHA-256 digests when recorded. This is local integrity detection, not a cryptographic attestation of reviewer identity. + +## Completed-stage checkpoints + +Only record a checkpoint **after** the stage has actually completed. Maintain the single task `state.json` instead of creating a new permanent Markdown report on each round. + +```sh +# Stage begins at INSPECT +node scripts/engineering-workflow-state.mjs advance skill-lease-retry READY +# Now PLAN, then PLAN_REVIEW +node scripts/engineering-workflow-state.mjs advance skill-lease-retry READY --artifact .tmp/agent-workflows/skill-lease-retry/plan.md +# Fresh external subagent review: never substitute an orchestrator self-review +node scripts/engineering-workflow-state.mjs advance skill-lease-retry PLAN_REQUEST_CHANGES --artifact .tmp/agent-workflows/skill-lease-retry/plan-review-1.md --reviewer-id codex-reviewer-1 +# CORRECT_DESIGN (uses one of two total correction rounds) +node scripts/engineering-workflow-state.mjs advance skill-lease-retry READY --artifact .tmp/agent-workflows/skill-lease-retry/plan.md +# PLAN_REVIEW again, with a genuinely fresh context and a NEW report filename +node scripts/engineering-workflow-state.mjs advance skill-lease-retry PLAN_APPROVED --artifact .tmp/agent-workflows/skill-lease-retry/plan-review-2.md --reviewer-id codex-reviewer-2 +# DESIGN_ONLY -> FINAL_GATE -> HUMAN_APPROVAL +node scripts/engineering-workflow-state.mjs advance skill-lease-retry PASSED --artifact .tmp/agent-workflows/skill-lease-retry/final-checks.json +``` + +The helper validates allowed state transitions and reviewer/artifact presence. It does **not** autonomously run tests or prove reviewer independence. The orchestrator must inspect actual logs, returned agent identities, approved source/plan, and required exit codes before recording `VERIFIED`, `PLAN_APPROVED`, `REVIEW_APPROVED` or `PASSED`. In `MEDIUM`/`HIGH_RISK`, expected stages after plan approval are `IMPLEMENT -> VERIFY -> CODE_REVIEW -> FINAL_GATE`; `VERIFY VERIFIED` requires a real ignored verification artifact; independent code review requires a new report and actual reviewer identifier. + +## Pause and interruption + +If the host interrupts a stage, record a pause if execution permissions still allow it: + +```sh +node scripts/engineering-workflow-state.mjs pause skill-lease-retry USAGE_LIMIT +node scripts/engineering-workflow-state.mjs resume skill-lease-retry +``` + +Supported reasons: `USAGE_LIMIT`, `REVIEW_CAPACITY`, `PERMISSION`, `ENVIRONMENT`, `INTERRUPTED`. If tools are unavailable and pause cannot be recorded, the last *completed* checkpoint is still authoritative. Do not create a synthetic reviewer verdict. + +- `READY`: fingerprint and artifacts match. Resume the **reported current stage**, not from `INSPECT`. +- `IN_PROGRESS_EDIT`: a partial diff exists in `PLAN`, `CORRECT_DESIGN`, `IMPLEMENT` or `CORRECT`. Inspect that diff; continue the same writer stage. Its previous approval cannot cover the changed implementation. Do not discard partial work. +- `BLOCKED_REVISION_CHANGED`: a revision changed while a nonwriting stage was pending. Stop; inspect the change. Do not silently reuse review or verification approval. +- `BLOCKED_EVIDENCE_CHANGED`: a recorded review/plan/check artifact was modified, deleted, or moved. Stop; preserve review history. Never convert a missing report into `APPROVED`. +- `HUMAN_APPROVAL`: terminal human gate. Resume never commits, pushes or authorizes implementation/deployment. + +If an independent reviewer invocation was interrupted **before** a completed stage checkpoint, that attempt has **no approved verdict**. Re-dispatch to a fresh reviewer context, retaining the prior incomplete evidence separately. On recovery from a usage limit, the human may need to press Continue or start a new session with this task slug; this is not unattended scheduled execution. + +## Source changes and explicit rebaseline + +When revision/evidence integrity checks fail, first inspect `git status`, complete diff, reviewer artifacts and source fingerprints. Unplanned changes during review invalidate that review. If source truly changed and re-review is required, an authorized agent may explicitly rebaseline: + +```sh +node scripts/engineering-workflow-state.mjs rebaseline skill-lease-retry PLAN --reason "Source changed; re-review required" +``` + +For implementation mode, an approved operator may choose `PLAN` or `VERIFY` depending on the source change. `PLAN` must record the current plan again and then obtain fresh independent approval. `VERIFY` is allowed only when an unchanged approved plan still applies, and it preserves plan approval while invalidating code review and test verification; inspect this assumption explicitly before choosing it. **Rebaseline clears stale approvals and verification as appropriate, retains the used correction count and full history, and never permits HUMAN_APPROVAL to be bypassed.** Prior reviewer artifacts must remain byte-for-byte unchanged; rebaseline is not a way to erase adverse findings. If the original plan has materially changed, record the updated plan explicitly before the new review and do not treat its old approval as valid. Where the root problem is ambiguous policy, production authority or restricted execution, stop for human action rather than rebaseline. + +## Final checkpoint invariants + +1. Reviewer identities must be verified from real fresh subagent/session execution; an ID string in JSON alone is not proof. +2. Accepted review applies only to the reviewed revision and plan artifact. Implementation changes require verification and a fresh code-review approval. +3. Test artifacts must contain independently checked commands, environment, exit codes, skip/fail accounting, and applicable final database state. `AUTHOR_REPORTED` and `NOT_RUN` cannot satisfy mandatory gates. +4. Correction budget is two total automatic fix waves per task; `init` and `rebaseline` do not reset it. +5. Any failure to validate state, files, gitignored boundaries, recorded hashes, or permissions **fails closed** and must not advance the workflow. +6. Do not serialize secrets or large customer payloads; keep temporary artifacts gitignored and clean them only after the workflow is finalized or explicitly abandoned (SKILL.md step 3). diff --git a/.agents/skills/engineering-workflow/references/review.md b/.agents/skills/engineering-workflow/references/review.md new file mode 100644 index 0000000..c1bd5c0 --- /dev/null +++ b/.agents/skills/engineering-workflow/references/review.md @@ -0,0 +1,21 @@ +# Independent code review + +## Review package + +- Task brief and approved acceptance criteria; BASE and current target revision; complete diff with context (including staged, unstaged and listed untracked files); related callers, schema/SQL, tests and exact verification logs. +- Reviewer works in a fresh context, read-only against implementation files. When repro tests are needed, use a separate disposable worktree/database and keep them out of the author's working tree until reviewed. +- Reviewer should verify **spec compliance first**, then correctness/safety/test quality. Use `ponytail-review` as the quality lens and relevant domain guidance as needed; don't launch a repo-wide `ponytail-audit` by default. + +## Required checks + +Trace real inputs, business identity, SQL ownership and externally visible effects. Challenge missing/contradictory evidence, replay, cancellation/rollback, lock ordering, stale state, privilege checks and migrations as relevant. Reproduce material findings. Report command/execution provenance and whether cases are newly executed or read from author logs. + +## Findings and verdict + +Each finding: severity (P0/P1/P2/observation), file/location, concrete scenario and actual-versus-required behavior, impacted invariant and reproducible assertion or reason not executable. Duplicate/inapplicable findings should be ruled with evidence, not counted as blockers indefinitely. + +`REVIEW_APPROVED` means no P0/P1 or acceptance-blocking P2 **for the reviewed revision only**. `REVIEW_REQUEST_CHANGES` includes an ordered consolidated fix list. A passing test count alone cannot establish approval; later code edits invalidate the verdict. Security/policy questions must not be silently fixed by changing requirements. + +## Interrupted reviewers and checkpoint evidence + +A reviewer invoked before a usage-limit interruption is **not approved** unless its independent verdict and evidence were successfully checkpointed. On resume, rerun the unfinished review in a fresh context. Record each reviewer response under a distinct ignored filename, include its actual reviewer/session identity, and verify reviewed source fingerprint before accepting the verdict. The ledger only stores claimed identity; real provenance must be checked in host execution history. See [resume.md](resume.md). diff --git a/.agents/skills/frontend-design/LICENSE.txt b/.agents/skills/frontend-design/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/.agents/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..a533345 --- /dev/null +++ b/.agents/skills/frontend-design/SKILL.md @@ -0,0 +1,71 @@ +--- +name: frontend-design +description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults. +license: Complete terms in LICENSE.txt +--- + +# Frontend Design + +Approach this as the design lead at a design studio known for giving every client a distinct visual identity that is not mistaken for anyone else's. This client has already rejected proposals that felt cliché or templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take aesthetic risk if justified. + +## Ground your designs in the subject matter + +If the brief does not identify what the product or subject matter is, identify it yourself before designing, and confirm with the client. You can come up with one concrete subject, the design's audience, and the design's primary job, as a proposal. If there's any information in your memory about the client's preferences or context about what they're building, use that as a hint. The subject's industry, subject matter, materials, and vernacular are where distinctive visual choices come from — a design for a toy for girls aged 8–11 will be very aesthetically different from a dashboard for financial analysts. Build with the brief's real content and subject matter throughout. + +## Design principles + +For web designs, the hero is the first thing viewers will see. Open with the most characteristic thing in the subject's world, in the form that is most appropriate: a headline, an image, an animation, a live demo, an interactive moment, or other treatments. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the default treatment, so only use it if that's truly the best option. + +Typography carries the personality of the page. You don't need a different typeface for display or headline text and body content: use one family or two, and if two, make them clearly distinct. + +Choose your typefaces deliberately, not the default families you would reach for on any other project, and set a clear type scale following the default guidance of The Elements of Typographic Style with intentional weights, widths, and spacing. When type is used as a headline or visual element, use the type treatment itself as an active part of the design, not a neutral delivery vehicle for the content. + +Default to line lengths of less than 80 characters. Serif typefaces can have slightly longer line lengths; give serif body text slightly more line-height than a sans-serif. + +Avoid these default typographic treatments; they are the commonest tells of a generated page: +- Accenting just a single word or phrase in a headline, like putting one word in italic/bold or a different color. +- Using all caps for labels. +- Adding unnecessary typographic labels above content. + +Visual structure is information. Structural devices like outlines, borders, numbering, eyebrows, dividers, labels, etc., encode useful information about the content rather than decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence — like a stepped process or a timeline. Before adding numbered markers, check the content really is a sequence. + +Use non-user-triggered motion sparingly and deliberately, only to draw attention. A single orchestrated moment — one page-load sequence or one reveal — lands better than scattered effects; fade-and-slide-up entrances on each section and hover transitions on every card are the generic default and read as AI-generated. Motion that answers a person's action (opening, expanding, confirming) is welcome when it shows what changed. + +Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy and placeholder content. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance. + +## Process: plan, review against the brief, build, critique + +For calibration, AI-generated design right now clusters around some traits: +1. a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta or warm-clay accent (often near #D97757 — Anthropic's own Claude-interaction accent, so on a user's brief it reads as a tell); +2. a near-black background with a single bright acid-green or vermilion accent; +3. a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns; +4. the SaaS-card kit: content chopped into identical rounded cards, one border-radius on everything regardless of hierarchy, the same soft grey shadow (rgba(0,0,0,.1)) under each, and gradient washes as decoration; +5. template chrome that appears whatever the subject: a tracked-out ALL-CAPS eyebrow label above every heading; meta strings joined with middle dots ('A · B · C'); labels built as 'WORD — fragment' with a spaced em dash; tinted near-black (#0B0B0B, #111) standing in for black; a monospace face for small data labels; a '→' appended to link and button text. + +All traits are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. As with a hired human designer, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn. + +Work in two passes. First, brainstorm a short design plan based on the client's design brief: create a compact token system with color, type, layout, and principles. +- Color: describe the core base palette as 4–6 named hex values. +- Type: the typefaces and their roles. +- Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Include alignment guidance; should the content be left aligned, center aligned, justified? +- Principles: the high-level guidance for what makes this page unique. + +Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan. + +When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and an element-based selector like .cta). This can happen often with padding/margin between sections. + +## Restraint and self-critique + +Spend your boldness in one place. Let one element be the memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected, visually accessible, harmonious color palettes. Critique your own work as you build, taking screenshots to review if your environment supports it — a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creatives have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes. + +## More on writing in design + +Words appear in a design for one reason: to make it easier to understand and use. They are design content, not decoration. Bring the same intentionality and minimalism to copywriting that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience. + +Write from the end user's perspective. Name things by what users will understand in simple language, not by how the system is built. A user manages notifications, not webhook config. Describe what something is or does in plain terms rather than selling it. Being specific and legible to new users is always better than being clever. + +Use active voice as default. A CTA says exactly what happens when it is used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around. + +Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act. + +Keep the tone conversational: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each written element do exactly one job. diff --git a/.agents/skills/grill-me/LICENSE b/.agents/skills/grill-me/LICENSE deleted file mode 100644 index f1dd2c0..0000000 --- a/.agents/skills/grill-me/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Matt Pocock - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/.agents/skills/grill-me/SKILL.md b/.agents/skills/grill-me/SKILL.md deleted file mode 100644 index ba9931e..0000000 --- a/.agents/skills/grill-me/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: grill-me -description: A relentless interview to sharpen a plan or design. -disable-model-invocation: true ---- - -Read [../grilling/SKILL.md](../grilling/SKILL.md) and follow it. diff --git a/.agents/skills/grill-me/agents/openai.yaml b/.agents/skills/grill-me/agents/openai.yaml deleted file mode 100644 index 4d6fb0c..0000000 --- a/.agents/skills/grill-me/agents/openai.yaml +++ /dev/null @@ -1,5 +0,0 @@ -interface: - display_name: "Grill Me" - short_description: "Sharpen a plan through interview" -policy: - allow_implicit_invocation: false diff --git a/.agents/skills/ship-pr/SKILL.md b/.agents/skills/ship-pr/SKILL.md new file mode 100644 index 0000000..e5a2d14 --- /dev/null +++ b/.agents/skills/ship-pr/SKILL.md @@ -0,0 +1,38 @@ +--- +name: ship-pr +description: Prepare a finished task branch for a pull request (rebase, guards, pre-PR checks, review prerequisites, PR description from the template) and push or open the PR only after explicit user authorization. Use when a T1/T2 task is done; T0 changes may use it too. +--- + +# Ship a PR + +Never push, open or merge a PR without the user's explicit go-ahead in this session. Merging is always the human's. + +## 1. Bring the branch up to date +- In the task worktree, the tree is clean and everything is committed. +- `git fetch origin && git rebase origin/main`. On a conflict in migrations, T2 code or anything you cannot resolve with certainty, stop and report. + +## 2. Guards +- New migrations: the number follows the latest on `origin/main`, the journal entry is appended with a newer `when`. Run `node scripts/check-migration-history.mjs origin/main HEAD`. +- `node scripts/check-pr-review-signals.mjs origin/main HEAD`. If it lists T2-area files but the task was classified lower, reclassify and meet the T2 obligations before continuing. Treat a CI or guard change as needing an independent review. + +## 3. Checks +Run the pre-PR set and any size-specific checks from AGENTS.md *Verification commands*. Any FAIL stops here. Report every planned check as PASS, FAIL or NOT RUN with the reason. + +## 4. Review prerequisites +- T1: `ponytail-review` of your own diff. +- T2, CI or guard changes: `codex-review` on the current HEAD. If commits landed after that review, review again. +- Auth, user ownership, cron auth or secrets: also run `/security-review` in Claude Code. + +## 5. PR description +Write `.tmp//pr.md` from `.github/pull_request_template.md`, including the review guide. Use only evidence from commands you actually ran; hosted CI is NOT RUN until the PR exists. Title: a Conventional Commit line. + +## 6. Ask, then ship +Show the user the branch, title and a three-line summary, and ask to push and open the PR. Only after a yes: +```sh +git push -u origin / # always an explicit branch, never a bare push +gh pr create --base main --head / --title "" --body-file .tmp/<task-slug>/pr.md +``` +(or the host's GitHub tools). Report the PR URL. Watch checks (`gh pr checks`) only if asked; never merge. + +## 7. After the merge +When the user confirms the merge: remove the worktree with `git worktree remove`, delete the local branch, and delete the task's `.tmp` files. diff --git a/.agents/skills/vercel-react-best-practices/LICENSE b/.agents/skills/vercel-react-best-practices/LICENSE new file mode 100644 index 0000000..c11f389 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/LICENSE @@ -0,0 +1,10 @@ +vercel-react-best-practices is vendored from https://github.com/vercel-labs/agent-skills +(revision 063bee94c3f4df8453406c830b0a7df0f2860278, path skills/react-best-practices). + +Upstream declares the MIT license in its repository README ("## License: MIT") and in +SKILL.md frontmatter (`license: MIT`), but ships no LICENSE file at that revision, so no +license text or copyright notice is reproduced here. MIT terms: +https://opensource.org/license/mit + +This note is repository-authored. It applies to the vendored skill files in this +directory, not to the application. diff --git a/.agents/skills/vercel-react-best-practices/README.md b/.agents/skills/vercel-react-best-practices/README.md new file mode 100644 index 0000000..f283e1c --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/README.md @@ -0,0 +1,123 @@ +# React Best Practices + +A structured repository for creating and maintaining React Best Practices optimized for agents and LLMs. + +## Structure + +- `rules/` - Individual rule files (one per rule) + - `_sections.md` - Section metadata (titles, impacts, descriptions) + - `_template.md` - Template for creating new rules + - `area-description.md` - Individual rule files +- `src/` - Build scripts and utilities +- `metadata.json` - Document metadata (version, organization, abstract) +- __`AGENTS.md`__ - Compiled output (generated) +- __`test-cases.json`__ - Test cases for LLM evaluation (generated) + +## Getting Started + +1. Install dependencies: + ```bash + pnpm install + ``` + +2. Build AGENTS.md from rules: + ```bash + pnpm build + ``` + +3. Validate rule files: + ```bash + pnpm validate + ``` + +4. Extract test cases: + ```bash + pnpm extract-tests + ``` + +## Creating a New Rule + +1. Copy `rules/_template.md` to `rules/area-description.md` +2. Choose the appropriate area prefix: + - `async-` for Eliminating Waterfalls (Section 1) + - `bundle-` for Bundle Size Optimization (Section 2) + - `server-` for Server-Side Performance (Section 3) + - `client-` for Client-Side Data Fetching (Section 4) + - `rerender-` for Re-render Optimization (Section 5) + - `rendering-` for Rendering Performance (Section 6) + - `js-` for JavaScript Performance (Section 7) + - `advanced-` for Advanced Patterns (Section 8) +3. Fill in the frontmatter and content +4. Ensure you have clear examples with explanations +5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json + +## Rule File Structure + +Each rule file should follow this structure: + +```markdown +--- +title: Rule Title Here +impact: MEDIUM +impactDescription: Optional description +tags: tag1, tag2, tag3 +--- + +## Rule Title Here + +Brief explanation of the rule and why it matters. + +**Incorrect (description of what's wrong):** + +```typescript +// Bad code example +``` + +**Correct (description of what's right):** + +```typescript +// Good code example +``` + +Optional explanatory text after examples. + +Reference: [Link](https://example.com) + +## File Naming Convention + +- Files starting with `_` are special (excluded from build) +- Rule files: `area-description.md` (e.g., `async-parallel.md`) +- Section is automatically inferred from filename prefix +- Rules are sorted alphabetically by title within each section +- IDs (e.g., 1.1, 1.2) are auto-generated during build + +## Impact Levels + +- `CRITICAL` - Highest priority, major performance gains +- `HIGH` - Significant performance improvements +- `MEDIUM-HIGH` - Moderate-high gains +- `MEDIUM` - Moderate performance improvements +- `LOW-MEDIUM` - Low-medium gains +- `LOW` - Incremental improvements + +## Scripts + +- `pnpm build` - Compile rules into AGENTS.md +- `pnpm validate` - Validate all rule files +- `pnpm extract-tests` - Extract test cases for LLM evaluation +- `pnpm dev` - Build and validate + +## Contributing + +When adding or modifying rules: + +1. Use the correct filename prefix for your section +2. Follow the `_template.md` structure +3. Include clear bad/good examples with explanations +4. Add appropriate tags +5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json +6. Rules are automatically sorted by title - no need to manage numbers! + +## Acknowledgments + +Originally created by [@shuding](https://x.com/shuding) at [Vercel](https://vercel.com). diff --git a/.agents/skills/vercel-react-best-practices/SKILL.md b/.agents/skills/vercel-react-best-practices/SKILL.md new file mode 100644 index 0000000..eaa162d --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/SKILL.md @@ -0,0 +1,149 @@ +--- +name: vercel-react-best-practices +description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements. +license: MIT +metadata: + author: vercel + version: "1.0.0" +--- + +# Vercel React Best Practices + +Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 70 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation. + +## When to Apply + +Reference these guidelines when: +- Writing new React components or Next.js pages +- Implementing data fetching (client or server-side) +- Reviewing code for performance issues +- Refactoring existing React/Next.js code +- Optimizing bundle size or load times + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | Eliminating Waterfalls | CRITICAL | `async-` | +| 2 | Bundle Size Optimization | CRITICAL | `bundle-` | +| 3 | Server-Side Performance | HIGH | `server-` | +| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` | +| 5 | Re-render Optimization | MEDIUM | `rerender-` | +| 6 | Rendering Performance | MEDIUM | `rendering-` | +| 7 | JavaScript Performance | LOW-MEDIUM | `js-` | +| 8 | Advanced Patterns | LOW | `advanced-` | + +## Quick Reference + +### 1. Eliminating Waterfalls (CRITICAL) + +- `async-cheap-condition-before-await` - Check cheap sync conditions before awaiting flags or remote values +- `async-defer-await` - Move await into branches where actually used +- `async-parallel` - Use Promise.all() for independent operations +- `async-dependencies` - Use better-all for partial dependencies +- `async-api-routes` - Start promises early, await late in API routes +- `async-suspense-boundaries` - Use Suspense to stream content + +### 2. Bundle Size Optimization (CRITICAL) + +- `bundle-barrel-imports` - Import directly, avoid barrel files +- `bundle-analyzable-paths` - Prefer statically analyzable import and file-system paths to avoid broad bundles and traces +- `bundle-dynamic-imports` - Use next/dynamic for heavy components +- `bundle-defer-third-party` - Load analytics/logging after hydration +- `bundle-conditional` - Load modules only when feature is activated +- `bundle-preload` - Preload on hover/focus for perceived speed + +### 3. Server-Side Performance (HIGH) + +- `server-auth-actions` - Authenticate server actions like API routes +- `server-cache-react` - Use React.cache() for per-request deduplication +- `server-cache-lru` - Use LRU cache for cross-request caching +- `server-dedup-props` - Avoid duplicate serialization in RSC props +- `server-hoist-static-io` - Hoist static I/O (fonts, logos) to module level +- `server-no-shared-module-state` - Avoid module-level mutable request state in RSC/SSR +- `server-serialization` - Minimize data passed to client components +- `server-parallel-fetching` - Restructure components to parallelize fetches +- `server-parallel-nested-fetching` - Chain nested fetches per item in Promise.all +- `server-after-nonblocking` - Use after() for non-blocking operations + +### 4. Client-Side Data Fetching (MEDIUM-HIGH) + +- `client-swr-dedup` - Use SWR for automatic request deduplication +- `client-event-listeners` - Deduplicate global event listeners +- `client-passive-event-listeners` - Use passive listeners for scroll +- `client-localstorage-schema` - Version and minimize localStorage data + +### 5. Re-render Optimization (MEDIUM) + +- `rerender-defer-reads` - Don't subscribe to state only used in callbacks +- `rerender-memo` - Extract expensive work into memoized components +- `rerender-memo-with-default-value` - Hoist default non-primitive props +- `rerender-dependencies` - Use primitive dependencies in effects +- `rerender-derived-state` - Subscribe to derived booleans, not raw values +- `rerender-derived-state-no-effect` - Derive state during render, not effects +- `rerender-functional-setstate` - Use functional setState for stable callbacks +- `rerender-lazy-state-init` - Pass function to useState for expensive values +- `rerender-simple-expression-in-memo` - Avoid memo for simple primitives +- `rerender-split-combined-hooks` - Split hooks with independent dependencies +- `rerender-move-effect-to-event` - Put interaction logic in event handlers +- `rerender-transitions` - Use startTransition for non-urgent updates +- `rerender-use-deferred-value` - Defer expensive renders to keep input responsive +- `rerender-use-ref-transient-values` - Use refs for transient frequent values +- `rerender-no-inline-components` - Don't define components inside components + +### 6. Rendering Performance (MEDIUM) + +- `rendering-animate-svg-wrapper` - Animate div wrapper, not SVG element +- `rendering-content-visibility` - Use content-visibility for long lists +- `rendering-hoist-jsx` - Extract static JSX outside components +- `rendering-svg-precision` - Reduce SVG coordinate precision +- `rendering-hydration-no-flicker` - Use inline script for client-only data +- `rendering-hydration-suppress-warning` - Suppress expected mismatches +- `rendering-activity` - Use Activity component for show/hide +- `rendering-conditional-render` - Use ternary, not && for conditionals +- `rendering-usetransition-loading` - Prefer useTransition for loading state +- `rendering-resource-hints` - Use React DOM resource hints for preloading +- `rendering-script-defer-async` - Use defer or async on script tags + +### 7. JavaScript Performance (LOW-MEDIUM) + +- `js-batch-dom-css` - Group CSS changes via classes or cssText +- `js-index-maps` - Build Map for repeated lookups +- `js-cache-property-access` - Cache object properties in loops +- `js-cache-function-results` - Cache function results in module-level Map +- `js-cache-storage` - Cache localStorage/sessionStorage reads +- `js-combine-iterations` - Combine multiple filter/map into one loop +- `js-length-check-first` - Check array length before expensive comparison +- `js-early-exit` - Return early from functions +- `js-hoist-regexp` - Hoist RegExp creation outside loops +- `js-min-max-loop` - Use loop for min/max instead of sort +- `js-set-map-lookups` - Use Set/Map for O(1) lookups +- `js-tosorted-immutable` - Use toSorted() for immutability +- `js-flatmap-filter` - Use flatMap to map and filter in one pass +- `js-request-idle-callback` - Defer non-critical work to browser idle time + +### 8. Advanced Patterns (LOW) + +- `advanced-effect-event-deps` - Don't put `useEffectEvent` results in effect deps +- `advanced-event-handler-refs` - Store event handlers in refs +- `advanced-init-once` - Initialize app once per app load +- `advanced-use-latest` - useLatest for stable callback refs + +## How to Use + +Read individual rule files for detailed explanations and code examples: + +``` +rules/async-parallel.md +rules/bundle-barrel-imports.md +``` + +Each rule file contains: +- Brief explanation of why it matters +- Incorrect code example with explanation +- Correct code example with explanation +- Additional context and references + +## Full Compiled Document + +Read only the `rules/*.md` files the task needs. The upstream compiled `AGENTS.md` (every rule expanded, ~108 KB) is intentionally not vendored in this repository. diff --git a/.agents/skills/vercel-react-best-practices/metadata.json b/.agents/skills/vercel-react-best-practices/metadata.json new file mode 100644 index 0000000..3bec38b --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/metadata.json @@ -0,0 +1,15 @@ +{ + "version": "1.0.0", + "organization": "Vercel Engineering", + "date": "January 2026", + "abstract": "Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.", + "references": [ + "https://react.dev", + "https://nextjs.org", + "https://swr.vercel.app", + "https://github.com/shuding/better-all", + "https://github.com/isaacs/node-lru-cache", + "https://vercel.com/blog/how-we-optimized-package-imports-in-next-js", + "https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast" + ] +} diff --git a/.agents/skills/vercel-react-best-practices/rules/_sections.md b/.agents/skills/vercel-react-best-practices/rules/_sections.md new file mode 100644 index 0000000..4d20c14 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/_sections.md @@ -0,0 +1,46 @@ +# Sections + +This file defines all sections, their ordering, impact levels, and descriptions. +The section ID (in parentheses) is the filename prefix used to group rules. + +--- + +## 1. Eliminating Waterfalls (async) + +**Impact:** CRITICAL +**Description:** Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains. + +## 2. Bundle Size Optimization (bundle) + +**Impact:** CRITICAL +**Description:** Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint. + +## 3. Server-Side Performance (server) + +**Impact:** HIGH +**Description:** Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times. + +## 4. Client-Side Data Fetching (client) + +**Impact:** MEDIUM-HIGH +**Description:** Automatic deduplication and efficient data fetching patterns reduce redundant network requests. + +## 5. Re-render Optimization (rerender) + +**Impact:** MEDIUM +**Description:** Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness. + +## 6. Rendering Performance (rendering) + +**Impact:** MEDIUM +**Description:** Optimizing the rendering process reduces the work the browser needs to do. + +## 7. JavaScript Performance (js) + +**Impact:** LOW-MEDIUM +**Description:** Micro-optimizations for hot paths can add up to meaningful improvements. + +## 8. Advanced Patterns (advanced) + +**Impact:** LOW +**Description:** Advanced patterns for specific cases that require careful implementation. diff --git a/.agents/skills/vercel-react-best-practices/rules/_template.md b/.agents/skills/vercel-react-best-practices/rules/_template.md new file mode 100644 index 0000000..1e9e707 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/_template.md @@ -0,0 +1,28 @@ +--- +title: Rule Title Here +impact: MEDIUM +impactDescription: Optional description of impact (e.g., "20-50% improvement") +tags: tag1, tag2 +--- + +## Rule Title Here + +**Impact: MEDIUM (optional impact description)** + +Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications. + +**Incorrect (description of what's wrong):** + +```typescript +// Bad code example here +const bad = example() +``` + +**Correct (description of what's right):** + +```typescript +// Good code example here +const good = example() +``` + +Reference: [Link to documentation or resource](https://example.com) diff --git a/.agents/skills/vercel-react-best-practices/rules/advanced-effect-event-deps.md b/.agents/skills/vercel-react-best-practices/rules/advanced-effect-event-deps.md new file mode 100644 index 0000000..24d901b --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/advanced-effect-event-deps.md @@ -0,0 +1,56 @@ +--- +title: Do Not Put Effect Events in Dependency Arrays +impact: LOW +impactDescription: avoids unnecessary effect re-runs and lint errors +tags: advanced, hooks, useEffectEvent, dependencies, effects +--- + +## Do Not Put Effect Events in Dependency Arrays + +Effect Event functions do not have a stable identity. Their identity intentionally changes on every render. Do not include the function returned by `useEffectEvent` in a `useEffect` dependency array. Keep the actual reactive values as dependencies and call the Effect Event from inside the effect body or subscriptions created by that effect. + +**Incorrect (Effect Event added as a dependency):** + +```tsx +import { useEffect, useEffectEvent } from 'react' + +function ChatRoom({ roomId, onConnected }: { + roomId: string + onConnected: () => void +}) { + const handleConnected = useEffectEvent(onConnected) + + useEffect(() => { + const connection = createConnection(roomId) + connection.on('connected', handleConnected) + connection.connect() + + return () => connection.disconnect() + }, [roomId, handleConnected]) +} +``` + +Including the Effect Event in dependencies makes the effect re-run every render and triggers the React Hooks lint rule. + +**Correct (depend on reactive values, not the Effect Event):** + +```tsx +import { useEffect, useEffectEvent } from 'react' + +function ChatRoom({ roomId, onConnected }: { + roomId: string + onConnected: () => void +}) { + const handleConnected = useEffectEvent(onConnected) + + useEffect(() => { + const connection = createConnection(roomId) + connection.on('connected', handleConnected) + connection.connect() + + return () => connection.disconnect() + }, [roomId]) +} +``` + +Reference: [React useEffectEvent: Effect Event in deps](https://react.dev/reference/react/useEffectEvent#effect-event-in-deps) diff --git a/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md b/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md new file mode 100644 index 0000000..97e7ade --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md @@ -0,0 +1,55 @@ +--- +title: Store Event Handlers in Refs +impact: LOW +impactDescription: stable subscriptions +tags: advanced, hooks, refs, event-handlers, optimization +--- + +## Store Event Handlers in Refs + +Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes. + +**Incorrect (re-subscribes on every render):** + +```tsx +function useWindowEvent(event: string, handler: (e) => void) { + useEffect(() => { + window.addEventListener(event, handler) + return () => window.removeEventListener(event, handler) + }, [event, handler]) +} +``` + +**Correct (stable subscription):** + +```tsx +function useWindowEvent(event: string, handler: (e) => void) { + const handlerRef = useRef(handler) + useEffect(() => { + handlerRef.current = handler + }, [handler]) + + useEffect(() => { + const listener = (e) => handlerRef.current(e) + window.addEventListener(event, listener) + return () => window.removeEventListener(event, listener) + }, [event]) +} +``` + +**Alternative: use `useEffectEvent` if you're on latest React:** + +```tsx +import { useEffectEvent } from 'react' + +function useWindowEvent(event: string, handler: (e) => void) { + const onEvent = useEffectEvent(handler) + + useEffect(() => { + window.addEventListener(event, onEvent) + return () => window.removeEventListener(event, onEvent) + }, [event]) +} +``` + +`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler. diff --git a/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md b/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md new file mode 100644 index 0000000..73ee38e --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md @@ -0,0 +1,42 @@ +--- +title: Initialize App Once, Not Per Mount +impact: LOW-MEDIUM +impactDescription: avoids duplicate init in development +tags: initialization, useEffect, app-startup, side-effects +--- + +## Initialize App Once, Not Per Mount + +Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead. + +**Incorrect (runs twice in dev, re-runs on remount):** + +```tsx +function Comp() { + useEffect(() => { + loadFromStorage() + checkAuthToken() + }, []) + + // ... +} +``` + +**Correct (once per app load):** + +```tsx +let didInit = false + +function Comp() { + useEffect(() => { + if (didInit) return + didInit = true + loadFromStorage() + checkAuthToken() + }, []) + + // ... +} +``` + +Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application) diff --git a/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md b/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md new file mode 100644 index 0000000..9c7cb50 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md @@ -0,0 +1,39 @@ +--- +title: useEffectEvent for Stable Callback Refs +impact: LOW +impactDescription: prevents effect re-runs +tags: advanced, hooks, useEffectEvent, refs, optimization +--- + +## useEffectEvent for Stable Callback Refs + +Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures. + +**Incorrect (effect re-runs on every callback change):** + +```tsx +function SearchInput({ onSearch }: { onSearch: (q: string) => void }) { + const [query, setQuery] = useState('') + + useEffect(() => { + const timeout = setTimeout(() => onSearch(query), 300) + return () => clearTimeout(timeout) + }, [query, onSearch]) +} +``` + +**Correct (using React's useEffectEvent):** + +```tsx +import { useEffectEvent } from 'react'; + +function SearchInput({ onSearch }: { onSearch: (q: string) => void }) { + const [query, setQuery] = useState('') + const onSearchEvent = useEffectEvent(onSearch) + + useEffect(() => { + const timeout = setTimeout(() => onSearchEvent(query), 300) + return () => clearTimeout(timeout) + }, [query]) +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md b/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md new file mode 100644 index 0000000..6feda1e --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md @@ -0,0 +1,38 @@ +--- +title: Prevent Waterfall Chains in API Routes +impact: CRITICAL +impactDescription: 2-10× improvement +tags: api-routes, server-actions, waterfalls, parallelization +--- + +## Prevent Waterfall Chains in API Routes + +In API routes and Server Actions, start independent operations immediately, even if you don't await them yet. + +**Incorrect (config waits for auth, data waits for both):** + +```typescript +export async function GET(request: Request) { + const session = await auth() + const config = await fetchConfig() + const data = await fetchData(session.user.id) + return Response.json({ data, config }) +} +``` + +**Correct (auth and config start immediately):** + +```typescript +export async function GET(request: Request) { + const sessionPromise = auth() + const configPromise = fetchConfig() + const session = await sessionPromise + const [config, data] = await Promise.all([ + configPromise, + fetchData(session.user.id) + ]) + return Response.json({ data, config }) +} +``` + +For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization). diff --git a/.agents/skills/vercel-react-best-practices/rules/async-cheap-condition-before-await.md b/.agents/skills/vercel-react-best-practices/rules/async-cheap-condition-before-await.md new file mode 100644 index 0000000..5799d7f --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/async-cheap-condition-before-await.md @@ -0,0 +1,37 @@ +--- +title: Check Cheap Conditions Before Async Flags +impact: HIGH +impactDescription: avoids unnecessary async work when a synchronous guard already fails +tags: async, await, feature-flags, short-circuit, conditional +--- + +## Check Cheap Conditions Before Async Flags + +When a branch uses `await` for a flag or remote value and also requires a **cheap synchronous** condition (local props, request metadata, already-loaded state), evaluate the cheap condition **first**. Otherwise you pay for the async call even when the compound condition can never be true. + +This is a specialization of [Defer Await Until Needed](./async-defer-await.md) for `flag && cheapCondition` style checks. + +**Incorrect:** + +```typescript +const someFlag = await getFlag() + +if (someFlag && someCondition) { + // ... +} +``` + +**Correct:** + +```typescript +if (someCondition) { + const someFlag = await getFlag() + if (someFlag) { + // ... + } +} +``` + +This matters when `getFlag` hits the network, a feature-flag service, or `React.cache` / DB work: skipping it when `someCondition` is false removes that cost on the cold path. + +Keep the original order if `someCondition` is expensive, depends on the flag, or you must run side effects in a fixed order. diff --git a/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md b/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md new file mode 100644 index 0000000..460b5e1 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md @@ -0,0 +1,82 @@ +--- +title: Defer Await Until Needed +impact: HIGH +impactDescription: avoids blocking unused code paths +tags: async, await, conditional, optimization +--- + +## Defer Await Until Needed + +Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them. + +**Incorrect (blocks both branches):** + +```typescript +async function handleRequest(userId: string, skipProcessing: boolean) { + const userData = await fetchUserData(userId) + + if (skipProcessing) { + // Returns immediately but still waited for userData + return { skipped: true } + } + + // Only this branch uses userData + return processUserData(userData) +} +``` + +**Correct (only blocks when needed):** + +```typescript +async function handleRequest(userId: string, skipProcessing: boolean) { + if (skipProcessing) { + // Returns immediately without waiting + return { skipped: true } + } + + // Fetch only when needed + const userData = await fetchUserData(userId) + return processUserData(userData) +} +``` + +**Another example (early return optimization):** + +```typescript +// Incorrect: always fetches permissions +async function updateResource(resourceId: string, userId: string) { + const permissions = await fetchPermissions(userId) + const resource = await getResource(resourceId) + + if (!resource) { + return { error: 'Not found' } + } + + if (!permissions.canEdit) { + return { error: 'Forbidden' } + } + + return await updateResourceData(resource, permissions) +} + +// Correct: fetches only when needed +async function updateResource(resourceId: string, userId: string) { + const resource = await getResource(resourceId) + + if (!resource) { + return { error: 'Not found' } + } + + const permissions = await fetchPermissions(userId) + + if (!permissions.canEdit) { + return { error: 'Forbidden' } + } + + return await updateResourceData(resource, permissions) +} +``` + +This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive. + +For `await getFlag()` combined with a cheap synchronous guard (`flag && someCondition`), see [Check Cheap Conditions Before Async Flags](./async-cheap-condition-before-await.md). diff --git a/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md b/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md new file mode 100644 index 0000000..0484eba --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md @@ -0,0 +1,51 @@ +--- +title: Dependency-Based Parallelization +impact: CRITICAL +impactDescription: 2-10× improvement +tags: async, parallelization, dependencies, better-all +--- + +## Dependency-Based Parallelization + +For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment. + +**Incorrect (profile waits for config unnecessarily):** + +```typescript +const [user, config] = await Promise.all([ + fetchUser(), + fetchConfig() +]) +const profile = await fetchProfile(user.id) +``` + +**Correct (config and profile run in parallel):** + +```typescript +import { all } from 'better-all' + +const { user, config, profile } = await all({ + async user() { return fetchUser() }, + async config() { return fetchConfig() }, + async profile() { + return fetchProfile((await this.$.user).id) + } +}) +``` + +**Alternative without extra dependencies:** + +We can also create all the promises first, and do `Promise.all()` at the end. + +```typescript +const userPromise = fetchUser() +const profilePromise = userPromise.then(user => fetchProfile(user.id)) + +const [user, config, profile] = await Promise.all([ + userPromise, + fetchConfig(), + profilePromise +]) +``` + +Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all) diff --git a/.agents/skills/vercel-react-best-practices/rules/async-parallel.md b/.agents/skills/vercel-react-best-practices/rules/async-parallel.md new file mode 100644 index 0000000..64133f6 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/async-parallel.md @@ -0,0 +1,28 @@ +--- +title: Promise.all() for Independent Operations +impact: CRITICAL +impactDescription: 2-10× improvement +tags: async, parallelization, promises, waterfalls +--- + +## Promise.all() for Independent Operations + +When async operations have no interdependencies, execute them concurrently using `Promise.all()`. + +**Incorrect (sequential execution, 3 round trips):** + +```typescript +const user = await fetchUser() +const posts = await fetchPosts() +const comments = await fetchComments() +``` + +**Correct (parallel execution, 1 round trip):** + +```typescript +const [user, posts, comments] = await Promise.all([ + fetchUser(), + fetchPosts(), + fetchComments() +]) +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md b/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md new file mode 100644 index 0000000..1fbc05b --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md @@ -0,0 +1,99 @@ +--- +title: Strategic Suspense Boundaries +impact: HIGH +impactDescription: faster initial paint +tags: async, suspense, streaming, layout-shift +--- + +## Strategic Suspense Boundaries + +Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads. + +**Incorrect (wrapper blocked by data fetching):** + +```tsx +async function Page() { + const data = await fetchData() // Blocks entire page + + return ( + <div> + <div>Sidebar</div> + <div>Header</div> + <div> + <DataDisplay data={data} /> + </div> + <div>Footer</div> + </div> + ) +} +``` + +The entire layout waits for data even though only the middle section needs it. + +**Correct (wrapper shows immediately, data streams in):** + +```tsx +function Page() { + return ( + <div> + <div>Sidebar</div> + <div>Header</div> + <div> + <Suspense fallback={<Skeleton />}> + <DataDisplay /> + </Suspense> + </div> + <div>Footer</div> + </div> + ) +} + +async function DataDisplay() { + const data = await fetchData() // Only blocks this component + return <div>{data.content}</div> +} +``` + +Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data. + +**Alternative (share promise across components):** + +```tsx +function Page() { + // Start fetch immediately, but don't await + const dataPromise = fetchData() + + return ( + <div> + <div>Sidebar</div> + <div>Header</div> + <Suspense fallback={<Skeleton />}> + <DataDisplay dataPromise={dataPromise} /> + <DataSummary dataPromise={dataPromise} /> + </Suspense> + <div>Footer</div> + </div> + ) +} + +function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) { + const data = use(dataPromise) // Unwraps the promise + return <div>{data.content}</div> +} + +function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) { + const data = use(dataPromise) // Reuses the same promise + return <div>{data.summary}</div> +} +``` + +Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together. + +**When NOT to use this pattern:** + +- Critical data needed for layout decisions (affects positioning) +- SEO-critical content above the fold +- Small, fast queries where suspense overhead isn't worth it +- When you want to avoid layout shift (loading → content jump) + +**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities. diff --git a/.agents/skills/vercel-react-best-practices/rules/bundle-analyzable-paths.md b/.agents/skills/vercel-react-best-practices/rules/bundle-analyzable-paths.md new file mode 100644 index 0000000..8276f15 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/bundle-analyzable-paths.md @@ -0,0 +1,63 @@ +--- +title: Prefer Statically Analyzable Paths +impact: HIGH +impactDescription: avoids accidental broad bundles and file traces +tags: bundle, nextjs, vite, webpack, rollup, esbuild, path +--- + +## Prefer Statically Analyzable Paths + +Build tools work best when import and file-system paths are obvious at build time. If you hide the real path inside a variable or compose it too dynamically, the tool either has to include a broad set of possible files, warn that it cannot analyze the import, or widen file tracing to stay safe. + +Prefer explicit maps or literal paths so the set of reachable files stays narrow and predictable. This is the same rule whether you are choosing modules with `import()` or reading files in server/build code. + +When analysis becomes too broad, the cost is real: +- Larger server bundles +- Slower builds +- Worse cold starts +- More memory use + +### Import Paths + +**Incorrect (the bundler cannot tell what may be imported):** + +```ts +const PAGE_MODULES = { + home: './pages/home', + settings: './pages/settings', +} as const + +const Page = await import(PAGE_MODULES[pageName]) +``` + +**Correct (use an explicit map of allowed modules):** + +```ts +const PAGE_MODULES = { + home: () => import('./pages/home'), + settings: () => import('./pages/settings'), +} as const + +const Page = await PAGE_MODULES[pageName]() +``` + +### File-System Paths + +**Incorrect (a 2-value enum still hides the final path from static analysis):** + +```ts +const baseDir = path.join(process.cwd(), 'content/' + contentKind) +``` + +**Correct (make each final path literal at the callsite):** + +```ts +const baseDir = + kind === ContentKind.Blog + ? path.join(process.cwd(), 'content/blog') + : path.join(process.cwd(), 'content/docs') +``` + +In Next.js server code, this matters for output file tracing too. `path.join(process.cwd(), someVar)` can widen the traced file set because Next.js statically analyze `import`, `require`, and `fs` usage. + +Reference: [Next.js output](https://nextjs.org/docs/app/api-reference/config/next-config-js/output), [Next.js dynamic imports](https://nextjs.org/learn/seo/dynamic-imports), [Vite features](https://vite.dev/guide/features.html), [esbuild API](https://esbuild.github.io/api/), [Rollup dynamic import vars](https://www.npmjs.com/package/@rollup/plugin-dynamic-import-vars), [Webpack dependency management](https://webpack.js.org/guides/dependency-management/) diff --git a/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md b/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md new file mode 100644 index 0000000..22d1050 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md @@ -0,0 +1,60 @@ +--- +title: Avoid Barrel File Imports +impact: CRITICAL +impactDescription: 200-800ms import cost, slow builds +tags: bundle, imports, tree-shaking, barrel-files, performance +--- + +## Avoid Barrel File Imports + +Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`). + +Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts. + +**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph. + +**Incorrect (imports entire library):** + +```tsx +import { Check, X, Menu } from 'lucide-react' +// Loads 1,583 modules, takes ~2.8s extra in dev +// Runtime cost: 200-800ms on every cold start + +import { Button, TextField } from '@mui/material' +// Loads 2,225 modules, takes ~4.2s extra in dev +``` + +**Correct - Next.js 13.5+ (recommended):** + +```js +// next.config.js - automatically optimizes barrel imports at build time +module.exports = { + experimental: { + optimizePackageImports: ['lucide-react', '@mui/material'] + } +} +``` + +```tsx +// Keep the standard imports - Next.js transforms them to direct imports +import { Check, X, Menu } from 'lucide-react' +// Full TypeScript support, no manual path wrangling +``` + +This is the recommended approach because it preserves TypeScript type safety and editor autocompletion while still eliminating the barrel import cost. + +**Correct - Direct imports (non-Next.js projects):** + +```tsx +import Button from '@mui/material/Button' +import TextField from '@mui/material/TextField' +// Loads only what you use +``` + +> **TypeScript warning:** Some libraries (notably `lucide-react`) don't ship `.d.ts` files for their deep import paths. Importing from `lucide-react/dist/esm/icons/check` resolves to an implicit `any` type, causing errors under `strict` or `noImplicitAny`. Prefer `optimizePackageImports` when available, or verify the library exports types for its subpaths before using direct imports. + +These optimizations provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR. + +Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`. + +Reference: [How we optimized package imports in Next.js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js) diff --git a/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md b/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md new file mode 100644 index 0000000..99d6fc9 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md @@ -0,0 +1,31 @@ +--- +title: Conditional Module Loading +impact: HIGH +impactDescription: loads large data only when needed +tags: bundle, conditional-loading, lazy-loading +--- + +## Conditional Module Loading + +Load large data or modules only when a feature is activated. + +**Example (lazy-load animation frames):** + +```tsx +function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) { + const [frames, setFrames] = useState<Frame[] | null>(null) + + useEffect(() => { + if (enabled && !frames && typeof window !== 'undefined') { + import('./animation-frames.js') + .then(mod => setFrames(mod.frames)) + .catch(() => setEnabled(false)) + } + }, [enabled, frames, setEnabled]) + + if (!frames) return <Skeleton /> + return <Canvas frames={frames} /> +} +``` + +The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed. diff --git a/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md b/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md new file mode 100644 index 0000000..db041d1 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md @@ -0,0 +1,49 @@ +--- +title: Defer Non-Critical Third-Party Libraries +impact: MEDIUM +impactDescription: loads after hydration +tags: bundle, third-party, analytics, defer +--- + +## Defer Non-Critical Third-Party Libraries + +Analytics, logging, and error tracking don't block user interaction. Load them after hydration. + +**Incorrect (blocks initial bundle):** + +```tsx +import { Analytics } from '@vercel/analytics/react' + +export default function RootLayout({ children }) { + return ( + <html> + <body> + {children} + <Analytics /> + </body> + </html> + ) +} +``` + +**Correct (loads after hydration):** + +```tsx +import dynamic from 'next/dynamic' + +const Analytics = dynamic( + () => import('@vercel/analytics/react').then(m => m.Analytics), + { ssr: false } +) + +export default function RootLayout({ children }) { + return ( + <html> + <body> + {children} + <Analytics /> + </body> + </html> + ) +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md b/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md new file mode 100644 index 0000000..60b6269 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md @@ -0,0 +1,35 @@ +--- +title: Dynamic Imports for Heavy Components +impact: CRITICAL +impactDescription: directly affects TTI and LCP +tags: bundle, dynamic-import, code-splitting, next-dynamic +--- + +## Dynamic Imports for Heavy Components + +Use `next/dynamic` to lazy-load large components not needed on initial render. + +**Incorrect (Monaco bundles with main chunk ~300KB):** + +```tsx +import { MonacoEditor } from './monaco-editor' + +function CodePanel({ code }: { code: string }) { + return <MonacoEditor value={code} /> +} +``` + +**Correct (Monaco loads on demand):** + +```tsx +import dynamic from 'next/dynamic' + +const MonacoEditor = dynamic( + () => import('./monaco-editor').then(m => m.MonacoEditor), + { ssr: false } +) + +function CodePanel({ code }: { code: string }) { + return <MonacoEditor value={code} /> +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md b/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md new file mode 100644 index 0000000..7000504 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md @@ -0,0 +1,50 @@ +--- +title: Preload Based on User Intent +impact: MEDIUM +impactDescription: reduces perceived latency +tags: bundle, preload, user-intent, hover +--- + +## Preload Based on User Intent + +Preload heavy bundles before they're needed to reduce perceived latency. + +**Example (preload on hover/focus):** + +```tsx +function EditorButton({ onClick }: { onClick: () => void }) { + const preload = () => { + if (typeof window !== 'undefined') { + void import('./monaco-editor') + } + } + + return ( + <button + onMouseEnter={preload} + onFocus={preload} + onClick={onClick} + > + Open Editor + </button> + ) +} +``` + +**Example (preload when feature flag is enabled):** + +```tsx +function FlagsProvider({ children, flags }: Props) { + useEffect(() => { + if (flags.editorEnabled && typeof window !== 'undefined') { + void import('./monaco-editor').then(mod => mod.init()) + } + }, [flags.editorEnabled]) + + return <FlagsContext.Provider value={flags}> + {children} + </FlagsContext.Provider> +} +``` + +The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed. diff --git a/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md b/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md new file mode 100644 index 0000000..aad4ae9 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md @@ -0,0 +1,74 @@ +--- +title: Deduplicate Global Event Listeners +impact: LOW +impactDescription: single listener for N components +tags: client, swr, event-listeners, subscription +--- + +## Deduplicate Global Event Listeners + +Use `useSWRSubscription()` to share global event listeners across component instances. + +**Incorrect (N instances = N listeners):** + +```tsx +function useKeyboardShortcut(key: string, callback: () => void) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && e.key === key) { + callback() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [key, callback]) +} +``` + +When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener. + +**Correct (N instances = 1 listener):** + +```tsx +import useSWRSubscription from 'swr/subscription' + +// Module-level Map to track callbacks per key +const keyCallbacks = new Map<string, Set<() => void>>() + +function useKeyboardShortcut(key: string, callback: () => void) { + // Register this callback in the Map + useEffect(() => { + if (!keyCallbacks.has(key)) { + keyCallbacks.set(key, new Set()) + } + keyCallbacks.get(key)!.add(callback) + + return () => { + const set = keyCallbacks.get(key) + if (set) { + set.delete(callback) + if (set.size === 0) { + keyCallbacks.delete(key) + } + } + } + }, [key, callback]) + + useSWRSubscription('global-keydown', () => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && keyCallbacks.has(e.key)) { + keyCallbacks.get(e.key)!.forEach(cb => cb()) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }) +} + +function Profile() { + // Multiple shortcuts will share the same listener + useKeyboardShortcut('p', () => { /* ... */ }) + useKeyboardShortcut('k', () => { /* ... */ }) + // ... +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md b/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md new file mode 100644 index 0000000..d30a1a7 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md @@ -0,0 +1,71 @@ +--- +title: Version and Minimize localStorage Data +impact: MEDIUM +impactDescription: prevents schema conflicts, reduces storage size +tags: client, localStorage, storage, versioning, data-minimization +--- + +## Version and Minimize localStorage Data + +Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data. + +**Incorrect:** + +```typescript +// No version, stores everything, no error handling +localStorage.setItem('userConfig', JSON.stringify(fullUserObject)) +const data = localStorage.getItem('userConfig') +``` + +**Correct:** + +```typescript +const VERSION = 'v2' + +function saveConfig(config: { theme: string; language: string }) { + try { + localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config)) + } catch { + // Throws in incognito/private browsing, quota exceeded, or disabled + } +} + +function loadConfig() { + try { + const data = localStorage.getItem(`userConfig:${VERSION}`) + return data ? JSON.parse(data) : null + } catch { + return null + } +} + +// Migration from v1 to v2 +function migrate() { + try { + const v1 = localStorage.getItem('userConfig:v1') + if (v1) { + const old = JSON.parse(v1) + saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang }) + localStorage.removeItem('userConfig:v1') + } + } catch {} +} +``` + +**Store minimal fields from server responses:** + +```typescript +// User object has 20+ fields, only store what UI needs +function cachePrefs(user: FullUser) { + try { + localStorage.setItem('prefs:v1', JSON.stringify({ + theme: user.preferences.theme, + notifications: user.preferences.notifications + })) + } catch {} +} +``` + +**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled. + +**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags. diff --git a/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md b/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md new file mode 100644 index 0000000..ce39a88 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md @@ -0,0 +1,48 @@ +--- +title: Use Passive Event Listeners for Scrolling Performance +impact: MEDIUM +impactDescription: eliminates scroll delay caused by event listeners +tags: client, event-listeners, scrolling, performance, touch, wheel +--- + +## Use Passive Event Listeners for Scrolling Performance + +Add `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay. + +**Incorrect:** + +```typescript +useEffect(() => { + const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) + const handleWheel = (e: WheelEvent) => console.log(e.deltaY) + + document.addEventListener('touchstart', handleTouch) + document.addEventListener('wheel', handleWheel) + + return () => { + document.removeEventListener('touchstart', handleTouch) + document.removeEventListener('wheel', handleWheel) + } +}, []) +``` + +**Correct:** + +```typescript +useEffect(() => { + const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) + const handleWheel = (e: WheelEvent) => console.log(e.deltaY) + + document.addEventListener('touchstart', handleTouch, { passive: true }) + document.addEventListener('wheel', handleWheel, { passive: true }) + + return () => { + document.removeEventListener('touchstart', handleTouch) + document.removeEventListener('wheel', handleWheel) + } +}, []) +``` + +**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`. + +**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`. diff --git a/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md b/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md new file mode 100644 index 0000000..2a430f2 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md @@ -0,0 +1,56 @@ +--- +title: Use SWR for Automatic Deduplication +impact: MEDIUM-HIGH +impactDescription: automatic deduplication +tags: client, swr, deduplication, data-fetching +--- + +## Use SWR for Automatic Deduplication + +SWR enables request deduplication, caching, and revalidation across component instances. + +**Incorrect (no deduplication, each instance fetches):** + +```tsx +function UserList() { + const [users, setUsers] = useState([]) + useEffect(() => { + fetch('/api/users') + .then(r => r.json()) + .then(setUsers) + }, []) +} +``` + +**Correct (multiple instances share one request):** + +```tsx +import useSWR from 'swr' + +function UserList() { + const { data: users } = useSWR('/api/users', fetcher) +} +``` + +**For immutable data:** + +```tsx +import { useImmutableSWR } from '@/lib/swr' + +function StaticContent() { + const { data } = useImmutableSWR('/api/config', fetcher) +} +``` + +**For mutations:** + +```tsx +import { useSWRMutation } from 'swr/mutation' + +function UpdateButton() { + const { trigger } = useSWRMutation('/api/user', updateUser) + return <button onClick={() => trigger()}>Update</button> +} +``` + +Reference: [https://swr.vercel.app](https://swr.vercel.app) diff --git a/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md b/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md new file mode 100644 index 0000000..a62d84e --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md @@ -0,0 +1,107 @@ +--- +title: Avoid Layout Thrashing +impact: MEDIUM +impactDescription: prevents forced synchronous layouts and reduces performance bottlenecks +tags: javascript, dom, css, performance, reflow, layout-thrashing +--- + +## Avoid Layout Thrashing + +Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow. + +**This is OK (browser batches style changes):** +```typescript +function updateElementStyles(element: HTMLElement) { + // Each line invalidates style, but browser batches the recalculation + element.style.width = '100px' + element.style.height = '200px' + element.style.backgroundColor = 'blue' + element.style.border = '1px solid black' +} +``` + +**Incorrect (interleaved reads and writes force reflows):** +```typescript +function layoutThrashing(element: HTMLElement) { + element.style.width = '100px' + const width = element.offsetWidth // Forces reflow + element.style.height = '200px' + const height = element.offsetHeight // Forces another reflow +} +``` + +**Correct (batch writes, then read once):** +```typescript +function updateElementStyles(element: HTMLElement) { + // Batch all writes together + element.style.width = '100px' + element.style.height = '200px' + element.style.backgroundColor = 'blue' + element.style.border = '1px solid black' + + // Read after all writes are done (single reflow) + const { width, height } = element.getBoundingClientRect() +} +``` + +**Correct (batch reads, then writes):** +```typescript +function avoidThrashing(element: HTMLElement) { + // Read phase - all layout queries first + const rect1 = element.getBoundingClientRect() + const offsetWidth = element.offsetWidth + const offsetHeight = element.offsetHeight + + // Write phase - all style changes after + element.style.width = '100px' + element.style.height = '200px' +} +``` + +**Better: use CSS classes** +```css +.highlighted-box { + width: 100px; + height: 200px; + background-color: blue; + border: 1px solid black; +} +``` +```typescript +function updateElementStyles(element: HTMLElement) { + element.classList.add('highlighted-box') + + const { width, height } = element.getBoundingClientRect() +} +``` + +**React example:** +```tsx +// Incorrect: interleaving style changes with layout queries +function Box({ isHighlighted }: { isHighlighted: boolean }) { + const ref = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (ref.current && isHighlighted) { + ref.current.style.width = '100px' + const width = ref.current.offsetWidth // Forces layout + ref.current.style.height = '200px' + } + }, [isHighlighted]) + + return <div ref={ref}>Content</div> +} + +// Correct: toggle class +function Box({ isHighlighted }: { isHighlighted: boolean }) { + return ( + <div className={isHighlighted ? 'highlighted-box' : ''}> + Content + </div> + ) +} +``` + +Prefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain. + +See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations. diff --git a/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md b/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md new file mode 100644 index 0000000..180f8ac --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md @@ -0,0 +1,80 @@ +--- +title: Cache Repeated Function Calls +impact: MEDIUM +impactDescription: avoid redundant computation +tags: javascript, cache, memoization, performance +--- + +## Cache Repeated Function Calls + +Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render. + +**Incorrect (redundant computation):** + +```typescript +function ProjectList({ projects }: { projects: Project[] }) { + return ( + <div> + {projects.map(project => { + // slugify() called 100+ times for same project names + const slug = slugify(project.name) + + return <ProjectCard key={project.id} slug={slug} /> + })} + </div> + ) +} +``` + +**Correct (cached results):** + +```typescript +// Module-level cache +const slugifyCache = new Map<string, string>() + +function cachedSlugify(text: string): string { + if (slugifyCache.has(text)) { + return slugifyCache.get(text)! + } + const result = slugify(text) + slugifyCache.set(text, result) + return result +} + +function ProjectList({ projects }: { projects: Project[] }) { + return ( + <div> + {projects.map(project => { + // Computed only once per unique project name + const slug = cachedSlugify(project.name) + + return <ProjectCard key={project.id} slug={slug} /> + })} + </div> + ) +} +``` + +**Simpler pattern for single-value functions:** + +```typescript +let isLoggedInCache: boolean | null = null + +function isLoggedIn(): boolean { + if (isLoggedInCache !== null) { + return isLoggedInCache + } + + isLoggedInCache = document.cookie.includes('auth=') + return isLoggedInCache +} + +// Clear cache when auth changes +function onAuthChange() { + isLoggedInCache = null +} +``` + +Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components. + +Reference: [How we made the Vercel Dashboard twice as fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast) diff --git a/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md b/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md new file mode 100644 index 0000000..39eec90 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md @@ -0,0 +1,28 @@ +--- +title: Cache Property Access in Loops +impact: LOW-MEDIUM +impactDescription: reduces lookups +tags: javascript, loops, optimization, caching +--- + +## Cache Property Access in Loops + +Cache object property lookups in hot paths. + +**Incorrect (3 lookups × N iterations):** + +```typescript +for (let i = 0; i < arr.length; i++) { + process(obj.config.settings.value) +} +``` + +**Correct (1 lookup total):** + +```typescript +const value = obj.config.settings.value +const len = arr.length +for (let i = 0; i < len; i++) { + process(value) +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md b/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md new file mode 100644 index 0000000..aa4a30c --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md @@ -0,0 +1,70 @@ +--- +title: Cache Storage API Calls +impact: LOW-MEDIUM +impactDescription: reduces expensive I/O +tags: javascript, localStorage, storage, caching, performance +--- + +## Cache Storage API Calls + +`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory. + +**Incorrect (reads storage on every call):** + +```typescript +function getTheme() { + return localStorage.getItem('theme') ?? 'light' +} +// Called 10 times = 10 storage reads +``` + +**Correct (Map cache):** + +```typescript +const storageCache = new Map<string, string | null>() + +function getLocalStorage(key: string) { + if (!storageCache.has(key)) { + storageCache.set(key, localStorage.getItem(key)) + } + return storageCache.get(key) +} + +function setLocalStorage(key: string, value: string) { + localStorage.setItem(key, value) + storageCache.set(key, value) // keep cache in sync +} +``` + +Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components. + +**Cookie caching:** + +```typescript +let cookieCache: Record<string, string> | null = null + +function getCookie(name: string) { + if (!cookieCache) { + cookieCache = Object.fromEntries( + document.cookie.split('; ').map(c => c.split('=')) + ) + } + return cookieCache[name] +} +``` + +**Important (invalidate on external changes):** + +If storage can change externally (another tab, server-set cookies), invalidate cache: + +```typescript +window.addEventListener('storage', (e) => { + if (e.key) storageCache.delete(e.key) +}) + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + storageCache.clear() + } +}) +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md b/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md new file mode 100644 index 0000000..044d017 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md @@ -0,0 +1,32 @@ +--- +title: Combine Multiple Array Iterations +impact: LOW-MEDIUM +impactDescription: reduces iterations +tags: javascript, arrays, loops, performance +--- + +## Combine Multiple Array Iterations + +Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop. + +**Incorrect (3 iterations):** + +```typescript +const admins = users.filter(u => u.isAdmin) +const testers = users.filter(u => u.isTester) +const inactive = users.filter(u => !u.isActive) +``` + +**Correct (1 iteration):** + +```typescript +const admins: User[] = [] +const testers: User[] = [] +const inactive: User[] = [] + +for (const user of users) { + if (user.isAdmin) admins.push(user) + if (user.isTester) testers.push(user) + if (!user.isActive) inactive.push(user) +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md b/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md new file mode 100644 index 0000000..f46cb89 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md @@ -0,0 +1,50 @@ +--- +title: Early Return from Functions +impact: LOW-MEDIUM +impactDescription: avoids unnecessary computation +tags: javascript, functions, optimization, early-return +--- + +## Early Return from Functions + +Return early when result is determined to skip unnecessary processing. + +**Incorrect (processes all items even after finding answer):** + +```typescript +function validateUsers(users: User[]) { + let hasError = false + let errorMessage = '' + + for (const user of users) { + if (!user.email) { + hasError = true + errorMessage = 'Email required' + } + if (!user.name) { + hasError = true + errorMessage = 'Name required' + } + // Continues checking all users even after error found + } + + return hasError ? { valid: false, error: errorMessage } : { valid: true } +} +``` + +**Correct (returns immediately on first error):** + +```typescript +function validateUsers(users: User[]) { + for (const user of users) { + if (!user.email) { + return { valid: false, error: 'Email required' } + } + if (!user.name) { + return { valid: false, error: 'Name required' } + } + } + + return { valid: true } +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/js-flatmap-filter.md b/.agents/skills/vercel-react-best-practices/rules/js-flatmap-filter.md new file mode 100644 index 0000000..ee0edf0 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-flatmap-filter.md @@ -0,0 +1,60 @@ +--- +title: Use flatMap to Map and Filter in One Pass +impact: LOW-MEDIUM +impactDescription: eliminates intermediate array +tags: javascript, arrays, flatMap, filter, performance +--- + +## Use flatMap to Map and Filter in One Pass + +**Impact: LOW-MEDIUM (eliminates intermediate array)** + +Chaining `.map().filter(Boolean)` creates an intermediate array and iterates twice. Use `.flatMap()` to transform and filter in a single pass. + +**Incorrect (2 iterations, intermediate array):** + +```typescript +const userNames = users + .map(user => user.isActive ? user.name : null) + .filter(Boolean) +``` + +**Correct (1 iteration, no intermediate array):** + +```typescript +const userNames = users.flatMap(user => + user.isActive ? [user.name] : [] +) +``` + +**More examples:** + +```typescript +// Extract valid emails from responses +// Before +const emails = responses + .map(r => r.success ? r.data.email : null) + .filter(Boolean) + +// After +const emails = responses.flatMap(r => + r.success ? [r.data.email] : [] +) + +// Parse and filter valid numbers +// Before +const numbers = strings + .map(s => parseInt(s, 10)) + .filter(n => !isNaN(n)) + +// After +const numbers = strings.flatMap(s => { + const n = parseInt(s, 10) + return isNaN(n) ? [] : [n] +}) +``` + +**When to use:** +- Transforming items while filtering some out +- Conditional mapping where some inputs produce no output +- Parsing/validating where invalid inputs should be skipped diff --git a/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md b/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md new file mode 100644 index 0000000..dae3fef --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md @@ -0,0 +1,45 @@ +--- +title: Hoist RegExp Creation +impact: LOW-MEDIUM +impactDescription: avoids recreation +tags: javascript, regexp, optimization, memoization +--- + +## Hoist RegExp Creation + +Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`. + +**Incorrect (new RegExp every render):** + +```tsx +function Highlighter({ text, query }: Props) { + const regex = new RegExp(`(${query})`, 'gi') + const parts = text.split(regex) + return <>{parts.map((part, i) => ...)}</> +} +``` + +**Correct (memoize or hoist):** + +```tsx +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function Highlighter({ text, query }: Props) { + const regex = useMemo( + () => new RegExp(`(${escapeRegex(query)})`, 'gi'), + [query] + ) + const parts = text.split(regex) + return <>{parts.map((part, i) => ...)}</> +} +``` + +**Warning (global regex has mutable state):** + +Global regex (`/g`) has mutable `lastIndex` state: + +```typescript +const regex = /foo/g +regex.test('foo') // true, lastIndex = 3 +regex.test('foo') // false, lastIndex = 0 +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md b/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md new file mode 100644 index 0000000..9d357a0 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md @@ -0,0 +1,37 @@ +--- +title: Build Index Maps for Repeated Lookups +impact: LOW-MEDIUM +impactDescription: 1M ops to 2K ops +tags: javascript, map, indexing, optimization, performance +--- + +## Build Index Maps for Repeated Lookups + +Multiple `.find()` calls by the same key should use a Map. + +**Incorrect (O(n) per lookup):** + +```typescript +function processOrders(orders: Order[], users: User[]) { + return orders.map(order => ({ + ...order, + user: users.find(u => u.id === order.userId) + })) +} +``` + +**Correct (O(1) per lookup):** + +```typescript +function processOrders(orders: Order[], users: User[]) { + const userById = new Map(users.map(u => [u.id, u])) + + return orders.map(order => ({ + ...order, + user: userById.get(order.userId) + })) +} +``` + +Build map once (O(n)), then all lookups are O(1). +For 1000 orders × 1000 users: 1M ops → 2K ops. diff --git a/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md b/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md new file mode 100644 index 0000000..8b89573 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md @@ -0,0 +1,49 @@ +--- +title: Early Length Check for Array Comparisons +impact: MEDIUM-HIGH +impactDescription: avoids expensive operations when lengths differ +tags: javascript, arrays, performance, optimization, comparison +--- + +## Early Length Check for Array Comparisons + +When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal. + +In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops). + +**Incorrect (always runs expensive comparison):** + +```typescript +function hasChanges(current: string[], original: string[]) { + // Always sorts and joins, even when lengths differ + return current.sort().join() !== original.sort().join() +} +``` + +Two O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings. + +**Correct (O(1) length check first):** + +```typescript +function hasChanges(current: string[], original: string[]) { + // Early return if lengths differ + if (current.length !== original.length) { + return true + } + // Only sort when lengths match + const currentSorted = current.toSorted() + const originalSorted = original.toSorted() + for (let i = 0; i < currentSorted.length; i++) { + if (currentSorted[i] !== originalSorted[i]) { + return true + } + } + return false +} +``` + +This new approach is more efficient because: +- It avoids the overhead of sorting and joining the arrays when lengths differ +- It avoids consuming memory for the joined strings (especially important for large arrays) +- It avoids mutating the original arrays +- It returns early when a difference is found diff --git a/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md b/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md new file mode 100644 index 0000000..4b6656e --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md @@ -0,0 +1,82 @@ +--- +title: Use Loop for Min/Max Instead of Sort +impact: LOW +impactDescription: O(n) instead of O(n log n) +tags: javascript, arrays, performance, sorting, algorithms +--- + +## Use Loop for Min/Max Instead of Sort + +Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower. + +**Incorrect (O(n log n) - sort to find latest):** + +```typescript +interface Project { + id: string + name: string + updatedAt: number +} + +function getLatestProject(projects: Project[]) { + const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt) + return sorted[0] +} +``` + +Sorts the entire array just to find the maximum value. + +**Incorrect (O(n log n) - sort for oldest and newest):** + +```typescript +function getOldestAndNewest(projects: Project[]) { + const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt) + return { oldest: sorted[0], newest: sorted[sorted.length - 1] } +} +``` + +Still sorts unnecessarily when only min/max are needed. + +**Correct (O(n) - single loop):** + +```typescript +function getLatestProject(projects: Project[]) { + if (projects.length === 0) return null + + let latest = projects[0] + + for (let i = 1; i < projects.length; i++) { + if (projects[i].updatedAt > latest.updatedAt) { + latest = projects[i] + } + } + + return latest +} + +function getOldestAndNewest(projects: Project[]) { + if (projects.length === 0) return { oldest: null, newest: null } + + let oldest = projects[0] + let newest = projects[0] + + for (let i = 1; i < projects.length; i++) { + if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i] + if (projects[i].updatedAt > newest.updatedAt) newest = projects[i] + } + + return { oldest, newest } +} +``` + +Single pass through the array, no copying, no sorting. + +**Alternative (Math.min/Math.max for small arrays):** + +```typescript +const numbers = [5, 2, 8, 1, 9] +const min = Math.min(...numbers) +const max = Math.max(...numbers) +``` + +This works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability. diff --git a/.agents/skills/vercel-react-best-practices/rules/js-request-idle-callback.md b/.agents/skills/vercel-react-best-practices/rules/js-request-idle-callback.md new file mode 100644 index 0000000..d81927e --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-request-idle-callback.md @@ -0,0 +1,105 @@ +--- +title: Defer Non-Critical Work with requestIdleCallback +impact: MEDIUM +impactDescription: keeps UI responsive during background tasks +tags: javascript, performance, idle, scheduling, analytics +--- + +## Defer Non-Critical Work with requestIdleCallback + +**Impact: MEDIUM (keeps UI responsive during background tasks)** + +Use `requestIdleCallback()` to schedule non-critical work during browser idle periods. This keeps the main thread free for user interactions and animations, reducing jank and improving perceived performance. + +**Incorrect (blocks main thread during user interaction):** + +```typescript +function handleSearch(query: string) { + const results = searchItems(query) + setResults(results) + + // These block the main thread immediately + analytics.track('search', { query }) + saveToRecentSearches(query) + prefetchTopResults(results.slice(0, 3)) +} +``` + +**Correct (defers non-critical work to idle time):** + +```typescript +function handleSearch(query: string) { + const results = searchItems(query) + setResults(results) + + // Defer non-critical work to idle periods + requestIdleCallback(() => { + analytics.track('search', { query }) + }) + + requestIdleCallback(() => { + saveToRecentSearches(query) + }) + + requestIdleCallback(() => { + prefetchTopResults(results.slice(0, 3)) + }) +} +``` + +**With timeout for required work:** + +```typescript +// Ensure analytics fires within 2 seconds even if browser stays busy +requestIdleCallback( + () => analytics.track('page_view', { path: location.pathname }), + { timeout: 2000 } +) +``` + +**Chunking large tasks:** + +```typescript +function processLargeDataset(items: Item[]) { + let index = 0 + + function processChunk(deadline: IdleDeadline) { + // Process items while we have idle time (aim for <50ms chunks) + while (index < items.length && deadline.timeRemaining() > 0) { + processItem(items[index]) + index++ + } + + // Schedule next chunk if more items remain + if (index < items.length) { + requestIdleCallback(processChunk) + } + } + + requestIdleCallback(processChunk) +} +``` + +**With fallback for unsupported browsers:** + +```typescript +const scheduleIdleWork = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1)) + +scheduleIdleWork(() => { + // Non-critical work +}) +``` + +**When to use:** + +- Analytics and telemetry +- Saving state to localStorage/IndexedDB +- Prefetching resources for likely next actions +- Processing non-urgent data transformations +- Lazy initialization of non-critical features + +**When NOT to use:** + +- User-initiated actions that need immediate feedback +- Rendering updates the user is waiting for +- Time-sensitive operations diff --git a/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md b/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md new file mode 100644 index 0000000..680a489 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md @@ -0,0 +1,24 @@ +--- +title: Use Set/Map for O(1) Lookups +impact: LOW-MEDIUM +impactDescription: O(n) to O(1) +tags: javascript, set, map, data-structures, performance +--- + +## Use Set/Map for O(1) Lookups + +Convert arrays to Set/Map for repeated membership checks. + +**Incorrect (O(n) per check):** + +```typescript +const allowedIds = ['a', 'b', 'c', ...] +items.filter(item => allowedIds.includes(item.id)) +``` + +**Correct (O(1) per check):** + +```typescript +const allowedIds = new Set(['a', 'b', 'c', ...]) +items.filter(item => allowedIds.has(item.id)) +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md b/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md new file mode 100644 index 0000000..eae8b3f --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md @@ -0,0 +1,57 @@ +--- +title: Use toSorted() Instead of sort() for Immutability +impact: MEDIUM-HIGH +impactDescription: prevents mutation bugs in React state +tags: javascript, arrays, immutability, react, state, mutation +--- + +## Use toSorted() Instead of sort() for Immutability + +`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation. + +**Incorrect (mutates original array):** + +```typescript +function UserList({ users }: { users: User[] }) { + // Mutates the users prop array! + const sorted = useMemo( + () => users.sort((a, b) => a.name.localeCompare(b.name)), + [users] + ) + return <div>{sorted.map(renderUser)}</div> +} +``` + +**Correct (creates new array):** + +```typescript +function UserList({ users }: { users: User[] }) { + // Creates new sorted array, original unchanged + const sorted = useMemo( + () => users.toSorted((a, b) => a.name.localeCompare(b.name)), + [users] + ) + return <div>{sorted.map(renderUser)}</div> +} +``` + +**Why this matters in React:** + +1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only +2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior + +**Browser support (fallback for older browsers):** + +`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator: + +```typescript +// Fallback for older browsers +const sorted = [...items].sort((a, b) => a.value - b.value) +``` + +**Other immutable array methods:** + +- `.toSorted()` - immutable sort +- `.toReversed()` - immutable reverse +- `.toSpliced()` - immutable splice +- `.with()` - immutable element replacement diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md b/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md new file mode 100644 index 0000000..c957a49 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md @@ -0,0 +1,26 @@ +--- +title: Use Activity Component for Show/Hide +impact: MEDIUM +impactDescription: preserves state/DOM +tags: rendering, activity, visibility, state-preservation +--- + +## Use Activity Component for Show/Hide + +Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility. + +**Usage:** + +```tsx +import { Activity } from 'react' + +function Dropdown({ isOpen }: Props) { + return ( + <Activity mode={isOpen ? 'visible' : 'hidden'}> + <ExpensiveMenu /> + </Activity> + ) +} +``` + +Avoids expensive re-renders and state loss. diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md b/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md new file mode 100644 index 0000000..646744c --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md @@ -0,0 +1,47 @@ +--- +title: Animate SVG Wrapper Instead of SVG Element +impact: LOW +impactDescription: enables hardware acceleration +tags: rendering, svg, css, animation, performance +--- + +## Animate SVG Wrapper Instead of SVG Element + +Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead. + +**Incorrect (animating SVG directly - no hardware acceleration):** + +```tsx +function LoadingSpinner() { + return ( + <svg + className="animate-spin" + width="24" + height="24" + viewBox="0 0 24 24" + > + <circle cx="12" cy="12" r="10" stroke="currentColor" /> + </svg> + ) +} +``` + +**Correct (animating wrapper div - hardware accelerated):** + +```tsx +function LoadingSpinner() { + return ( + <div className="animate-spin"> + <svg + width="24" + height="24" + viewBox="0 0 24 24" + > + <circle cx="12" cy="12" r="10" stroke="currentColor" /> + </svg> + </div> + ) +} +``` + +This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations. diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md b/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md new file mode 100644 index 0000000..7e866f5 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md @@ -0,0 +1,40 @@ +--- +title: Use Explicit Conditional Rendering +impact: LOW +impactDescription: prevents rendering 0 or NaN +tags: rendering, conditional, jsx, falsy-values +--- + +## Use Explicit Conditional Rendering + +Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render. + +**Incorrect (renders "0" when count is 0):** + +```tsx +function Badge({ count }: { count: number }) { + return ( + <div> + {count && <span className="badge">{count}</span>} + </div> + ) +} + +// When count = 0, renders: <div>0</div> +// When count = 5, renders: <div><span class="badge">5</span></div> +``` + +**Correct (renders nothing when count is 0):** + +```tsx +function Badge({ count }: { count: number }) { + return ( + <div> + {count > 0 ? <span className="badge">{count}</span> : null} + </div> + ) +} + +// When count = 0, renders: <div></div> +// When count = 5, renders: <div><span class="badge">5</span></div> +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md b/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md new file mode 100644 index 0000000..aa66563 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md @@ -0,0 +1,38 @@ +--- +title: CSS content-visibility for Long Lists +impact: HIGH +impactDescription: faster initial render +tags: rendering, css, content-visibility, long-lists +--- + +## CSS content-visibility for Long Lists + +Apply `content-visibility: auto` to defer off-screen rendering. + +**CSS:** + +```css +.message-item { + content-visibility: auto; + contain-intrinsic-size: 0 80px; +} +``` + +**Example:** + +```tsx +function MessageList({ messages }: { messages: Message[] }) { + return ( + <div className="overflow-y-auto h-screen"> + {messages.map(msg => ( + <div key={msg.id} className="message-item"> + <Avatar user={msg.author} /> + <div>{msg.content}</div> + </div> + ))} + </div> + ) +} +``` + +For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render). diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md b/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md new file mode 100644 index 0000000..32d2f3f --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md @@ -0,0 +1,46 @@ +--- +title: Hoist Static JSX Elements +impact: LOW +impactDescription: avoids re-creation +tags: rendering, jsx, static, optimization +--- + +## Hoist Static JSX Elements + +Extract static JSX outside components to avoid re-creation. + +**Incorrect (recreates element every render):** + +```tsx +function LoadingSkeleton() { + return <div className="animate-pulse h-20 bg-gray-200" /> +} + +function Container() { + return ( + <div> + {loading && <LoadingSkeleton />} + </div> + ) +} +``` + +**Correct (reuses same element):** + +```tsx +const loadingSkeleton = ( + <div className="animate-pulse h-20 bg-gray-200" /> +) + +function Container() { + return ( + <div> + {loading && loadingSkeleton} + </div> + ) +} +``` + +This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render. + +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary. diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md b/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md new file mode 100644 index 0000000..5cf0e79 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md @@ -0,0 +1,82 @@ +--- +title: Prevent Hydration Mismatch Without Flickering +impact: MEDIUM +impactDescription: avoids visual flicker and hydration errors +tags: rendering, ssr, hydration, localStorage, flicker +--- + +## Prevent Hydration Mismatch Without Flickering + +When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates. + +**Incorrect (breaks SSR):** + +```tsx +function ThemeWrapper({ children }: { children: ReactNode }) { + // localStorage is not available on server - throws error + const theme = localStorage.getItem('theme') || 'light' + + return ( + <div className={theme}> + {children} + </div> + ) +} +``` + +Server-side rendering will fail because `localStorage` is undefined. + +**Incorrect (visual flickering):** + +```tsx +function ThemeWrapper({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState('light') + + useEffect(() => { + // Runs after hydration - causes visible flash + const stored = localStorage.getItem('theme') + if (stored) { + setTheme(stored) + } + }, []) + + return ( + <div className={theme}> + {children} + </div> + ) +} +``` + +Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content. + +**Correct (no flicker, no hydration mismatch):** + +```tsx +function ThemeWrapper({ children }: { children: ReactNode }) { + return ( + <> + <div id="theme-wrapper"> + {children} + </div> + <script + dangerouslySetInnerHTML={{ + __html: ` + (function() { + try { + var theme = localStorage.getItem('theme') || 'light'; + var el = document.getElementById('theme-wrapper'); + if (el) el.className = theme; + } catch (e) {} + })(); + `, + }} + /> + </> + ) +} +``` + +The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch. + +This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values. diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md b/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md new file mode 100644 index 0000000..24ba251 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md @@ -0,0 +1,30 @@ +--- +title: Suppress Expected Hydration Mismatches +impact: LOW-MEDIUM +impactDescription: avoids noisy hydration warnings for known differences +tags: rendering, hydration, ssr, nextjs +--- + +## Suppress Expected Hydration Mismatches + +In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it. + +**Incorrect (known mismatch warnings):** + +```tsx +function Timestamp() { + return <span>{new Date().toLocaleString()}</span> +} +``` + +**Correct (suppress expected mismatch only):** + +```tsx +function Timestamp() { + return ( + <span suppressHydrationWarning> + {new Date().toLocaleString()} + </span> + ) +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-resource-hints.md b/.agents/skills/vercel-react-best-practices/rules/rendering-resource-hints.md new file mode 100644 index 0000000..1290bef --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-resource-hints.md @@ -0,0 +1,85 @@ +--- +title: Use React DOM Resource Hints +impact: HIGH +impactDescription: reduces load time for critical resources +tags: rendering, preload, preconnect, prefetch, resource-hints +--- + +## Use React DOM Resource Hints + +**Impact: HIGH (reduces load time for critical resources)** + +React DOM provides APIs to hint the browser about resources it will need. These are especially useful in server components to start loading resources before the client even receives the HTML. + +- **`prefetchDNS(href)`**: Resolve DNS for a domain you expect to connect to +- **`preconnect(href)`**: Establish connection (DNS + TCP + TLS) to a server +- **`preload(href, options)`**: Fetch a resource (stylesheet, font, script, image) you'll use soon +- **`preloadModule(href)`**: Fetch an ES module you'll use soon +- **`preinit(href, options)`**: Fetch and evaluate a stylesheet or script +- **`preinitModule(href)`**: Fetch and evaluate an ES module + +**Example (preconnect to third-party APIs):** + +```tsx +import { preconnect, prefetchDNS } from 'react-dom' + +export default function App() { + prefetchDNS('https://analytics.example.com') + preconnect('https://api.example.com') + + return <main>{/* content */}</main> +} +``` + +**Example (preload critical fonts and styles):** + +```tsx +import { preload, preinit } from 'react-dom' + +export default function RootLayout({ children }) { + // Preload font file + preload('/fonts/inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' }) + + // Fetch and apply critical stylesheet immediately + preinit('/styles/critical.css', { as: 'style' }) + + return ( + <html> + <body>{children}</body> + </html> + ) +} +``` + +**Example (preload modules for code-split routes):** + +```tsx +import { preloadModule, preinitModule } from 'react-dom' + +function Navigation() { + const preloadDashboard = () => { + preloadModule('/dashboard.js', { as: 'script' }) + } + + return ( + <nav> + <a href="/dashboard" onMouseEnter={preloadDashboard}> + Dashboard + </a> + </nav> + ) +} +``` + +**When to use each:** + +| API | Use case | +|-----|----------| +| `prefetchDNS` | Third-party domains you'll connect to later | +| `preconnect` | APIs or CDNs you'll fetch from immediately | +| `preload` | Critical resources needed for current page | +| `preloadModule` | JS modules for likely next navigation | +| `preinit` | Stylesheets/scripts that must execute early | +| `preinitModule` | ES modules that must execute early | + +Reference: [React DOM Resource Preloading APIs](https://react.dev/reference/react-dom#resource-preloading-apis) diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-script-defer-async.md b/.agents/skills/vercel-react-best-practices/rules/rendering-script-defer-async.md new file mode 100644 index 0000000..ee275ed --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-script-defer-async.md @@ -0,0 +1,68 @@ +--- +title: Use defer or async on Script Tags +impact: HIGH +impactDescription: eliminates render-blocking +tags: rendering, script, defer, async, performance +--- + +## Use defer or async on Script Tags + +**Impact: HIGH (eliminates render-blocking)** + +Script tags without `defer` or `async` block HTML parsing while the script downloads and executes. This delays First Contentful Paint and Time to Interactive. + +- **`defer`**: Downloads in parallel, executes after HTML parsing completes, maintains execution order +- **`async`**: Downloads in parallel, executes immediately when ready, no guaranteed order + +Use `defer` for scripts that depend on DOM or other scripts. Use `async` for independent scripts like analytics. + +**Incorrect (blocks rendering):** + +```tsx +export default function Document() { + return ( + <html> + <head> + <script src="https://example.com/analytics.js" /> + <script src="/scripts/utils.js" /> + </head> + <body>{/* content */}</body> + </html> + ) +} +``` + +**Correct (non-blocking):** + +```tsx +export default function Document() { + return ( + <html> + <head> + {/* Independent script - use async */} + <script src="https://example.com/analytics.js" async /> + {/* DOM-dependent script - use defer */} + <script src="/scripts/utils.js" defer /> + </head> + <body>{/* content */}</body> + </html> + ) +} +``` + +**Note:** In Next.js, prefer the `next/script` component with `strategy` prop instead of raw script tags: + +```tsx +import Script from 'next/script' + +export default function Page() { + return ( + <> + <Script src="https://example.com/analytics.js" strategy="afterInteractive" /> + <Script src="/scripts/utils.js" strategy="beforeInteractive" /> + </> + ) +} +``` + +Reference: [MDN - Script element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer) diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md b/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md new file mode 100644 index 0000000..6d77128 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md @@ -0,0 +1,28 @@ +--- +title: Optimize SVG Precision +impact: LOW +impactDescription: reduces file size +tags: rendering, svg, optimization, svgo +--- + +## Optimize SVG Precision + +Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered. + +**Incorrect (excessive precision):** + +```svg +<path d="M 10.293847 20.847362 L 30.938472 40.192837" /> +``` + +**Correct (1 decimal place):** + +```svg +<path d="M 10.3 20.8 L 30.9 40.2" /> +``` + +**Automate with SVGO:** + +```bash +npx svgo --precision=1 --multipass icon.svg +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md b/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md new file mode 100644 index 0000000..0c1b0b9 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md @@ -0,0 +1,75 @@ +--- +title: Use useTransition Over Manual Loading States +impact: LOW +impactDescription: reduces re-renders and improves code clarity +tags: rendering, transitions, useTransition, loading, state +--- + +## Use useTransition Over Manual Loading States + +Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions. + +**Incorrect (manual loading state):** + +```tsx +function SearchResults() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + const handleSearch = async (value: string) => { + setIsLoading(true) + setQuery(value) + const data = await fetchResults(value) + setResults(data) + setIsLoading(false) + } + + return ( + <> + <input onChange={(e) => handleSearch(e.target.value)} /> + {isLoading && <Spinner />} + <ResultsList results={results} /> + </> + ) +} +``` + +**Correct (useTransition with built-in pending state):** + +```tsx +import { useTransition, useState } from 'react' + +function SearchResults() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isPending, startTransition] = useTransition() + + const handleSearch = (value: string) => { + setQuery(value) // Update input immediately + + startTransition(async () => { + // Fetch and update results + const data = await fetchResults(value) + setResults(data) + }) + } + + return ( + <> + <input onChange={(e) => handleSearch(e.target.value)} /> + {isPending && <Spinner />} + <ResultsList results={results} /> + </> + ) +} +``` + +**Benefits:** + +- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)` +- **Error resilience**: Pending state correctly resets even if the transition throws +- **Better responsiveness**: Keeps the UI responsive during updates +- **Interrupt handling**: New transitions automatically cancel pending ones + +Reference: [useTransition](https://react.dev/reference/react/useTransition) diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md b/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md new file mode 100644 index 0000000..e867c95 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md @@ -0,0 +1,39 @@ +--- +title: Defer State Reads to Usage Point +impact: MEDIUM +impactDescription: avoids unnecessary subscriptions +tags: rerender, searchParams, localStorage, optimization +--- + +## Defer State Reads to Usage Point + +Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks. + +**Incorrect (subscribes to all searchParams changes):** + +```tsx +function ShareButton({ chatId }: { chatId: string }) { + const searchParams = useSearchParams() + + const handleShare = () => { + const ref = searchParams.get('ref') + shareChat(chatId, { ref }) + } + + return <button onClick={handleShare}>Share</button> +} +``` + +**Correct (reads on demand, no subscription):** + +```tsx +function ShareButton({ chatId }: { chatId: string }) { + const handleShare = () => { + const params = new URLSearchParams(window.location.search) + const ref = params.get('ref') + shareChat(chatId, { ref }) + } + + return <button onClick={handleShare}>Share</button> +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md b/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md new file mode 100644 index 0000000..47a4d92 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md @@ -0,0 +1,45 @@ +--- +title: Narrow Effect Dependencies +impact: LOW +impactDescription: minimizes effect re-runs +tags: rerender, useEffect, dependencies, optimization +--- + +## Narrow Effect Dependencies + +Specify primitive dependencies instead of objects to minimize effect re-runs. + +**Incorrect (re-runs on any user field change):** + +```tsx +useEffect(() => { + console.log(user.id) +}, [user]) +``` + +**Correct (re-runs only when id changes):** + +```tsx +useEffect(() => { + console.log(user.id) +}, [user.id]) +``` + +**For derived state, compute outside effect:** + +```tsx +// Incorrect: runs on width=767, 766, 765... +useEffect(() => { + if (width < 768) { + enableMobileMode() + } +}, [width]) + +// Correct: runs only on boolean transition +const isMobile = width < 768 +useEffect(() => { + if (isMobile) { + enableMobileMode() + } +}, [isMobile]) +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md b/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md new file mode 100644 index 0000000..3d9fe40 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md @@ -0,0 +1,40 @@ +--- +title: Calculate Derived State During Rendering +impact: MEDIUM +impactDescription: avoids redundant renders and state drift +tags: rerender, derived-state, useEffect, state +--- + +## Calculate Derived State During Rendering + +If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead. + +**Incorrect (redundant state and effect):** + +```tsx +function Form() { + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const [fullName, setFullName] = useState('') + + useEffect(() => { + setFullName(firstName + ' ' + lastName) + }, [firstName, lastName]) + + return <p>{fullName}</p> +} +``` + +**Correct (derive during render):** + +```tsx +function Form() { + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const fullName = firstName + ' ' + lastName + + return <p>{fullName}</p> +} +``` + +References: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md b/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md new file mode 100644 index 0000000..e5c899f --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md @@ -0,0 +1,29 @@ +--- +title: Subscribe to Derived State +impact: MEDIUM +impactDescription: reduces re-render frequency +tags: rerender, derived-state, media-query, optimization +--- + +## Subscribe to Derived State + +Subscribe to derived boolean state instead of continuous values to reduce re-render frequency. + +**Incorrect (re-renders on every pixel change):** + +```tsx +function Sidebar() { + const width = useWindowWidth() // updates continuously + const isMobile = width < 768 + return <nav className={isMobile ? 'mobile' : 'desktop'} /> +} +``` + +**Correct (re-renders only when boolean changes):** + +```tsx +function Sidebar() { + const isMobile = useMediaQuery('(max-width: 767px)') + return <nav className={isMobile ? 'mobile' : 'desktop'} /> +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md b/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md new file mode 100644 index 0000000..b004ef4 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md @@ -0,0 +1,74 @@ +--- +title: Use Functional setState Updates +impact: MEDIUM +impactDescription: prevents stale closures and unnecessary callback recreations +tags: react, hooks, useState, useCallback, callbacks, closures +--- + +## Use Functional setState Updates + +When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references. + +**Incorrect (requires state as dependency):** + +```tsx +function TodoList() { + const [items, setItems] = useState(initialItems) + + // Callback must depend on items, recreated on every items change + const addItems = useCallback((newItems: Item[]) => { + setItems([...items, ...newItems]) + }, [items]) // ❌ items dependency causes recreations + + // Risk of stale closure if dependency is forgotten + const removeItem = useCallback((id: string) => { + setItems(items.filter(item => item.id !== id)) + }, []) // ❌ Missing items dependency - will use stale items! + + return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} /> +} +``` + +The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value. + +**Correct (stable callbacks, no stale closures):** + +```tsx +function TodoList() { + const [items, setItems] = useState(initialItems) + + // Stable callback, never recreated + const addItems = useCallback((newItems: Item[]) => { + setItems(curr => [...curr, ...newItems]) + }, []) // ✅ No dependencies needed + + // Always uses latest state, no stale closure risk + const removeItem = useCallback((id: string) => { + setItems(curr => curr.filter(item => item.id !== id)) + }, []) // ✅ Safe and stable + + return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} /> +} +``` + +**Benefits:** + +1. **Stable callback references** - Callbacks don't need to be recreated when state changes +2. **No stale closures** - Always operates on the latest state value +3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks +4. **Prevents bugs** - Eliminates the most common source of React closure bugs + +**When to use functional updates:** + +- Any setState that depends on the current state value +- Inside useCallback/useMemo when state is needed +- Event handlers that reference state +- Async operations that update state + +**When direct updates are fine:** + +- Setting state to a static value: `setCount(0)` +- Setting state from props/arguments only: `setName(newName)` +- State doesn't depend on previous value + +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs. diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md b/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md new file mode 100644 index 0000000..4ecb350 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md @@ -0,0 +1,58 @@ +--- +title: Use Lazy State Initialization +impact: MEDIUM +impactDescription: wasted computation on every render +tags: react, hooks, useState, performance, initialization +--- + +## Use Lazy State Initialization + +Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once. + +**Incorrect (runs on every render):** + +```tsx +function FilteredList({ items }: { items: Item[] }) { + // buildSearchIndex() runs on EVERY render, even after initialization + const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items)) + const [query, setQuery] = useState('') + + // When query changes, buildSearchIndex runs again unnecessarily + return <SearchResults index={searchIndex} query={query} /> +} + +function UserProfile() { + // JSON.parse runs on every render + const [settings, setSettings] = useState( + JSON.parse(localStorage.getItem('settings') || '{}') + ) + + return <SettingsForm settings={settings} onChange={setSettings} /> +} +``` + +**Correct (runs only once):** + +```tsx +function FilteredList({ items }: { items: Item[] }) { + // buildSearchIndex() runs ONLY on initial render + const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items)) + const [query, setQuery] = useState('') + + return <SearchResults index={searchIndex} query={query} /> +} + +function UserProfile() { + // JSON.parse runs only on initial render + const [settings, setSettings] = useState(() => { + const stored = localStorage.getItem('settings') + return stored ? JSON.parse(stored) : {} + }) + + return <SettingsForm settings={settings} onChange={setSettings} /> +} +``` + +Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations. + +For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary. diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md b/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md new file mode 100644 index 0000000..6357049 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md @@ -0,0 +1,38 @@ +--- + +title: Extract Default Non-primitive Parameter Value from Memoized Component to Constant +impact: MEDIUM +impactDescription: restores memoization by using a constant for default value +tags: rerender, memo, optimization + +--- + +## Extract Default Non-primitive Parameter Value from Memoized Component to Constant + +When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`. + +To address this issue, extract the default value into a constant. + +**Incorrect (`onClick` has different values on every rerender):** + +```tsx +const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) { + // ... +}) + +// Used without optional onClick +<UserAvatar /> +``` + +**Correct (stable default value):** + +```tsx +const NOOP = () => {}; + +const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) { + // ... +}) + +// Used without optional onClick +<UserAvatar /> +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md b/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md new file mode 100644 index 0000000..f8982ab --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md @@ -0,0 +1,44 @@ +--- +title: Extract to Memoized Components +impact: MEDIUM +impactDescription: enables early returns +tags: rerender, memo, useMemo, optimization +--- + +## Extract to Memoized Components + +Extract expensive work into memoized components to enable early returns before computation. + +**Incorrect (computes avatar even when loading):** + +```tsx +function Profile({ user, loading }: Props) { + const avatar = useMemo(() => { + const id = computeAvatarId(user) + return <Avatar id={id} /> + }, [user]) + + if (loading) return <Skeleton /> + return <div>{avatar}</div> +} +``` + +**Correct (skips computation when loading):** + +```tsx +const UserAvatar = memo(function UserAvatar({ user }: { user: User }) { + const id = useMemo(() => computeAvatarId(user), [user]) + return <Avatar id={id} /> +}) + +function Profile({ user, loading }: Props) { + if (loading) return <Skeleton /> + return ( + <div> + <UserAvatar user={user} /> + </div> + ) +} +``` + +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders. diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md b/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md new file mode 100644 index 0000000..dd58a1a --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md @@ -0,0 +1,45 @@ +--- +title: Put Interaction Logic in Event Handlers +impact: MEDIUM +impactDescription: avoids effect re-runs and duplicate side effects +tags: rerender, useEffect, events, side-effects, dependencies +--- + +## Put Interaction Logic in Event Handlers + +If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action. + +**Incorrect (event modeled as state + effect):** + +```tsx +function Form() { + const [submitted, setSubmitted] = useState(false) + const theme = useContext(ThemeContext) + + useEffect(() => { + if (submitted) { + post('/api/register') + showToast('Registered', theme) + } + }, [submitted, theme]) + + return <button onClick={() => setSubmitted(true)}>Submit</button> +} +``` + +**Correct (do it in the handler):** + +```tsx +function Form() { + const theme = useContext(ThemeContext) + + function handleSubmit() { + post('/api/register') + showToast('Registered', theme) + } + + return <button onClick={handleSubmit}>Submit</button> +} +``` + +Reference: [Should this code move to an event handler?](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler) diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-no-inline-components.md b/.agents/skills/vercel-react-best-practices/rules/rerender-no-inline-components.md new file mode 100644 index 0000000..d97592a --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-no-inline-components.md @@ -0,0 +1,82 @@ +--- +title: Don't Define Components Inside Components +impact: HIGH +impactDescription: prevents remount on every render +tags: rerender, components, remount, performance +--- + +## Don't Define Components Inside Components + +**Impact: HIGH (prevents remount on every render)** + +Defining a component inside another component creates a new component type on every render. React sees a different component each time and fully remounts it, destroying all state and DOM. + +A common reason developers do this is to access parent variables without passing props. Always pass props instead. + +**Incorrect (remounts on every render):** + +```tsx +function UserProfile({ user, theme }) { + // Defined inside to access `theme` - BAD + const Avatar = () => ( + <img + src={user.avatarUrl} + className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'} + /> + ) + + // Defined inside to access `user` - BAD + const Stats = () => ( + <div> + <span>{user.followers} followers</span> + <span>{user.posts} posts</span> + </div> + ) + + return ( + <div> + <Avatar /> + <Stats /> + </div> + ) +} +``` + +Every time `UserProfile` renders, `Avatar` and `Stats` are new component types. React unmounts the old instances and mounts new ones, losing any internal state, running effects again, and recreating DOM nodes. + +**Correct (pass props instead):** + +```tsx +function Avatar({ src, theme }: { src: string; theme: string }) { + return ( + <img + src={src} + className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'} + /> + ) +} + +function Stats({ followers, posts }: { followers: number; posts: number }) { + return ( + <div> + <span>{followers} followers</span> + <span>{posts} posts</span> + </div> + ) +} + +function UserProfile({ user, theme }) { + return ( + <div> + <Avatar src={user.avatarUrl} theme={theme} /> + <Stats followers={user.followers} posts={user.posts} /> + </div> + ) +} +``` + +**Symptoms of this bug:** +- Input fields lose focus on every keystroke +- Animations restart unexpectedly +- `useEffect` cleanup/setup runs on every parent render +- Scroll position resets inside the component diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md b/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md new file mode 100644 index 0000000..59dfab0 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md @@ -0,0 +1,35 @@ +--- +title: Do not wrap a simple expression with a primitive result type in useMemo +impact: LOW-MEDIUM +impactDescription: wasted computation on every render +tags: rerender, useMemo, optimization +--- + +## Do not wrap a simple expression with a primitive result type in useMemo + +When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`. +Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself. + +**Incorrect:** + +```tsx +function Header({ user, notifications }: Props) { + const isLoading = useMemo(() => { + return user.isLoading || notifications.isLoading + }, [user.isLoading, notifications.isLoading]) + + if (isLoading) return <Skeleton /> + // return some markup +} +``` + +**Correct:** + +```tsx +function Header({ user, notifications }: Props) { + const isLoading = user.isLoading || notifications.isLoading + + if (isLoading) return <Skeleton /> + // return some markup +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-split-combined-hooks.md b/.agents/skills/vercel-react-best-practices/rules/rerender-split-combined-hooks.md new file mode 100644 index 0000000..89d8056 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-split-combined-hooks.md @@ -0,0 +1,64 @@ +--- +title: Split Combined Hook Computations +impact: MEDIUM +impactDescription: avoids recomputing independent steps +tags: rerender, useMemo, useEffect, dependencies, optimization +--- + +## Split Combined Hook Computations + +When a hook contains multiple independent tasks with different dependencies, split them into separate hooks. A combined hook reruns all tasks when any dependency changes, even if some tasks don't use the changed value. + +**Incorrect (changing `sortOrder` recomputes filtering):** + +```tsx +const sortedProducts = useMemo(() => { + const filtered = products.filter((p) => p.category === category) + const sorted = filtered.toSorted((a, b) => + sortOrder === "asc" ? a.price - b.price : b.price - a.price + ) + return sorted +}, [products, category, sortOrder]) +``` + +**Correct (filtering only recomputes when products or category change):** + +```tsx +const filteredProducts = useMemo( + () => products.filter((p) => p.category === category), + [products, category] +) + +const sortedProducts = useMemo( + () => + filteredProducts.toSorted((a, b) => + sortOrder === "asc" ? a.price - b.price : b.price - a.price + ), + [filteredProducts, sortOrder] +) +``` + +This pattern also applies to `useEffect` when combining unrelated side effects: + +**Incorrect (both effects run when either dependency changes):** + +```tsx +useEffect(() => { + analytics.trackPageView(pathname) + document.title = `${pageTitle} | My App` +}, [pathname, pageTitle]) +``` + +**Correct (effects run independently):** + +```tsx +useEffect(() => { + analytics.trackPageView(pathname) +}, [pathname]) + +useEffect(() => { + document.title = `${pageTitle} | My App` +}, [pageTitle]) +``` + +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, it automatically optimizes dependency tracking and may handle some of these cases for you. diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md b/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md new file mode 100644 index 0000000..d99f43f --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md @@ -0,0 +1,40 @@ +--- +title: Use Transitions for Non-Urgent Updates +impact: MEDIUM +impactDescription: maintains UI responsiveness +tags: rerender, transitions, startTransition, performance +--- + +## Use Transitions for Non-Urgent Updates + +Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness. + +**Incorrect (blocks UI on every scroll):** + +```tsx +function ScrollTracker() { + const [scrollY, setScrollY] = useState(0) + useEffect(() => { + const handler = () => setScrollY(window.scrollY) + window.addEventListener('scroll', handler, { passive: true }) + return () => window.removeEventListener('scroll', handler) + }, []) +} +``` + +**Correct (non-blocking updates):** + +```tsx +import { startTransition } from 'react' + +function ScrollTracker() { + const [scrollY, setScrollY] = useState(0) + useEffect(() => { + const handler = () => { + startTransition(() => setScrollY(window.scrollY)) + } + window.addEventListener('scroll', handler, { passive: true }) + return () => window.removeEventListener('scroll', handler) + }, []) +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-use-deferred-value.md b/.agents/skills/vercel-react-best-practices/rules/rerender-use-deferred-value.md new file mode 100644 index 0000000..619c04b --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-use-deferred-value.md @@ -0,0 +1,59 @@ +--- +title: Use useDeferredValue for Expensive Derived Renders +impact: MEDIUM +impactDescription: keeps input responsive during heavy computation +tags: rerender, useDeferredValue, optimization, concurrent +--- + +## Use useDeferredValue for Expensive Derived Renders + +When user input triggers expensive computations or renders, use `useDeferredValue` to keep the input responsive. The deferred value lags behind, allowing React to prioritize the input update and render the expensive result when idle. + +**Incorrect (input feels laggy while filtering):** + +```tsx +function Search({ items }: { items: Item[] }) { + const [query, setQuery] = useState('') + const filtered = items.filter(item => fuzzyMatch(item, query)) + + return ( + <> + <input value={query} onChange={e => setQuery(e.target.value)} /> + <ResultsList results={filtered} /> + </> + ) +} +``` + +**Correct (input stays snappy, results render when ready):** + +```tsx +function Search({ items }: { items: Item[] }) { + const [query, setQuery] = useState('') + const deferredQuery = useDeferredValue(query) + const filtered = useMemo( + () => items.filter(item => fuzzyMatch(item, deferredQuery)), + [items, deferredQuery] + ) + const isStale = query !== deferredQuery + + return ( + <> + <input value={query} onChange={e => setQuery(e.target.value)} /> + <div style={{ opacity: isStale ? 0.7 : 1 }}> + <ResultsList results={filtered} /> + </div> + </> + ) +} +``` + +**When to use:** + +- Filtering/searching large lists +- Expensive visualizations (charts, graphs) reacting to input +- Any derived state that causes noticeable render delays + +**Note:** Wrap the expensive computation in `useMemo` with the deferred value as a dependency, otherwise it still runs on every render. + +Reference: [React useDeferredValue](https://react.dev/reference/react/useDeferredValue) diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md b/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md new file mode 100644 index 0000000..cf04b81 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md @@ -0,0 +1,73 @@ +--- +title: Use useRef for Transient Values +impact: MEDIUM +impactDescription: avoids unnecessary re-renders on frequent updates +tags: rerender, useref, state, performance +--- + +## Use useRef for Transient Values + +When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render. + +**Incorrect (renders every update):** + +```tsx +function Tracker() { + const [lastX, setLastX] = useState(0) + + useEffect(() => { + const onMove = (e: MouseEvent) => setLastX(e.clientX) + window.addEventListener('mousemove', onMove) + return () => window.removeEventListener('mousemove', onMove) + }, []) + + return ( + <div + style={{ + position: 'fixed', + top: 0, + left: lastX, + width: 8, + height: 8, + background: 'black', + }} + /> + ) +} +``` + +**Correct (no re-render for tracking):** + +```tsx +function Tracker() { + const lastXRef = useRef(0) + const dotRef = useRef<HTMLDivElement>(null) + + useEffect(() => { + const onMove = (e: MouseEvent) => { + lastXRef.current = e.clientX + const node = dotRef.current + if (node) { + node.style.transform = `translateX(${e.clientX}px)` + } + } + window.addEventListener('mousemove', onMove) + return () => window.removeEventListener('mousemove', onMove) + }, []) + + return ( + <div + ref={dotRef} + style={{ + position: 'fixed', + top: 0, + left: 0, + width: 8, + height: 8, + background: 'black', + transform: 'translateX(0px)', + }} + /> + ) +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md b/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md new file mode 100644 index 0000000..e8f5b26 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md @@ -0,0 +1,73 @@ +--- +title: Use after() for Non-Blocking Operations +impact: MEDIUM +impactDescription: faster response times +tags: server, async, logging, analytics, side-effects +--- + +## Use after() for Non-Blocking Operations + +Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response. + +**Incorrect (blocks response):** + +```tsx +import { logUserAction } from '@/app/utils' + +export async function POST(request: Request) { + // Perform mutation + await updateDatabase(request) + + // Logging blocks the response + const userAgent = request.headers.get('user-agent') || 'unknown' + await logUserAction({ userAgent }) + + return new Response(JSON.stringify({ status: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} +``` + +**Correct (non-blocking):** + +```tsx +import { after } from 'next/server' +import { headers, cookies } from 'next/headers' +import { logUserAction } from '@/app/utils' + +export async function POST(request: Request) { + // Perform mutation + await updateDatabase(request) + + // Log after response is sent + after(async () => { + const userAgent = (await headers()).get('user-agent') || 'unknown' + const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous' + + logUserAction({ sessionCookie, userAgent }) + }) + + return new Response(JSON.stringify({ status: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} +``` + +The response is sent immediately while logging happens in the background. + +**Common use cases:** + +- Analytics tracking +- Audit logging +- Sending notifications +- Cache invalidation +- Cleanup tasks + +**Important notes:** + +- `after()` runs even if the response fails or redirects +- Works in Server Actions, Route Handlers, and Server Components + +Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after) diff --git a/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md b/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md new file mode 100644 index 0000000..ee82c04 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md @@ -0,0 +1,96 @@ +--- +title: Authenticate Server Actions Like API Routes +impact: CRITICAL +impactDescription: prevents unauthorized access to server mutations +tags: server, server-actions, authentication, security, authorization +--- + +## Authenticate Server Actions Like API Routes + +**Impact: CRITICAL (prevents unauthorized access to server mutations)** + +Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly. + +Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation." + +**Incorrect (no authentication check):** + +```typescript +'use server' + +export async function deleteUser(userId: string) { + // Anyone can call this! No auth check + await db.user.delete({ where: { id: userId } }) + return { success: true } +} +``` + +**Correct (authentication inside the action):** + +```typescript +'use server' + +import { verifySession } from '@/lib/auth' +import { unauthorized } from '@/lib/errors' + +export async function deleteUser(userId: string) { + // Always check auth inside the action + const session = await verifySession() + + if (!session) { + throw unauthorized('Must be logged in') + } + + // Check authorization too + if (session.user.role !== 'admin' && session.user.id !== userId) { + throw unauthorized('Cannot delete other users') + } + + await db.user.delete({ where: { id: userId } }) + return { success: true } +} +``` + +**With input validation:** + +```typescript +'use server' + +import { verifySession } from '@/lib/auth' +import { z } from 'zod' + +const updateProfileSchema = z.object({ + userId: z.string().uuid(), + name: z.string().min(1).max(100), + email: z.string().email() +}) + +export async function updateProfile(data: unknown) { + // Validate input first + const validated = updateProfileSchema.parse(data) + + // Then authenticate + const session = await verifySession() + if (!session) { + throw new Error('Unauthorized') + } + + // Then authorize + if (session.user.id !== validated.userId) { + throw new Error('Can only update own profile') + } + + // Finally perform the mutation + await db.user.update({ + where: { id: validated.userId }, + data: { + name: validated.name, + email: validated.email + } + }) + + return { success: true } +} +``` + +Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication) diff --git a/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md b/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md new file mode 100644 index 0000000..ef6938a --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md @@ -0,0 +1,41 @@ +--- +title: Cross-Request LRU Caching +impact: HIGH +impactDescription: caches across requests +tags: server, cache, lru, cross-request +--- + +## Cross-Request LRU Caching + +`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache. + +**Implementation:** + +```typescript +import { LRUCache } from 'lru-cache' + +const cache = new LRUCache<string, any>({ + max: 1000, + ttl: 5 * 60 * 1000 // 5 minutes +}) + +export async function getUser(id: string) { + const cached = cache.get(id) + if (cached) return cached + + const user = await db.user.findUnique({ where: { id } }) + cache.set(id, user) + return user +} + +// Request 1: DB query, result cached +// Request 2: cache hit, no DB query +``` + +Use when sequential user actions hit multiple endpoints needing the same data within seconds. + +**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis. + +**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching. + +Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache) diff --git a/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md b/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md new file mode 100644 index 0000000..87c9ca3 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md @@ -0,0 +1,76 @@ +--- +title: Per-Request Deduplication with React.cache() +impact: MEDIUM +impactDescription: deduplicates within request +tags: server, cache, react-cache, deduplication +--- + +## Per-Request Deduplication with React.cache() + +Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most. + +**Usage:** + +```typescript +import { cache } from 'react' + +export const getCurrentUser = cache(async () => { + const session = await auth() + if (!session?.user?.id) return null + return await db.user.findUnique({ + where: { id: session.user.id } + }) +}) +``` + +Within a single request, multiple calls to `getCurrentUser()` execute the query only once. + +**Avoid inline objects as arguments:** + +`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits. + +**Incorrect (always cache miss):** + +```typescript +const getUser = cache(async (params: { uid: number }) => { + return await db.user.findUnique({ where: { id: params.uid } }) +}) + +// Each call creates new object, never hits cache +getUser({ uid: 1 }) +getUser({ uid: 1 }) // Cache miss, runs query again +``` + +**Correct (cache hit):** + +```typescript +const getUser = cache(async (uid: number) => { + return await db.user.findUnique({ where: { id: uid } }) +}) + +// Primitive args use value equality +getUser(1) +getUser(1) // Cache hit, returns cached result +``` + +If you must pass objects, pass the same reference: + +```typescript +const params = { uid: 1 } +getUser(params) // Query runs +getUser(params) // Cache hit (same reference) +``` + +**Next.js-Specific Note:** + +In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks: + +- Database queries (Prisma, Drizzle, etc.) +- Heavy computations +- Authentication checks +- File system operations +- Any non-fetch async work + +Use `React.cache()` to deduplicate these operations across your component tree. + +Reference: [React.cache documentation](https://react.dev/reference/react/cache) diff --git a/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md b/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md new file mode 100644 index 0000000..fb24a25 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md @@ -0,0 +1,65 @@ +--- +title: Avoid Duplicate Serialization in RSC Props +impact: LOW +impactDescription: reduces network payload by avoiding duplicate serialization +tags: server, rsc, serialization, props, client-components +--- + +## Avoid Duplicate Serialization in RSC Props + +**Impact: LOW (reduces network payload by avoiding duplicate serialization)** + +RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server. + +**Incorrect (duplicates array):** + +```tsx +// RSC: sends 6 strings (2 arrays × 3 items) +<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} /> +``` + +**Correct (sends 3 strings):** + +```tsx +// RSC: send once +<ClientList usernames={usernames} /> + +// Client: transform there +'use client' +const sorted = useMemo(() => [...usernames].sort(), [usernames]) +``` + +**Nested deduplication behavior:** + +Deduplication works recursively. Impact varies by data type: + +- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated +- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference + +```tsx +// string[] - duplicates everything +usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings + +// object[] - duplicates array structure only +users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4) +``` + +**Operations breaking deduplication (create new references):** + +- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]` +- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())` + +**More examples:** + +```tsx +// ❌ Bad +<C users={users} active={users.filter(u => u.active)} /> +<C product={product} productName={product.name} /> + +// ✅ Good +<C users={users} /> +<C product={product} /> +// Do filtering/destructuring in client +``` + +**Exception:** Pass derived data when transformation is expensive or client doesn't need original. diff --git a/.agents/skills/vercel-react-best-practices/rules/server-hoist-static-io.md b/.agents/skills/vercel-react-best-practices/rules/server-hoist-static-io.md new file mode 100644 index 0000000..25205c2 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/server-hoist-static-io.md @@ -0,0 +1,149 @@ +--- +title: Hoist Static I/O to Module Level +impact: HIGH +impactDescription: avoids repeated file/network I/O per request +tags: server, io, performance, next.js, route-handlers, og-image +--- + +## Hoist Static I/O to Module Level + +**Impact: HIGH (avoids repeated file/network I/O per request)** + +When loading static assets (fonts, logos, images, config files) in route handlers or server functions, hoist the I/O operation to module level. Module-level code runs once when the module is first imported, not on every request. This eliminates redundant file system reads or network fetches that would otherwise run on every invocation. + +**Incorrect (reads font file on every request):** + +```typescript +// app/api/og/route.tsx +import { ImageResponse } from 'next/og' + +export async function GET(request: Request) { + // Runs on EVERY request - expensive! + const fontData = await fetch( + new URL('./fonts/Inter.ttf', import.meta.url) + ).then(res => res.arrayBuffer()) + + const logoData = await fetch( + new URL('./images/logo.png', import.meta.url) + ).then(res => res.arrayBuffer()) + + return new ImageResponse( + <div style={{ fontFamily: 'Inter' }}> + <img src={logoData} /> + Hello World + </div>, + { fonts: [{ name: 'Inter', data: fontData }] } + ) +} +``` + +**Correct (loads once at module initialization):** + +```typescript +// app/api/og/route.tsx +import { ImageResponse } from 'next/og' + +// Module-level: runs ONCE when module is first imported +const fontData = fetch( + new URL('./fonts/Inter.ttf', import.meta.url) +).then(res => res.arrayBuffer()) + +const logoData = fetch( + new URL('./images/logo.png', import.meta.url) +).then(res => res.arrayBuffer()) + +export async function GET(request: Request) { + // Await the already-started promises + const [font, logo] = await Promise.all([fontData, logoData]) + + return new ImageResponse( + <div style={{ fontFamily: 'Inter' }}> + <img src={logo} /> + Hello World + </div>, + { fonts: [{ name: 'Inter', data: font }] } + ) +} +``` + +**Correct (synchronous fs at module level):** + +```typescript +// app/api/og/route.tsx +import { ImageResponse } from 'next/og' +import { readFileSync } from 'fs' +import { join } from 'path' + +// Synchronous read at module level - blocks only during module init +const fontData = readFileSync( + join(process.cwd(), 'public/fonts/Inter.ttf') +) + +const logoData = readFileSync( + join(process.cwd(), 'public/images/logo.png') +) + +export async function GET(request: Request) { + return new ImageResponse( + <div style={{ fontFamily: 'Inter' }}> + <img src={logoData} /> + Hello World + </div>, + { fonts: [{ name: 'Inter', data: fontData }] } + ) +} +``` + +**Incorrect (reads config on every call):** + +```typescript +import fs from 'node:fs/promises' + +export async function processRequest(data: Data) { + const config = JSON.parse( + await fs.readFile('./config.json', 'utf-8') + ) + const template = await fs.readFile('./template.html', 'utf-8') + + return render(template, data, config) +} +``` + +**Correct (hoists config and template to module level):** + +```typescript +import fs from 'node:fs/promises' + +const configPromise = fs + .readFile('./config.json', 'utf-8') + .then(JSON.parse) +const templatePromise = fs.readFile('./template.html', 'utf-8') + +export async function processRequest(data: Data) { + const [config, template] = await Promise.all([ + configPromise, + templatePromise, + ]) + + return render(template, data, config) +} +``` + +When to use this pattern: + +- Loading fonts for OG image generation +- Loading static logos, icons, or watermarks +- Reading configuration files that don't change at runtime +- Loading email templates or other static templates +- Any static asset that's the same across all requests + +When not to use this pattern: + +- Assets that vary per request or user +- Files that may change during runtime (use caching with TTL instead) +- Large files that would consume too much memory if kept loaded +- Sensitive data that shouldn't persist in memory + +With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute), module-level caching is especially effective because multiple concurrent requests share the same function instance. The static assets stay loaded in memory across requests without cold start penalties. + +In traditional serverless, each cold start re-executes module-level code, but subsequent warm invocations reuse the loaded assets until the instance is recycled. diff --git a/.agents/skills/vercel-react-best-practices/rules/server-no-shared-module-state.md b/.agents/skills/vercel-react-best-practices/rules/server-no-shared-module-state.md new file mode 100644 index 0000000..ff7d9f2 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/server-no-shared-module-state.md @@ -0,0 +1,50 @@ +--- +title: Avoid Shared Module State for Request Data +impact: HIGH +impactDescription: prevents concurrency bugs and request data leaks +tags: server, rsc, ssr, concurrency, security, state +--- + +## Avoid Shared Module State for Request Data + +For React Server Components and client components rendered during SSR, avoid using mutable module-level variables to share request-scoped data. Server renders can run concurrently in the same process. If one render writes to shared module state and another render reads it, you can get race conditions, cross-request contamination, and security bugs where one user's data appears in another user's response. + +Treat module scope on the server as process-wide shared memory, not request-local state. + +**Incorrect (request data leaks across concurrent renders):** + +```tsx +let currentUser: User | null = null + +export default async function Page() { + currentUser = await auth() + return <Dashboard /> +} + +async function Dashboard() { + return <div>{currentUser?.name}</div> +} +``` + +If two requests overlap, request A can set `currentUser`, then request B overwrites it before request A finishes rendering `Dashboard`. + +**Correct (keep request data local to the render tree):** + +```tsx +export default async function Page() { + const user = await auth() + return <Dashboard user={user} /> +} + +function Dashboard({ user }: { user: User | null }) { + return <div>{user?.name}</div> +} +``` + +Safe exceptions: + +- Immutable static assets or config loaded once at module scope +- Shared caches intentionally designed for cross-request reuse and keyed correctly +- Process-wide singletons that do not store request- or user-specific mutable data + +For static assets and config, see [Hoist Static I/O to Module Level](./server-hoist-static-io.md). diff --git a/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md b/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md new file mode 100644 index 0000000..1affc83 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md @@ -0,0 +1,83 @@ +--- +title: Parallel Data Fetching with Component Composition +impact: CRITICAL +impactDescription: eliminates server-side waterfalls +tags: server, rsc, parallel-fetching, composition +--- + +## Parallel Data Fetching with Component Composition + +React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching. + +**Incorrect (Sidebar waits for Page's fetch to complete):** + +```tsx +export default async function Page() { + const header = await fetchHeader() + return ( + <div> + <div>{header}</div> + <Sidebar /> + </div> + ) +} + +async function Sidebar() { + const items = await fetchSidebarItems() + return <nav>{items.map(renderItem)}</nav> +} +``` + +**Correct (both fetch simultaneously):** + +```tsx +async function Header() { + const data = await fetchHeader() + return <div>{data}</div> +} + +async function Sidebar() { + const items = await fetchSidebarItems() + return <nav>{items.map(renderItem)}</nav> +} + +export default function Page() { + return ( + <div> + <Header /> + <Sidebar /> + </div> + ) +} +``` + +**Alternative with children prop:** + +```tsx +async function Header() { + const data = await fetchHeader() + return <div>{data}</div> +} + +async function Sidebar() { + const items = await fetchSidebarItems() + return <nav>{items.map(renderItem)}</nav> +} + +function Layout({ children }: { children: ReactNode }) { + return ( + <div> + <Header /> + {children} + </div> + ) +} + +export default function Page() { + return ( + <Layout> + <Sidebar /> + </Layout> + ) +} +``` diff --git a/.agents/skills/vercel-react-best-practices/rules/server-parallel-nested-fetching.md b/.agents/skills/vercel-react-best-practices/rules/server-parallel-nested-fetching.md new file mode 100644 index 0000000..be1dc25 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/server-parallel-nested-fetching.md @@ -0,0 +1,34 @@ +--- +title: Parallel Nested Data Fetching +impact: CRITICAL +impactDescription: eliminates server-side waterfalls +tags: server, rsc, parallel-fetching, promise-chaining +--- + +## Parallel Nested Data Fetching + +When fetching nested data in parallel, chain dependent fetches within each item's promise so a slow item doesn't block the rest. + +**Incorrect (a single slow item blocks all nested fetches):** + +```tsx +const chats = await Promise.all( + chatIds.map(id => getChat(id)) +) + +const chatAuthors = await Promise.all( + chats.map(chat => getUser(chat.author)) +) +``` + +If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 chats can't start loading even though their data is ready. + +**Correct (each item chains its own nested fetch):** + +```tsx +const chatAuthors = await Promise.all( + chatIds.map(id => getChat(id).then(chat => getUser(chat.author))) +) +``` + +Each item independently chains `getChat` → `getUser`, so a slow chat doesn't block author fetches for the others. diff --git a/.agents/skills/vercel-react-best-practices/rules/server-serialization.md b/.agents/skills/vercel-react-best-practices/rules/server-serialization.md new file mode 100644 index 0000000..39c5c41 --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/rules/server-serialization.md @@ -0,0 +1,38 @@ +--- +title: Minimize Serialization at RSC Boundaries +impact: HIGH +impactDescription: reduces data transfer size +tags: server, rsc, serialization, props +--- + +## Minimize Serialization at RSC Boundaries + +The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses. + +**Incorrect (serializes all 50 fields):** + +```tsx +async function Page() { + const user = await fetchUser() // 50 fields + return <Profile user={user} /> +} + +'use client' +function Profile({ user }: { user: User }) { + return <div>{user.name}</div> // uses 1 field +} +``` + +**Correct (serializes only 1 field):** + +```tsx +async function Page() { + const user = await fetchUser() + return <Profile name={user.name} /> +} + +'use client' +function Profile({ name }: { name: string }) { + return <div>{name}</div> +} +``` diff --git a/.claude/skills/codex-review/SKILL.md b/.claude/skills/codex-review/SKILL.md new file mode 100644 index 0000000..f2ed625 --- /dev/null +++ b/.claude/skills/codex-review/SKILL.md @@ -0,0 +1,39 @@ +--- +name: codex-review +description: Run an independent cross-model review with Codex on the current task branch or design, then triage its findings. Use when AGENTS.md requires an independent reviewer (T2, CI or guard changes) or when the user asks for a Codex review; not for self-review of T0/T1 work. +--- + +# Codex review + +Codex reviews; you (the implementer) prepare the package, run it, and triage. Codex never edits files. + +## 1. Prepare +- Work is committed on your task branch inside your worktree: review a commit, not a dirty tree. +- `git fetch origin`; rebase onto `origin/main` if it moved. Record the reviewed SHA: `git rev-parse HEAD`. +- Write a short brief in `.tmp/<task-slug>/review-brief.md`: goal, non-goals, task size, invariants and boundaries touched, acceptance criteria. Facts only: no claims that the change is correct, minimal or safe. + +## 2. Run (read-only, from the worktree root) +Write `.tmp/<task-slug>/review-prompt.md`: the brief, then this instruction: + +> You are an independent reviewer of the changes on this branch against origin/main (git diff origin/main...HEAD). Follow AGENTS.md: its task-size table, project invariants and review rules. Read the code and tests yourself; trust no summary. Check correctness, regressions, security, auth and user ownership, concurrency, retries and leases, migrations, AI-spend and notification side effects, and test adequacy as relevant. Report findings only, ordered by severity, each with: P0/P1/P2, file:line, a concrete failing scenario, actual versus required behavior. Say explicitly if you found nothing blocking. Do not modify files. + +Design (no diff yet): replace the diff sentence with "review this design for the repository" and append the design text or its `.tmp` path. + +The prompt is read from stdin, so the same command works in Command Prompt and in a POSIX shell: +```sh +codex exec -s read-only -c model_reasoning_effort="high" -o .tmp/<task-slug>/review-<n>.md - < .tmp/<task-slug>/review-prompt.md +``` +`-o` saves Codex's final message. Never overwrite an earlier review file. + +## 3. If Codex cannot run +Not installed, config or sandbox error, usage limit: report the error once, do not loop. Fall back to a fresh read-only subagent (or a fresh session) given the same brief and SHA, and label the reviewer as fallback. Your own re-read is not an independent review. + +## 4. Triage every finding +- **Accept**: reproduce it (a failing test where feasible), then fix. +- **Reject**: cite code or test evidence (file:line) showing why it does not apply. +- **Human decision**: policy, product or data-semantics questions go to the user; do not resolve them by changing requirements. + +Fix accepted findings in one wave, rerun the affected checks, and review the new SHA again. At most two correction rounds; then stop and hand the remaining findings to the user. + +## 5. Report (PR Review section) +Reviewer (Codex, model and effort, or fallback), reviewed SHA, a findings table with disposition, and open human decisions. diff --git a/.claude/skills/engineering-workflow/SKILL.md b/.claude/skills/engineering-workflow/SKILL.md new file mode 100644 index 0000000..fa56b80 --- /dev/null +++ b/.claude/skills/engineering-workflow/SKILL.md @@ -0,0 +1,43 @@ +--- +name: engineering-workflow +description: Orchestrate an explicitly requested complex or high-risk engineering task through planning, fresh independent plan review, implementation, verification, fresh independent code review, bounded correction, and human approval. Use for "run engineering workflow", "autonomous implementation with review", or similar; do not auto-trigger for routine edits or audit-only requests. +--- + +# Engineering Workflow — native-agent pilot v1.1 + +Use this skill only when an autonomous engineering workflow is expressly requested. This is an orchestration **procedure**, not a background daemon, CI service, or automatic grant of privileges. Follow repository `AGENTS.md`, project `CLAUDE.md`, higher-priority instructions, and approved product and data policies. Use existing skills (`ponytail`, `ponytail-review`, `systematic-debugging`, relevant database skill) within their appropriate phases; never override their project routing/safety boundaries. + +## Entry and mode + +1. Inspect checkout and `git status`, task owner, current code, approved decisions and verification instructions. Preserve all pre-existing changes. Establish `BASE` (current HEAD), scope, authorized actions, and exact required checks. +2. Choose the **lightest authorized mode**: `DESIGN_ONLY` for explicit design-only/dormant contract tasks; `MEDIUM` for bounded feature changes; `HIGH_RISK` for migrations, auth/ownership, ingestion and lease concurrency, recovery, AI spend, notifications and live integration. The user may narrow the mode, but a lower mode may not waive project safety gates. Simple fixes use the normal repository workflow instead. +3. Create one ignored handoff directory `.tmp/agent-workflows/<task-slug>/` (verify with `git check-ignore`) and follow the repository's *Files and artifacts* rules. Keep only: `state.json`; `plan.md`; one immutable reviewer report per review attempt (for example `plan-review-1.md`, `code-review-1.md`; never reuse a name); `verification.json` (VERIFY evidence: commands, exit codes, counts, short failure excerpts); `final-checks.json` (FINAL_GATE evidence); and at most one compact `handoff.md` when a stage records a blocking note. Plan, verification and final-check files may be rewritten only by recording them again with the state helper, which re-digests them; any other change to a recorded file is `BLOCKED_EVIDENCE_CHANGED`. No source snapshots, diff copies, full runner JSON or duplicate logs: Git and the checkpoint fingerprint already cover them. These files are the resume source: keep them until the workflow is finalized (human approval or rejection with the handoff delivered) or the user explicitly abandons it; never clean them at a pause or at `HUMAN_APPROVAL`. Do **not** create permanent per-round reports. Never save secrets. +4. **Before any agent dispatch**, read [resume.md](references/resume.md), initialize or resume the local state ledger with `node scripts/engineering-workflow-state.mjs`, and inspect the returned guard status. Never infer completed work from a partial chat transcript or a reviewer summary. State and evidence are untrusted until checked against source, artifacts, execution logs and actual reviewer provenance. +5. Persist an atomic checkpoint after each *completed* stage, including an immutable review report path and a verified fresh reviewer context identifier for review stages. Preserve the original review records instead of overwriting a previous verdict. Persist pause reason when capacity, permissions or environment block progress. +6. On resume: recompute full nonignored source fingerprint and recorded artifact digests, preserve the correction budget and stage, and **redrive only an incomplete stage**. If evidence changed, do not reuse an approval. If a write stage is interrupted mid-edit, inspect its partial diff and finish that same stage before recording its checkpoint. If a review was interrupted before a saved verdict, dispatch a fresh reviewer; never claim an old incomplete attempt succeeded. +7. Read [planning.md](references/planning.md), then [review.md](references/review.md), [correction.md](references/correction.md), and [acceptance.md](references/acceptance.md) as their stages begin. Do not preload every reference for unrelated stages. + +## State machine and roles + +`INSPECT -> PLAN -> PLAN_REVIEW -> IMPLEMENT -> VERIFY -> CODE_REVIEW -> (CORRECT -> VERIFY -> CODE_REVIEW)* -> FINAL_GATE -> HUMAN_APPROVAL` + +- In `DESIGN_ONLY`, stop after approved `PLAN_REVIEW` at `HUMAN_APPROVAL`; **do not implement**. +- Plan review and code review must use a **fresh, isolated context/subagent** with implementation write access withheld where supported; send task brief, acceptance criteria, exact BASE and current diff/evidence, not the author's narrative or approval claim. A reviewer must independently inspect actual sources. Do not call the implementer's self-review an independent review. +- If the current host cannot spawn a genuinely separate reviewer context, mark `BLOCKED_INDEPENDENT_REVIEW` and provide the handoff for a separate session. Do not silently simulate reviewer independence in the orchestrator context. +- Implementers own code changes, tests and permissible local operations; reviewers own findings/approval only. Do not parallelize implementation agents that write overlapping files. The orchestrator coordinates the stages but may not override explicit human decisions. +- Run deterministic checks with actual commands. A model verdict cannot override failing tests or an unverified required gate. +- Limit automatic correction to **two rounds** per checkpoint (shared across plan/code corrections; the ledger counter survives interruption and rebaseline). Resolve findings as one coherent fix wave; re-review the changed revision. Escalate unresolved P0/P1, a third fix round, design/policy ambiguity, permission errors or unavailable mandatory verification to `BLOCKED` with consolidated findings. + +## Revision and approval integrity + +Record `BASE`, full tracked and untracked inventory, and a revision fingerprint before each review. Use the checkpoint helper to hash HEAD + Git index/status + bytes of every nonignored tracked/untracked worktree file; track the plan/review/verification artifacts with separate content digests. A hash of the committed SHA alone is **not** enough for dirty working trees. Review package must include staged/unstaged diff and relevant untracked deliverables; ignored temp artifacts do not count. Any material source or test change after approval invalidates the approval until review and checks rerun for the new revision. A changed ignored plan or reviewer artifact also invalidates its recorded approval. Stale approval cannot be revived by a resume or counter reset; explicit rebaseline requires new independent checks and never grants human authorization. + +No automatic commit, push, PR creation, merge, deploy, real provider calls, production database mutation, cron registration or live-provider activation without separate authorization. For StackPulse, keep Skill Updates cron registration disabled and preserve its staging gates and budget limits. + +## Output contract + +Final response: mode, checkpoint directory/state, resume guard status, correction budget, verified checkout/revision, stage reached, design/reviewer verdict, exact passed/failed/skipped/not-run checks, unresolved P0/P1/P2 issues, files changed, safety boundaries, next human decision. Distinguish *author reported*, *independently executed*, *hosted CI*, and *not tested* evidence. **`READY_FOR_HUMAN_APPROVAL` is not deployment or production approval.** + +### References and inspirations + +Original adaptation inspired by `obra/superpowers` (fresh implementer + spec/quality review, precise review packages), `Yeachan-Heo/oh-my-claudecode` (staged plan/exec/verify/fix pipeline), and the already installed `ponytail-review`, `grilling`, and `systematic-debugging` skills. No upstream skill code copied. See repository `docs/AI_WORKFLOW.md` for links and provenance. diff --git a/.claude/skills/engineering-workflow/references/acceptance.md b/.claude/skills/engineering-workflow/references/acceptance.md new file mode 100644 index 0000000..7f744c2 --- /dev/null +++ b/.claude/skills/engineering-workflow/references/acceptance.md @@ -0,0 +1,20 @@ +# Final gate and human acceptance + +## Deterministic gates + +- Freeze scope and revision (BASE + commit SHA when committed + staged/unstaged diff fingerprint + untracked file inventory). If files changed after review, invalidate and rerun applicable checks/review. +- Confirm independently approved plan and implementation (unless explicit DESIGN_ONLY), exact acceptance assertions, required passing checks, relevant DB/upgrade/concurrency tests, source/permission invariants, and complete accounted skip/failure list. +- A check marked `NOT_RUN`, `BLOCKED_ENVIRONMENT` or `AUTHOR_REPORTED` does not satisfy a mandatory gate. Hosted CI is a separate gate from local Docker/Postgres; do not claim success without its result for the exact SHA. Workflows do not push, so at `FINAL_GATE` hosted CI is reported as `NOT_RUN`: it is not a mandatory gate for `READY_FOR_HUMAN_APPROVAL`, but it must pass on the PR before merge. +- Inspect dirty worktree and ensure generated/temp/secret files are not staged. Keep review artifacts in existing `.tmp/agent-workflows/<task>/` and preserve significant tests in version control. Only durable design/contracts/operations belong in canonical docs. + +## Verdicts and permissions + +- `READY_FOR_HUMAN_APPROVAL`: all in-scope mandatory gates completed on the reviewed revision; not equivalent to commit/merge/deploy permission. +- `BLOCKED`: unresolved P0/P1, acceptance-blocking P2, policy decision, review independence unavailable, exceeded correction limit, or required tests missing. +- `DESIGN_READY_FOR_HUMAN_APPROVAL`: design-only plan independently approved; implementation NOT authorized by this verdict. + +The human receives a short, actionable summary with all open questions. No unattended push, PR creation, merge, production database mutation, cron registration, provider call or deployment. If the user later authorizes one action, that authorization does not silently extend to the others. + +## Resume integrity gate + +Before presenting final approval, run `node scripts/engineering-workflow-state.mjs resume <task-slug>`, inspect `state.json`, and confirm both its **current source fingerprint** and each recorded ignored artifact digest still match. Record `FINAL_GATE PASSED` only with real evidence, matching independent plan/code review, checks performed on the current revision and an accepted zero-blocker disposition. A paused stage, stale/missing artifact, changed source, or exhausted correction budget cannot become `READY_FOR_HUMAN_APPROVAL` through replay of a summary. See [resume.md](resume.md). diff --git a/.claude/skills/engineering-workflow/references/correction.md b/.claude/skills/engineering-workflow/references/correction.md new file mode 100644 index 0000000..561a734 --- /dev/null +++ b/.claude/skills/engineering-workflow/references/correction.md @@ -0,0 +1,12 @@ +# Bounded correction + +- Read reviewer findings and reproduce each accepted blocker against unchanged reviewer tests before changing implementation. Use `systematic-debugging` for unclear root causes; avoid symptom-level fixes. +- One correction agent receives the **whole actionable findings set**, plus frozen acceptance contract, exact affected code and necessary tests. Keep each wave surgical and sequential. Preserve unrelated work, original adverse reviewer assertions and historical failing baselines. +- Add permanent regression cases for real correctness defects. Do not edit the review test to force green; if reviewer test is mistaken, produce a source-backed explanation for independent adjudication. +- Re-run scoped checks plus all impacted integration suites. A correction may expose a *different* case; classify its relation to the requested contract instead of growing an unbounded whole-repo audit. +- Revision fingerprint changes after every correction. Dispatch a fresh reviewer against the **new full revision** (or a scoped fix diff plus a full baseline review package); old approval does not carry over. +- Automatic correction budget = 2 waves. P0, unapproved product/business semantics, restricted execution, repeated P1 after two waves or mandatory missing test environment -> `BLOCKED`, with one consolidated handoff to the human. Never retry the same failure without new evidence. + +## Correction rounds across resumes + +Count a correction wave when a review/verification verdict enters `CORRECT_DESIGN` or `CORRECT`, not when the agent is relaunched. The two-round budget spans design/code work within the same checkpoint and **must survive restarts or explicit rebaseline**. An interrupted writer may continue its partial diff after inspection; do not create a new correction wave or mark the stage done merely because the tool stopped. Record the completed writer stage and re-dispatch a fresh reviewer after verification. See [resume.md](resume.md). diff --git a/.claude/skills/engineering-workflow/references/planning.md b/.claude/skills/engineering-workflow/references/planning.md new file mode 100644 index 0000000..58252e1 --- /dev/null +++ b/.claude/skills/engineering-workflow/references/planning.md @@ -0,0 +1,19 @@ +# Planning and plan review + +## Planner + +1. Establish the current source-of-truth: repo instructions, git state, approved decisions, actual call paths, database constraints and existing tests. Do not treat an older proposal as present behavior. +2. Write a compact task brief: goal, risk tier, explicit non-goals, affected invariants, files/code entry points, dependencies, acceptance checks, known unknowns and expected human gates. +3. Split tightly coupled work into **sequential** checkpoints. Avoid speculative interfaces, a full-repo audit and an oversized all-at-once implementation. Reuse the current skill routing: `grilling` only for consequential unsettled choices, `ponytail` for bounded implementation. +4. For HIGH_RISK work, name the transaction or lease owner, source of truth, user-ownership checks, failure/retry model, concurrency participants, DB test matrix, AI-spend and notification exposure, and prohibited activation (cron registration, live providers). Mark unimplemented prerequisites as blockers; do not invent synthetic production authority. +5. List tests that actually prove acceptance, and the checks possible in the environment; mocks do not prove Postgres concurrency or COMMIT semantics. + +## Fresh independent plan reviewer + +Pass the brief and source-entry points; do not hand over author conclusions as facts. Verify plan against real code/SQL; challenge scope, invariants, missing rollback/replay edges, lock order, migration upgrades, auth, source trust and test feasibility. Return `PLAN_APPROVED` or `PLAN_REQUEST_CHANGES` with P0/P1/P2 findings, exact evidence and smallest correction. The planner may revise the plan in at most two rounds; material policy or human-choice conflicts become `BLOCKED_HUMAN_DECISION`. + +In `DESIGN_ONLY`, approved design is the terminal output pending human acceptance. Do **not** begin the first implementation checkpoint. Design-only review may not be reinterpreted as code or deployment authorization. + +## Checkpoint handoff + +After the plan is complete, save it under the task's ignored workspace and record `PLAN READY` with `--artifact` in the v1.1 state ledger. If a reviewer requests changes, preserve the original reviewer report and write the corrected plan as a new version of the plan file; the current digest is updated only after correction actually completes. Do not reset the task slug or correction counter when a session is interrupted; use [resume.md](resume.md). diff --git a/.claude/skills/engineering-workflow/references/resume.md b/.claude/skills/engineering-workflow/references/resume.md new file mode 100644 index 0000000..f9b1ddb --- /dev/null +++ b/.claude/skills/engineering-workflow/references/resume.md @@ -0,0 +1,75 @@ +# Checkpoint and resume protocol (v1.1) + +This protocol makes native-agent workflows **recoverable**, not asynchronous. The host must still execute an actual agent turn when resumed. An API/model usage limit cannot be automatically bypassed; no background timer or polling is implied. + +## Local authoritative handoff + +Use one ignored workspace **per task**, for example `.tmp/agent-workflows/skill-lease-retry/`. Preserve existing task workspace files; do not overwrite old reviewer evidence. The checkpoint ledger `state.json` is a recovery hint that must be verified, **not** approval or proof that an agent actually ran. Keep only the files SKILL.md step 3 allows (state, plan, reviewer reports, one compact verification summary); include no secrets or real provider payloads. + +The helper lives at `scripts/engineering-workflow-state.mjs` and needs only Node 22+ and Git. Always run at the repository root. Example: + +```sh +node scripts/engineering-workflow-state.mjs init skill-lease-retry DESIGN_ONLY +node scripts/engineering-workflow-state.mjs resume skill-lease-retry +``` + +`init` **refuses** to reset an existing task, preventing accidental loss of the correction budget. The helper checks the task directory is gitignored and captures HEAD, Git index, working tree, and every nonignored tracked/untracked file by content. Each mutation takes an exclusive short-lived `.checkpoint.lock` to reject concurrent writers; after an abrupt process crash a stale lock must be inspected and cleared manually only after verifying no writer remains. Ignored plan/review/log artifacts receive separate SHA-256 digests when recorded. This is local integrity detection, not a cryptographic attestation of reviewer identity. + +## Completed-stage checkpoints + +Only record a checkpoint **after** the stage has actually completed. Maintain the single task `state.json` instead of creating a new permanent Markdown report on each round. + +```sh +# Stage begins at INSPECT +node scripts/engineering-workflow-state.mjs advance skill-lease-retry READY +# Now PLAN, then PLAN_REVIEW +node scripts/engineering-workflow-state.mjs advance skill-lease-retry READY --artifact .tmp/agent-workflows/skill-lease-retry/plan.md +# Fresh external subagent review: never substitute an orchestrator self-review +node scripts/engineering-workflow-state.mjs advance skill-lease-retry PLAN_REQUEST_CHANGES --artifact .tmp/agent-workflows/skill-lease-retry/plan-review-1.md --reviewer-id codex-reviewer-1 +# CORRECT_DESIGN (uses one of two total correction rounds) +node scripts/engineering-workflow-state.mjs advance skill-lease-retry READY --artifact .tmp/agent-workflows/skill-lease-retry/plan.md +# PLAN_REVIEW again, with a genuinely fresh context and a NEW report filename +node scripts/engineering-workflow-state.mjs advance skill-lease-retry PLAN_APPROVED --artifact .tmp/agent-workflows/skill-lease-retry/plan-review-2.md --reviewer-id codex-reviewer-2 +# DESIGN_ONLY -> FINAL_GATE -> HUMAN_APPROVAL +node scripts/engineering-workflow-state.mjs advance skill-lease-retry PASSED --artifact .tmp/agent-workflows/skill-lease-retry/final-checks.json +``` + +The helper validates allowed state transitions and reviewer/artifact presence. It does **not** autonomously run tests or prove reviewer independence. The orchestrator must inspect actual logs, returned agent identities, approved source/plan, and required exit codes before recording `VERIFIED`, `PLAN_APPROVED`, `REVIEW_APPROVED` or `PASSED`. In `MEDIUM`/`HIGH_RISK`, expected stages after plan approval are `IMPLEMENT -> VERIFY -> CODE_REVIEW -> FINAL_GATE`; `VERIFY VERIFIED` requires a real ignored verification artifact; independent code review requires a new report and actual reviewer identifier. + +## Pause and interruption + +If the host interrupts a stage, record a pause if execution permissions still allow it: + +```sh +node scripts/engineering-workflow-state.mjs pause skill-lease-retry USAGE_LIMIT +node scripts/engineering-workflow-state.mjs resume skill-lease-retry +``` + +Supported reasons: `USAGE_LIMIT`, `REVIEW_CAPACITY`, `PERMISSION`, `ENVIRONMENT`, `INTERRUPTED`. If tools are unavailable and pause cannot be recorded, the last *completed* checkpoint is still authoritative. Do not create a synthetic reviewer verdict. + +- `READY`: fingerprint and artifacts match. Resume the **reported current stage**, not from `INSPECT`. +- `IN_PROGRESS_EDIT`: a partial diff exists in `PLAN`, `CORRECT_DESIGN`, `IMPLEMENT` or `CORRECT`. Inspect that diff; continue the same writer stage. Its previous approval cannot cover the changed implementation. Do not discard partial work. +- `BLOCKED_REVISION_CHANGED`: a revision changed while a nonwriting stage was pending. Stop; inspect the change. Do not silently reuse review or verification approval. +- `BLOCKED_EVIDENCE_CHANGED`: a recorded review/plan/check artifact was modified, deleted, or moved. Stop; preserve review history. Never convert a missing report into `APPROVED`. +- `HUMAN_APPROVAL`: terminal human gate. Resume never commits, pushes or authorizes implementation/deployment. + +If an independent reviewer invocation was interrupted **before** a completed stage checkpoint, that attempt has **no approved verdict**. Re-dispatch to a fresh reviewer context, retaining the prior incomplete evidence separately. On recovery from a usage limit, the human may need to press Continue or start a new session with this task slug; this is not unattended scheduled execution. + +## Source changes and explicit rebaseline + +When revision/evidence integrity checks fail, first inspect `git status`, complete diff, reviewer artifacts and source fingerprints. Unplanned changes during review invalidate that review. If source truly changed and re-review is required, an authorized agent may explicitly rebaseline: + +```sh +node scripts/engineering-workflow-state.mjs rebaseline skill-lease-retry PLAN --reason "Source changed; re-review required" +``` + +For implementation mode, an approved operator may choose `PLAN` or `VERIFY` depending on the source change. `PLAN` must record the current plan again and then obtain fresh independent approval. `VERIFY` is allowed only when an unchanged approved plan still applies, and it preserves plan approval while invalidating code review and test verification; inspect this assumption explicitly before choosing it. **Rebaseline clears stale approvals and verification as appropriate, retains the used correction count and full history, and never permits HUMAN_APPROVAL to be bypassed.** Prior reviewer artifacts must remain byte-for-byte unchanged; rebaseline is not a way to erase adverse findings. If the original plan has materially changed, record the updated plan explicitly before the new review and do not treat its old approval as valid. Where the root problem is ambiguous policy, production authority or restricted execution, stop for human action rather than rebaseline. + +## Final checkpoint invariants + +1. Reviewer identities must be verified from real fresh subagent/session execution; an ID string in JSON alone is not proof. +2. Accepted review applies only to the reviewed revision and plan artifact. Implementation changes require verification and a fresh code-review approval. +3. Test artifacts must contain independently checked commands, environment, exit codes, skip/fail accounting, and applicable final database state. `AUTHOR_REPORTED` and `NOT_RUN` cannot satisfy mandatory gates. +4. Correction budget is two total automatic fix waves per task; `init` and `rebaseline` do not reset it. +5. Any failure to validate state, files, gitignored boundaries, recorded hashes, or permissions **fails closed** and must not advance the workflow. +6. Do not serialize secrets or large customer payloads; keep temporary artifacts gitignored and clean them only after the workflow is finalized or explicitly abandoned (SKILL.md step 3). diff --git a/.claude/skills/engineering-workflow/references/review.md b/.claude/skills/engineering-workflow/references/review.md new file mode 100644 index 0000000..c1bd5c0 --- /dev/null +++ b/.claude/skills/engineering-workflow/references/review.md @@ -0,0 +1,21 @@ +# Independent code review + +## Review package + +- Task brief and approved acceptance criteria; BASE and current target revision; complete diff with context (including staged, unstaged and listed untracked files); related callers, schema/SQL, tests and exact verification logs. +- Reviewer works in a fresh context, read-only against implementation files. When repro tests are needed, use a separate disposable worktree/database and keep them out of the author's working tree until reviewed. +- Reviewer should verify **spec compliance first**, then correctness/safety/test quality. Use `ponytail-review` as the quality lens and relevant domain guidance as needed; don't launch a repo-wide `ponytail-audit` by default. + +## Required checks + +Trace real inputs, business identity, SQL ownership and externally visible effects. Challenge missing/contradictory evidence, replay, cancellation/rollback, lock ordering, stale state, privilege checks and migrations as relevant. Reproduce material findings. Report command/execution provenance and whether cases are newly executed or read from author logs. + +## Findings and verdict + +Each finding: severity (P0/P1/P2/observation), file/location, concrete scenario and actual-versus-required behavior, impacted invariant and reproducible assertion or reason not executable. Duplicate/inapplicable findings should be ruled with evidence, not counted as blockers indefinitely. + +`REVIEW_APPROVED` means no P0/P1 or acceptance-blocking P2 **for the reviewed revision only**. `REVIEW_REQUEST_CHANGES` includes an ordered consolidated fix list. A passing test count alone cannot establish approval; later code edits invalidate the verdict. Security/policy questions must not be silently fixed by changing requirements. + +## Interrupted reviewers and checkpoint evidence + +A reviewer invoked before a usage-limit interruption is **not approved** unless its independent verdict and evidence were successfully checkpointed. On resume, rerun the unfinished review in a fresh context. Record each reviewer response under a distinct ignored filename, include its actual reviewer/session identity, and verify reviewed source fingerprint before accepting the verdict. The ledger only stores claimed identity; real provenance must be checked in host execution history. See [resume.md](resume.md). diff --git a/.claude/skills/frontend-design/LICENSE.txt b/.claude/skills/frontend-design/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/.claude/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.claude/skills/frontend-design/SKILL.md b/.claude/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..a533345 --- /dev/null +++ b/.claude/skills/frontend-design/SKILL.md @@ -0,0 +1,71 @@ +--- +name: frontend-design +description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults. +license: Complete terms in LICENSE.txt +--- + +# Frontend Design + +Approach this as the design lead at a design studio known for giving every client a distinct visual identity that is not mistaken for anyone else's. This client has already rejected proposals that felt cliché or templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take aesthetic risk if justified. + +## Ground your designs in the subject matter + +If the brief does not identify what the product or subject matter is, identify it yourself before designing, and confirm with the client. You can come up with one concrete subject, the design's audience, and the design's primary job, as a proposal. If there's any information in your memory about the client's preferences or context about what they're building, use that as a hint. The subject's industry, subject matter, materials, and vernacular are where distinctive visual choices come from — a design for a toy for girls aged 8–11 will be very aesthetically different from a dashboard for financial analysts. Build with the brief's real content and subject matter throughout. + +## Design principles + +For web designs, the hero is the first thing viewers will see. Open with the most characteristic thing in the subject's world, in the form that is most appropriate: a headline, an image, an animation, a live demo, an interactive moment, or other treatments. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the default treatment, so only use it if that's truly the best option. + +Typography carries the personality of the page. You don't need a different typeface for display or headline text and body content: use one family or two, and if two, make them clearly distinct. + +Choose your typefaces deliberately, not the default families you would reach for on any other project, and set a clear type scale following the default guidance of The Elements of Typographic Style with intentional weights, widths, and spacing. When type is used as a headline or visual element, use the type treatment itself as an active part of the design, not a neutral delivery vehicle for the content. + +Default to line lengths of less than 80 characters. Serif typefaces can have slightly longer line lengths; give serif body text slightly more line-height than a sans-serif. + +Avoid these default typographic treatments; they are the commonest tells of a generated page: +- Accenting just a single word or phrase in a headline, like putting one word in italic/bold or a different color. +- Using all caps for labels. +- Adding unnecessary typographic labels above content. + +Visual structure is information. Structural devices like outlines, borders, numbering, eyebrows, dividers, labels, etc., encode useful information about the content rather than decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence — like a stepped process or a timeline. Before adding numbered markers, check the content really is a sequence. + +Use non-user-triggered motion sparingly and deliberately, only to draw attention. A single orchestrated moment — one page-load sequence or one reveal — lands better than scattered effects; fade-and-slide-up entrances on each section and hover transitions on every card are the generic default and read as AI-generated. Motion that answers a person's action (opening, expanding, confirming) is welcome when it shows what changed. + +Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy and placeholder content. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance. + +## Process: plan, review against the brief, build, critique + +For calibration, AI-generated design right now clusters around some traits: +1. a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta or warm-clay accent (often near #D97757 — Anthropic's own Claude-interaction accent, so on a user's brief it reads as a tell); +2. a near-black background with a single bright acid-green or vermilion accent; +3. a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns; +4. the SaaS-card kit: content chopped into identical rounded cards, one border-radius on everything regardless of hierarchy, the same soft grey shadow (rgba(0,0,0,.1)) under each, and gradient washes as decoration; +5. template chrome that appears whatever the subject: a tracked-out ALL-CAPS eyebrow label above every heading; meta strings joined with middle dots ('A · B · C'); labels built as 'WORD — fragment' with a spaced em dash; tinted near-black (#0B0B0B, #111) standing in for black; a monospace face for small data labels; a '→' appended to link and button text. + +All traits are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. As with a hired human designer, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn. + +Work in two passes. First, brainstorm a short design plan based on the client's design brief: create a compact token system with color, type, layout, and principles. +- Color: describe the core base palette as 4–6 named hex values. +- Type: the typefaces and their roles. +- Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Include alignment guidance; should the content be left aligned, center aligned, justified? +- Principles: the high-level guidance for what makes this page unique. + +Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan. + +When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and an element-based selector like .cta). This can happen often with padding/margin between sections. + +## Restraint and self-critique + +Spend your boldness in one place. Let one element be the memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected, visually accessible, harmonious color palettes. Critique your own work as you build, taking screenshots to review if your environment supports it — a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creatives have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes. + +## More on writing in design + +Words appear in a design for one reason: to make it easier to understand and use. They are design content, not decoration. Bring the same intentionality and minimalism to copywriting that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience. + +Write from the end user's perspective. Name things by what users will understand in simple language, not by how the system is built. A user manages notifications, not webhook config. Describe what something is or does in plain terms rather than selling it. Being specific and legible to new users is always better than being clever. + +Use active voice as default. A CTA says exactly what happens when it is used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around. + +Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act. + +Keep the tone conversational: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each written element do exactly one job. diff --git a/.claude/skills/grill-me/LICENSE b/.claude/skills/grill-me/LICENSE deleted file mode 100644 index f1dd2c0..0000000 --- a/.claude/skills/grill-me/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Matt Pocock - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/.claude/skills/grill-me/SKILL.md b/.claude/skills/grill-me/SKILL.md deleted file mode 100644 index 3947ff9..0000000 --- a/.claude/skills/grill-me/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: grill-me -description: A relentless interview to sharpen a plan or design. -disable-model-invocation: true ---- - -Call the Skill tool with "grilling". diff --git a/.claude/skills/grill-me/agents/openai.yaml b/.claude/skills/grill-me/agents/openai.yaml deleted file mode 100644 index 4d6fb0c..0000000 --- a/.claude/skills/grill-me/agents/openai.yaml +++ /dev/null @@ -1,5 +0,0 @@ -interface: - display_name: "Grill Me" - short_description: "Sharpen a plan through interview" -policy: - allow_implicit_invocation: false diff --git a/.claude/skills/ship-pr/SKILL.md b/.claude/skills/ship-pr/SKILL.md new file mode 100644 index 0000000..e5a2d14 --- /dev/null +++ b/.claude/skills/ship-pr/SKILL.md @@ -0,0 +1,38 @@ +--- +name: ship-pr +description: Prepare a finished task branch for a pull request (rebase, guards, pre-PR checks, review prerequisites, PR description from the template) and push or open the PR only after explicit user authorization. Use when a T1/T2 task is done; T0 changes may use it too. +--- + +# Ship a PR + +Never push, open or merge a PR without the user's explicit go-ahead in this session. Merging is always the human's. + +## 1. Bring the branch up to date +- In the task worktree, the tree is clean and everything is committed. +- `git fetch origin && git rebase origin/main`. On a conflict in migrations, T2 code or anything you cannot resolve with certainty, stop and report. + +## 2. Guards +- New migrations: the number follows the latest on `origin/main`, the journal entry is appended with a newer `when`. Run `node scripts/check-migration-history.mjs origin/main HEAD`. +- `node scripts/check-pr-review-signals.mjs origin/main HEAD`. If it lists T2-area files but the task was classified lower, reclassify and meet the T2 obligations before continuing. Treat a CI or guard change as needing an independent review. + +## 3. Checks +Run the pre-PR set and any size-specific checks from AGENTS.md *Verification commands*. Any FAIL stops here. Report every planned check as PASS, FAIL or NOT RUN with the reason. + +## 4. Review prerequisites +- T1: `ponytail-review` of your own diff. +- T2, CI or guard changes: `codex-review` on the current HEAD. If commits landed after that review, review again. +- Auth, user ownership, cron auth or secrets: also run `/security-review` in Claude Code. + +## 5. PR description +Write `.tmp/<task-slug>/pr.md` from `.github/pull_request_template.md`, including the review guide. Use only evidence from commands you actually ran; hosted CI is NOT RUN until the PR exists. Title: a Conventional Commit line. + +## 6. Ask, then ship +Show the user the branch, title and a three-line summary, and ask to push and open the PR. Only after a yes: +```sh +git push -u origin <type>/<slug> # always an explicit branch, never a bare push +gh pr create --base main --head <type>/<slug> --title "<title>" --body-file .tmp/<task-slug>/pr.md +``` +(or the host's GitHub tools). Report the PR URL. Watch checks (`gh pr checks`) only if asked; never merge. + +## 7. After the merge +When the user confirms the merge: remove the worktree with `git worktree remove`, delete the local branch, and delete the task's `.tmp` files. diff --git a/.claude/skills/vercel-react-best-practices/LICENSE b/.claude/skills/vercel-react-best-practices/LICENSE new file mode 100644 index 0000000..c11f389 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/LICENSE @@ -0,0 +1,10 @@ +vercel-react-best-practices is vendored from https://github.com/vercel-labs/agent-skills +(revision 063bee94c3f4df8453406c830b0a7df0f2860278, path skills/react-best-practices). + +Upstream declares the MIT license in its repository README ("## License: MIT") and in +SKILL.md frontmatter (`license: MIT`), but ships no LICENSE file at that revision, so no +license text or copyright notice is reproduced here. MIT terms: +https://opensource.org/license/mit + +This note is repository-authored. It applies to the vendored skill files in this +directory, not to the application. diff --git a/.claude/skills/vercel-react-best-practices/README.md b/.claude/skills/vercel-react-best-practices/README.md new file mode 100644 index 0000000..f283e1c --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/README.md @@ -0,0 +1,123 @@ +# React Best Practices + +A structured repository for creating and maintaining React Best Practices optimized for agents and LLMs. + +## Structure + +- `rules/` - Individual rule files (one per rule) + - `_sections.md` - Section metadata (titles, impacts, descriptions) + - `_template.md` - Template for creating new rules + - `area-description.md` - Individual rule files +- `src/` - Build scripts and utilities +- `metadata.json` - Document metadata (version, organization, abstract) +- __`AGENTS.md`__ - Compiled output (generated) +- __`test-cases.json`__ - Test cases for LLM evaluation (generated) + +## Getting Started + +1. Install dependencies: + ```bash + pnpm install + ``` + +2. Build AGENTS.md from rules: + ```bash + pnpm build + ``` + +3. Validate rule files: + ```bash + pnpm validate + ``` + +4. Extract test cases: + ```bash + pnpm extract-tests + ``` + +## Creating a New Rule + +1. Copy `rules/_template.md` to `rules/area-description.md` +2. Choose the appropriate area prefix: + - `async-` for Eliminating Waterfalls (Section 1) + - `bundle-` for Bundle Size Optimization (Section 2) + - `server-` for Server-Side Performance (Section 3) + - `client-` for Client-Side Data Fetching (Section 4) + - `rerender-` for Re-render Optimization (Section 5) + - `rendering-` for Rendering Performance (Section 6) + - `js-` for JavaScript Performance (Section 7) + - `advanced-` for Advanced Patterns (Section 8) +3. Fill in the frontmatter and content +4. Ensure you have clear examples with explanations +5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json + +## Rule File Structure + +Each rule file should follow this structure: + +```markdown +--- +title: Rule Title Here +impact: MEDIUM +impactDescription: Optional description +tags: tag1, tag2, tag3 +--- + +## Rule Title Here + +Brief explanation of the rule and why it matters. + +**Incorrect (description of what's wrong):** + +```typescript +// Bad code example +``` + +**Correct (description of what's right):** + +```typescript +// Good code example +``` + +Optional explanatory text after examples. + +Reference: [Link](https://example.com) + +## File Naming Convention + +- Files starting with `_` are special (excluded from build) +- Rule files: `area-description.md` (e.g., `async-parallel.md`) +- Section is automatically inferred from filename prefix +- Rules are sorted alphabetically by title within each section +- IDs (e.g., 1.1, 1.2) are auto-generated during build + +## Impact Levels + +- `CRITICAL` - Highest priority, major performance gains +- `HIGH` - Significant performance improvements +- `MEDIUM-HIGH` - Moderate-high gains +- `MEDIUM` - Moderate performance improvements +- `LOW-MEDIUM` - Low-medium gains +- `LOW` - Incremental improvements + +## Scripts + +- `pnpm build` - Compile rules into AGENTS.md +- `pnpm validate` - Validate all rule files +- `pnpm extract-tests` - Extract test cases for LLM evaluation +- `pnpm dev` - Build and validate + +## Contributing + +When adding or modifying rules: + +1. Use the correct filename prefix for your section +2. Follow the `_template.md` structure +3. Include clear bad/good examples with explanations +4. Add appropriate tags +5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json +6. Rules are automatically sorted by title - no need to manage numbers! + +## Acknowledgments + +Originally created by [@shuding](https://x.com/shuding) at [Vercel](https://vercel.com). diff --git a/.claude/skills/vercel-react-best-practices/SKILL.md b/.claude/skills/vercel-react-best-practices/SKILL.md new file mode 100644 index 0000000..eaa162d --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/SKILL.md @@ -0,0 +1,149 @@ +--- +name: vercel-react-best-practices +description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements. +license: MIT +metadata: + author: vercel + version: "1.0.0" +--- + +# Vercel React Best Practices + +Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 70 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation. + +## When to Apply + +Reference these guidelines when: +- Writing new React components or Next.js pages +- Implementing data fetching (client or server-side) +- Reviewing code for performance issues +- Refactoring existing React/Next.js code +- Optimizing bundle size or load times + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | Eliminating Waterfalls | CRITICAL | `async-` | +| 2 | Bundle Size Optimization | CRITICAL | `bundle-` | +| 3 | Server-Side Performance | HIGH | `server-` | +| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` | +| 5 | Re-render Optimization | MEDIUM | `rerender-` | +| 6 | Rendering Performance | MEDIUM | `rendering-` | +| 7 | JavaScript Performance | LOW-MEDIUM | `js-` | +| 8 | Advanced Patterns | LOW | `advanced-` | + +## Quick Reference + +### 1. Eliminating Waterfalls (CRITICAL) + +- `async-cheap-condition-before-await` - Check cheap sync conditions before awaiting flags or remote values +- `async-defer-await` - Move await into branches where actually used +- `async-parallel` - Use Promise.all() for independent operations +- `async-dependencies` - Use better-all for partial dependencies +- `async-api-routes` - Start promises early, await late in API routes +- `async-suspense-boundaries` - Use Suspense to stream content + +### 2. Bundle Size Optimization (CRITICAL) + +- `bundle-barrel-imports` - Import directly, avoid barrel files +- `bundle-analyzable-paths` - Prefer statically analyzable import and file-system paths to avoid broad bundles and traces +- `bundle-dynamic-imports` - Use next/dynamic for heavy components +- `bundle-defer-third-party` - Load analytics/logging after hydration +- `bundle-conditional` - Load modules only when feature is activated +- `bundle-preload` - Preload on hover/focus for perceived speed + +### 3. Server-Side Performance (HIGH) + +- `server-auth-actions` - Authenticate server actions like API routes +- `server-cache-react` - Use React.cache() for per-request deduplication +- `server-cache-lru` - Use LRU cache for cross-request caching +- `server-dedup-props` - Avoid duplicate serialization in RSC props +- `server-hoist-static-io` - Hoist static I/O (fonts, logos) to module level +- `server-no-shared-module-state` - Avoid module-level mutable request state in RSC/SSR +- `server-serialization` - Minimize data passed to client components +- `server-parallel-fetching` - Restructure components to parallelize fetches +- `server-parallel-nested-fetching` - Chain nested fetches per item in Promise.all +- `server-after-nonblocking` - Use after() for non-blocking operations + +### 4. Client-Side Data Fetching (MEDIUM-HIGH) + +- `client-swr-dedup` - Use SWR for automatic request deduplication +- `client-event-listeners` - Deduplicate global event listeners +- `client-passive-event-listeners` - Use passive listeners for scroll +- `client-localstorage-schema` - Version and minimize localStorage data + +### 5. Re-render Optimization (MEDIUM) + +- `rerender-defer-reads` - Don't subscribe to state only used in callbacks +- `rerender-memo` - Extract expensive work into memoized components +- `rerender-memo-with-default-value` - Hoist default non-primitive props +- `rerender-dependencies` - Use primitive dependencies in effects +- `rerender-derived-state` - Subscribe to derived booleans, not raw values +- `rerender-derived-state-no-effect` - Derive state during render, not effects +- `rerender-functional-setstate` - Use functional setState for stable callbacks +- `rerender-lazy-state-init` - Pass function to useState for expensive values +- `rerender-simple-expression-in-memo` - Avoid memo for simple primitives +- `rerender-split-combined-hooks` - Split hooks with independent dependencies +- `rerender-move-effect-to-event` - Put interaction logic in event handlers +- `rerender-transitions` - Use startTransition for non-urgent updates +- `rerender-use-deferred-value` - Defer expensive renders to keep input responsive +- `rerender-use-ref-transient-values` - Use refs for transient frequent values +- `rerender-no-inline-components` - Don't define components inside components + +### 6. Rendering Performance (MEDIUM) + +- `rendering-animate-svg-wrapper` - Animate div wrapper, not SVG element +- `rendering-content-visibility` - Use content-visibility for long lists +- `rendering-hoist-jsx` - Extract static JSX outside components +- `rendering-svg-precision` - Reduce SVG coordinate precision +- `rendering-hydration-no-flicker` - Use inline script for client-only data +- `rendering-hydration-suppress-warning` - Suppress expected mismatches +- `rendering-activity` - Use Activity component for show/hide +- `rendering-conditional-render` - Use ternary, not && for conditionals +- `rendering-usetransition-loading` - Prefer useTransition for loading state +- `rendering-resource-hints` - Use React DOM resource hints for preloading +- `rendering-script-defer-async` - Use defer or async on script tags + +### 7. JavaScript Performance (LOW-MEDIUM) + +- `js-batch-dom-css` - Group CSS changes via classes or cssText +- `js-index-maps` - Build Map for repeated lookups +- `js-cache-property-access` - Cache object properties in loops +- `js-cache-function-results` - Cache function results in module-level Map +- `js-cache-storage` - Cache localStorage/sessionStorage reads +- `js-combine-iterations` - Combine multiple filter/map into one loop +- `js-length-check-first` - Check array length before expensive comparison +- `js-early-exit` - Return early from functions +- `js-hoist-regexp` - Hoist RegExp creation outside loops +- `js-min-max-loop` - Use loop for min/max instead of sort +- `js-set-map-lookups` - Use Set/Map for O(1) lookups +- `js-tosorted-immutable` - Use toSorted() for immutability +- `js-flatmap-filter` - Use flatMap to map and filter in one pass +- `js-request-idle-callback` - Defer non-critical work to browser idle time + +### 8. Advanced Patterns (LOW) + +- `advanced-effect-event-deps` - Don't put `useEffectEvent` results in effect deps +- `advanced-event-handler-refs` - Store event handlers in refs +- `advanced-init-once` - Initialize app once per app load +- `advanced-use-latest` - useLatest for stable callback refs + +## How to Use + +Read individual rule files for detailed explanations and code examples: + +``` +rules/async-parallel.md +rules/bundle-barrel-imports.md +``` + +Each rule file contains: +- Brief explanation of why it matters +- Incorrect code example with explanation +- Correct code example with explanation +- Additional context and references + +## Full Compiled Document + +Read only the `rules/*.md` files the task needs. The upstream compiled `AGENTS.md` (every rule expanded, ~108 KB) is intentionally not vendored in this repository. diff --git a/.claude/skills/vercel-react-best-practices/metadata.json b/.claude/skills/vercel-react-best-practices/metadata.json new file mode 100644 index 0000000..3bec38b --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/metadata.json @@ -0,0 +1,15 @@ +{ + "version": "1.0.0", + "organization": "Vercel Engineering", + "date": "January 2026", + "abstract": "Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.", + "references": [ + "https://react.dev", + "https://nextjs.org", + "https://swr.vercel.app", + "https://github.com/shuding/better-all", + "https://github.com/isaacs/node-lru-cache", + "https://vercel.com/blog/how-we-optimized-package-imports-in-next-js", + "https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast" + ] +} diff --git a/.claude/skills/vercel-react-best-practices/rules/_sections.md b/.claude/skills/vercel-react-best-practices/rules/_sections.md new file mode 100644 index 0000000..4d20c14 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/_sections.md @@ -0,0 +1,46 @@ +# Sections + +This file defines all sections, their ordering, impact levels, and descriptions. +The section ID (in parentheses) is the filename prefix used to group rules. + +--- + +## 1. Eliminating Waterfalls (async) + +**Impact:** CRITICAL +**Description:** Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains. + +## 2. Bundle Size Optimization (bundle) + +**Impact:** CRITICAL +**Description:** Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint. + +## 3. Server-Side Performance (server) + +**Impact:** HIGH +**Description:** Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times. + +## 4. Client-Side Data Fetching (client) + +**Impact:** MEDIUM-HIGH +**Description:** Automatic deduplication and efficient data fetching patterns reduce redundant network requests. + +## 5. Re-render Optimization (rerender) + +**Impact:** MEDIUM +**Description:** Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness. + +## 6. Rendering Performance (rendering) + +**Impact:** MEDIUM +**Description:** Optimizing the rendering process reduces the work the browser needs to do. + +## 7. JavaScript Performance (js) + +**Impact:** LOW-MEDIUM +**Description:** Micro-optimizations for hot paths can add up to meaningful improvements. + +## 8. Advanced Patterns (advanced) + +**Impact:** LOW +**Description:** Advanced patterns for specific cases that require careful implementation. diff --git a/.claude/skills/vercel-react-best-practices/rules/_template.md b/.claude/skills/vercel-react-best-practices/rules/_template.md new file mode 100644 index 0000000..1e9e707 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/_template.md @@ -0,0 +1,28 @@ +--- +title: Rule Title Here +impact: MEDIUM +impactDescription: Optional description of impact (e.g., "20-50% improvement") +tags: tag1, tag2 +--- + +## Rule Title Here + +**Impact: MEDIUM (optional impact description)** + +Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications. + +**Incorrect (description of what's wrong):** + +```typescript +// Bad code example here +const bad = example() +``` + +**Correct (description of what's right):** + +```typescript +// Good code example here +const good = example() +``` + +Reference: [Link to documentation or resource](https://example.com) diff --git a/.claude/skills/vercel-react-best-practices/rules/advanced-effect-event-deps.md b/.claude/skills/vercel-react-best-practices/rules/advanced-effect-event-deps.md new file mode 100644 index 0000000..24d901b --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/advanced-effect-event-deps.md @@ -0,0 +1,56 @@ +--- +title: Do Not Put Effect Events in Dependency Arrays +impact: LOW +impactDescription: avoids unnecessary effect re-runs and lint errors +tags: advanced, hooks, useEffectEvent, dependencies, effects +--- + +## Do Not Put Effect Events in Dependency Arrays + +Effect Event functions do not have a stable identity. Their identity intentionally changes on every render. Do not include the function returned by `useEffectEvent` in a `useEffect` dependency array. Keep the actual reactive values as dependencies and call the Effect Event from inside the effect body or subscriptions created by that effect. + +**Incorrect (Effect Event added as a dependency):** + +```tsx +import { useEffect, useEffectEvent } from 'react' + +function ChatRoom({ roomId, onConnected }: { + roomId: string + onConnected: () => void +}) { + const handleConnected = useEffectEvent(onConnected) + + useEffect(() => { + const connection = createConnection(roomId) + connection.on('connected', handleConnected) + connection.connect() + + return () => connection.disconnect() + }, [roomId, handleConnected]) +} +``` + +Including the Effect Event in dependencies makes the effect re-run every render and triggers the React Hooks lint rule. + +**Correct (depend on reactive values, not the Effect Event):** + +```tsx +import { useEffect, useEffectEvent } from 'react' + +function ChatRoom({ roomId, onConnected }: { + roomId: string + onConnected: () => void +}) { + const handleConnected = useEffectEvent(onConnected) + + useEffect(() => { + const connection = createConnection(roomId) + connection.on('connected', handleConnected) + connection.connect() + + return () => connection.disconnect() + }, [roomId]) +} +``` + +Reference: [React useEffectEvent: Effect Event in deps](https://react.dev/reference/react/useEffectEvent#effect-event-in-deps) diff --git a/.claude/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md b/.claude/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md new file mode 100644 index 0000000..97e7ade --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md @@ -0,0 +1,55 @@ +--- +title: Store Event Handlers in Refs +impact: LOW +impactDescription: stable subscriptions +tags: advanced, hooks, refs, event-handlers, optimization +--- + +## Store Event Handlers in Refs + +Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes. + +**Incorrect (re-subscribes on every render):** + +```tsx +function useWindowEvent(event: string, handler: (e) => void) { + useEffect(() => { + window.addEventListener(event, handler) + return () => window.removeEventListener(event, handler) + }, [event, handler]) +} +``` + +**Correct (stable subscription):** + +```tsx +function useWindowEvent(event: string, handler: (e) => void) { + const handlerRef = useRef(handler) + useEffect(() => { + handlerRef.current = handler + }, [handler]) + + useEffect(() => { + const listener = (e) => handlerRef.current(e) + window.addEventListener(event, listener) + return () => window.removeEventListener(event, listener) + }, [event]) +} +``` + +**Alternative: use `useEffectEvent` if you're on latest React:** + +```tsx +import { useEffectEvent } from 'react' + +function useWindowEvent(event: string, handler: (e) => void) { + const onEvent = useEffectEvent(handler) + + useEffect(() => { + window.addEventListener(event, onEvent) + return () => window.removeEventListener(event, onEvent) + }, [event]) +} +``` + +`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler. diff --git a/.claude/skills/vercel-react-best-practices/rules/advanced-init-once.md b/.claude/skills/vercel-react-best-practices/rules/advanced-init-once.md new file mode 100644 index 0000000..73ee38e --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/advanced-init-once.md @@ -0,0 +1,42 @@ +--- +title: Initialize App Once, Not Per Mount +impact: LOW-MEDIUM +impactDescription: avoids duplicate init in development +tags: initialization, useEffect, app-startup, side-effects +--- + +## Initialize App Once, Not Per Mount + +Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead. + +**Incorrect (runs twice in dev, re-runs on remount):** + +```tsx +function Comp() { + useEffect(() => { + loadFromStorage() + checkAuthToken() + }, []) + + // ... +} +``` + +**Correct (once per app load):** + +```tsx +let didInit = false + +function Comp() { + useEffect(() => { + if (didInit) return + didInit = true + loadFromStorage() + checkAuthToken() + }, []) + + // ... +} +``` + +Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application) diff --git a/.claude/skills/vercel-react-best-practices/rules/advanced-use-latest.md b/.claude/skills/vercel-react-best-practices/rules/advanced-use-latest.md new file mode 100644 index 0000000..9c7cb50 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/advanced-use-latest.md @@ -0,0 +1,39 @@ +--- +title: useEffectEvent for Stable Callback Refs +impact: LOW +impactDescription: prevents effect re-runs +tags: advanced, hooks, useEffectEvent, refs, optimization +--- + +## useEffectEvent for Stable Callback Refs + +Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures. + +**Incorrect (effect re-runs on every callback change):** + +```tsx +function SearchInput({ onSearch }: { onSearch: (q: string) => void }) { + const [query, setQuery] = useState('') + + useEffect(() => { + const timeout = setTimeout(() => onSearch(query), 300) + return () => clearTimeout(timeout) + }, [query, onSearch]) +} +``` + +**Correct (using React's useEffectEvent):** + +```tsx +import { useEffectEvent } from 'react'; + +function SearchInput({ onSearch }: { onSearch: (q: string) => void }) { + const [query, setQuery] = useState('') + const onSearchEvent = useEffectEvent(onSearch) + + useEffect(() => { + const timeout = setTimeout(() => onSearchEvent(query), 300) + return () => clearTimeout(timeout) + }, [query]) +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/async-api-routes.md b/.claude/skills/vercel-react-best-practices/rules/async-api-routes.md new file mode 100644 index 0000000..6feda1e --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/async-api-routes.md @@ -0,0 +1,38 @@ +--- +title: Prevent Waterfall Chains in API Routes +impact: CRITICAL +impactDescription: 2-10× improvement +tags: api-routes, server-actions, waterfalls, parallelization +--- + +## Prevent Waterfall Chains in API Routes + +In API routes and Server Actions, start independent operations immediately, even if you don't await them yet. + +**Incorrect (config waits for auth, data waits for both):** + +```typescript +export async function GET(request: Request) { + const session = await auth() + const config = await fetchConfig() + const data = await fetchData(session.user.id) + return Response.json({ data, config }) +} +``` + +**Correct (auth and config start immediately):** + +```typescript +export async function GET(request: Request) { + const sessionPromise = auth() + const configPromise = fetchConfig() + const session = await sessionPromise + const [config, data] = await Promise.all([ + configPromise, + fetchData(session.user.id) + ]) + return Response.json({ data, config }) +} +``` + +For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization). diff --git a/.claude/skills/vercel-react-best-practices/rules/async-cheap-condition-before-await.md b/.claude/skills/vercel-react-best-practices/rules/async-cheap-condition-before-await.md new file mode 100644 index 0000000..5799d7f --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/async-cheap-condition-before-await.md @@ -0,0 +1,37 @@ +--- +title: Check Cheap Conditions Before Async Flags +impact: HIGH +impactDescription: avoids unnecessary async work when a synchronous guard already fails +tags: async, await, feature-flags, short-circuit, conditional +--- + +## Check Cheap Conditions Before Async Flags + +When a branch uses `await` for a flag or remote value and also requires a **cheap synchronous** condition (local props, request metadata, already-loaded state), evaluate the cheap condition **first**. Otherwise you pay for the async call even when the compound condition can never be true. + +This is a specialization of [Defer Await Until Needed](./async-defer-await.md) for `flag && cheapCondition` style checks. + +**Incorrect:** + +```typescript +const someFlag = await getFlag() + +if (someFlag && someCondition) { + // ... +} +``` + +**Correct:** + +```typescript +if (someCondition) { + const someFlag = await getFlag() + if (someFlag) { + // ... + } +} +``` + +This matters when `getFlag` hits the network, a feature-flag service, or `React.cache` / DB work: skipping it when `someCondition` is false removes that cost on the cold path. + +Keep the original order if `someCondition` is expensive, depends on the flag, or you must run side effects in a fixed order. diff --git a/.claude/skills/vercel-react-best-practices/rules/async-defer-await.md b/.claude/skills/vercel-react-best-practices/rules/async-defer-await.md new file mode 100644 index 0000000..460b5e1 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/async-defer-await.md @@ -0,0 +1,82 @@ +--- +title: Defer Await Until Needed +impact: HIGH +impactDescription: avoids blocking unused code paths +tags: async, await, conditional, optimization +--- + +## Defer Await Until Needed + +Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them. + +**Incorrect (blocks both branches):** + +```typescript +async function handleRequest(userId: string, skipProcessing: boolean) { + const userData = await fetchUserData(userId) + + if (skipProcessing) { + // Returns immediately but still waited for userData + return { skipped: true } + } + + // Only this branch uses userData + return processUserData(userData) +} +``` + +**Correct (only blocks when needed):** + +```typescript +async function handleRequest(userId: string, skipProcessing: boolean) { + if (skipProcessing) { + // Returns immediately without waiting + return { skipped: true } + } + + // Fetch only when needed + const userData = await fetchUserData(userId) + return processUserData(userData) +} +``` + +**Another example (early return optimization):** + +```typescript +// Incorrect: always fetches permissions +async function updateResource(resourceId: string, userId: string) { + const permissions = await fetchPermissions(userId) + const resource = await getResource(resourceId) + + if (!resource) { + return { error: 'Not found' } + } + + if (!permissions.canEdit) { + return { error: 'Forbidden' } + } + + return await updateResourceData(resource, permissions) +} + +// Correct: fetches only when needed +async function updateResource(resourceId: string, userId: string) { + const resource = await getResource(resourceId) + + if (!resource) { + return { error: 'Not found' } + } + + const permissions = await fetchPermissions(userId) + + if (!permissions.canEdit) { + return { error: 'Forbidden' } + } + + return await updateResourceData(resource, permissions) +} +``` + +This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive. + +For `await getFlag()` combined with a cheap synchronous guard (`flag && someCondition`), see [Check Cheap Conditions Before Async Flags](./async-cheap-condition-before-await.md). diff --git a/.claude/skills/vercel-react-best-practices/rules/async-dependencies.md b/.claude/skills/vercel-react-best-practices/rules/async-dependencies.md new file mode 100644 index 0000000..0484eba --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/async-dependencies.md @@ -0,0 +1,51 @@ +--- +title: Dependency-Based Parallelization +impact: CRITICAL +impactDescription: 2-10× improvement +tags: async, parallelization, dependencies, better-all +--- + +## Dependency-Based Parallelization + +For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment. + +**Incorrect (profile waits for config unnecessarily):** + +```typescript +const [user, config] = await Promise.all([ + fetchUser(), + fetchConfig() +]) +const profile = await fetchProfile(user.id) +``` + +**Correct (config and profile run in parallel):** + +```typescript +import { all } from 'better-all' + +const { user, config, profile } = await all({ + async user() { return fetchUser() }, + async config() { return fetchConfig() }, + async profile() { + return fetchProfile((await this.$.user).id) + } +}) +``` + +**Alternative without extra dependencies:** + +We can also create all the promises first, and do `Promise.all()` at the end. + +```typescript +const userPromise = fetchUser() +const profilePromise = userPromise.then(user => fetchProfile(user.id)) + +const [user, config, profile] = await Promise.all([ + userPromise, + fetchConfig(), + profilePromise +]) +``` + +Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all) diff --git a/.claude/skills/vercel-react-best-practices/rules/async-parallel.md b/.claude/skills/vercel-react-best-practices/rules/async-parallel.md new file mode 100644 index 0000000..64133f6 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/async-parallel.md @@ -0,0 +1,28 @@ +--- +title: Promise.all() for Independent Operations +impact: CRITICAL +impactDescription: 2-10× improvement +tags: async, parallelization, promises, waterfalls +--- + +## Promise.all() for Independent Operations + +When async operations have no interdependencies, execute them concurrently using `Promise.all()`. + +**Incorrect (sequential execution, 3 round trips):** + +```typescript +const user = await fetchUser() +const posts = await fetchPosts() +const comments = await fetchComments() +``` + +**Correct (parallel execution, 1 round trip):** + +```typescript +const [user, posts, comments] = await Promise.all([ + fetchUser(), + fetchPosts(), + fetchComments() +]) +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md b/.claude/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md new file mode 100644 index 0000000..1fbc05b --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md @@ -0,0 +1,99 @@ +--- +title: Strategic Suspense Boundaries +impact: HIGH +impactDescription: faster initial paint +tags: async, suspense, streaming, layout-shift +--- + +## Strategic Suspense Boundaries + +Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads. + +**Incorrect (wrapper blocked by data fetching):** + +```tsx +async function Page() { + const data = await fetchData() // Blocks entire page + + return ( + <div> + <div>Sidebar</div> + <div>Header</div> + <div> + <DataDisplay data={data} /> + </div> + <div>Footer</div> + </div> + ) +} +``` + +The entire layout waits for data even though only the middle section needs it. + +**Correct (wrapper shows immediately, data streams in):** + +```tsx +function Page() { + return ( + <div> + <div>Sidebar</div> + <div>Header</div> + <div> + <Suspense fallback={<Skeleton />}> + <DataDisplay /> + </Suspense> + </div> + <div>Footer</div> + </div> + ) +} + +async function DataDisplay() { + const data = await fetchData() // Only blocks this component + return <div>{data.content}</div> +} +``` + +Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data. + +**Alternative (share promise across components):** + +```tsx +function Page() { + // Start fetch immediately, but don't await + const dataPromise = fetchData() + + return ( + <div> + <div>Sidebar</div> + <div>Header</div> + <Suspense fallback={<Skeleton />}> + <DataDisplay dataPromise={dataPromise} /> + <DataSummary dataPromise={dataPromise} /> + </Suspense> + <div>Footer</div> + </div> + ) +} + +function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) { + const data = use(dataPromise) // Unwraps the promise + return <div>{data.content}</div> +} + +function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) { + const data = use(dataPromise) // Reuses the same promise + return <div>{data.summary}</div> +} +``` + +Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together. + +**When NOT to use this pattern:** + +- Critical data needed for layout decisions (affects positioning) +- SEO-critical content above the fold +- Small, fast queries where suspense overhead isn't worth it +- When you want to avoid layout shift (loading → content jump) + +**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities. diff --git a/.claude/skills/vercel-react-best-practices/rules/bundle-analyzable-paths.md b/.claude/skills/vercel-react-best-practices/rules/bundle-analyzable-paths.md new file mode 100644 index 0000000..8276f15 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/bundle-analyzable-paths.md @@ -0,0 +1,63 @@ +--- +title: Prefer Statically Analyzable Paths +impact: HIGH +impactDescription: avoids accidental broad bundles and file traces +tags: bundle, nextjs, vite, webpack, rollup, esbuild, path +--- + +## Prefer Statically Analyzable Paths + +Build tools work best when import and file-system paths are obvious at build time. If you hide the real path inside a variable or compose it too dynamically, the tool either has to include a broad set of possible files, warn that it cannot analyze the import, or widen file tracing to stay safe. + +Prefer explicit maps or literal paths so the set of reachable files stays narrow and predictable. This is the same rule whether you are choosing modules with `import()` or reading files in server/build code. + +When analysis becomes too broad, the cost is real: +- Larger server bundles +- Slower builds +- Worse cold starts +- More memory use + +### Import Paths + +**Incorrect (the bundler cannot tell what may be imported):** + +```ts +const PAGE_MODULES = { + home: './pages/home', + settings: './pages/settings', +} as const + +const Page = await import(PAGE_MODULES[pageName]) +``` + +**Correct (use an explicit map of allowed modules):** + +```ts +const PAGE_MODULES = { + home: () => import('./pages/home'), + settings: () => import('./pages/settings'), +} as const + +const Page = await PAGE_MODULES[pageName]() +``` + +### File-System Paths + +**Incorrect (a 2-value enum still hides the final path from static analysis):** + +```ts +const baseDir = path.join(process.cwd(), 'content/' + contentKind) +``` + +**Correct (make each final path literal at the callsite):** + +```ts +const baseDir = + kind === ContentKind.Blog + ? path.join(process.cwd(), 'content/blog') + : path.join(process.cwd(), 'content/docs') +``` + +In Next.js server code, this matters for output file tracing too. `path.join(process.cwd(), someVar)` can widen the traced file set because Next.js statically analyze `import`, `require`, and `fs` usage. + +Reference: [Next.js output](https://nextjs.org/docs/app/api-reference/config/next-config-js/output), [Next.js dynamic imports](https://nextjs.org/learn/seo/dynamic-imports), [Vite features](https://vite.dev/guide/features.html), [esbuild API](https://esbuild.github.io/api/), [Rollup dynamic import vars](https://www.npmjs.com/package/@rollup/plugin-dynamic-import-vars), [Webpack dependency management](https://webpack.js.org/guides/dependency-management/) diff --git a/.claude/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md b/.claude/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md new file mode 100644 index 0000000..22d1050 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md @@ -0,0 +1,60 @@ +--- +title: Avoid Barrel File Imports +impact: CRITICAL +impactDescription: 200-800ms import cost, slow builds +tags: bundle, imports, tree-shaking, barrel-files, performance +--- + +## Avoid Barrel File Imports + +Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`). + +Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts. + +**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph. + +**Incorrect (imports entire library):** + +```tsx +import { Check, X, Menu } from 'lucide-react' +// Loads 1,583 modules, takes ~2.8s extra in dev +// Runtime cost: 200-800ms on every cold start + +import { Button, TextField } from '@mui/material' +// Loads 2,225 modules, takes ~4.2s extra in dev +``` + +**Correct - Next.js 13.5+ (recommended):** + +```js +// next.config.js - automatically optimizes barrel imports at build time +module.exports = { + experimental: { + optimizePackageImports: ['lucide-react', '@mui/material'] + } +} +``` + +```tsx +// Keep the standard imports - Next.js transforms them to direct imports +import { Check, X, Menu } from 'lucide-react' +// Full TypeScript support, no manual path wrangling +``` + +This is the recommended approach because it preserves TypeScript type safety and editor autocompletion while still eliminating the barrel import cost. + +**Correct - Direct imports (non-Next.js projects):** + +```tsx +import Button from '@mui/material/Button' +import TextField from '@mui/material/TextField' +// Loads only what you use +``` + +> **TypeScript warning:** Some libraries (notably `lucide-react`) don't ship `.d.ts` files for their deep import paths. Importing from `lucide-react/dist/esm/icons/check` resolves to an implicit `any` type, causing errors under `strict` or `noImplicitAny`. Prefer `optimizePackageImports` when available, or verify the library exports types for its subpaths before using direct imports. + +These optimizations provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR. + +Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`. + +Reference: [How we optimized package imports in Next.js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js) diff --git a/.claude/skills/vercel-react-best-practices/rules/bundle-conditional.md b/.claude/skills/vercel-react-best-practices/rules/bundle-conditional.md new file mode 100644 index 0000000..99d6fc9 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/bundle-conditional.md @@ -0,0 +1,31 @@ +--- +title: Conditional Module Loading +impact: HIGH +impactDescription: loads large data only when needed +tags: bundle, conditional-loading, lazy-loading +--- + +## Conditional Module Loading + +Load large data or modules only when a feature is activated. + +**Example (lazy-load animation frames):** + +```tsx +function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) { + const [frames, setFrames] = useState<Frame[] | null>(null) + + useEffect(() => { + if (enabled && !frames && typeof window !== 'undefined') { + import('./animation-frames.js') + .then(mod => setFrames(mod.frames)) + .catch(() => setEnabled(false)) + } + }, [enabled, frames, setEnabled]) + + if (!frames) return <Skeleton /> + return <Canvas frames={frames} /> +} +``` + +The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed. diff --git a/.claude/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md b/.claude/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md new file mode 100644 index 0000000..db041d1 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md @@ -0,0 +1,49 @@ +--- +title: Defer Non-Critical Third-Party Libraries +impact: MEDIUM +impactDescription: loads after hydration +tags: bundle, third-party, analytics, defer +--- + +## Defer Non-Critical Third-Party Libraries + +Analytics, logging, and error tracking don't block user interaction. Load them after hydration. + +**Incorrect (blocks initial bundle):** + +```tsx +import { Analytics } from '@vercel/analytics/react' + +export default function RootLayout({ children }) { + return ( + <html> + <body> + {children} + <Analytics /> + </body> + </html> + ) +} +``` + +**Correct (loads after hydration):** + +```tsx +import dynamic from 'next/dynamic' + +const Analytics = dynamic( + () => import('@vercel/analytics/react').then(m => m.Analytics), + { ssr: false } +) + +export default function RootLayout({ children }) { + return ( + <html> + <body> + {children} + <Analytics /> + </body> + </html> + ) +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md b/.claude/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md new file mode 100644 index 0000000..60b6269 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md @@ -0,0 +1,35 @@ +--- +title: Dynamic Imports for Heavy Components +impact: CRITICAL +impactDescription: directly affects TTI and LCP +tags: bundle, dynamic-import, code-splitting, next-dynamic +--- + +## Dynamic Imports for Heavy Components + +Use `next/dynamic` to lazy-load large components not needed on initial render. + +**Incorrect (Monaco bundles with main chunk ~300KB):** + +```tsx +import { MonacoEditor } from './monaco-editor' + +function CodePanel({ code }: { code: string }) { + return <MonacoEditor value={code} /> +} +``` + +**Correct (Monaco loads on demand):** + +```tsx +import dynamic from 'next/dynamic' + +const MonacoEditor = dynamic( + () => import('./monaco-editor').then(m => m.MonacoEditor), + { ssr: false } +) + +function CodePanel({ code }: { code: string }) { + return <MonacoEditor value={code} /> +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/bundle-preload.md b/.claude/skills/vercel-react-best-practices/rules/bundle-preload.md new file mode 100644 index 0000000..7000504 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/bundle-preload.md @@ -0,0 +1,50 @@ +--- +title: Preload Based on User Intent +impact: MEDIUM +impactDescription: reduces perceived latency +tags: bundle, preload, user-intent, hover +--- + +## Preload Based on User Intent + +Preload heavy bundles before they're needed to reduce perceived latency. + +**Example (preload on hover/focus):** + +```tsx +function EditorButton({ onClick }: { onClick: () => void }) { + const preload = () => { + if (typeof window !== 'undefined') { + void import('./monaco-editor') + } + } + + return ( + <button + onMouseEnter={preload} + onFocus={preload} + onClick={onClick} + > + Open Editor + </button> + ) +} +``` + +**Example (preload when feature flag is enabled):** + +```tsx +function FlagsProvider({ children, flags }: Props) { + useEffect(() => { + if (flags.editorEnabled && typeof window !== 'undefined') { + void import('./monaco-editor').then(mod => mod.init()) + } + }, [flags.editorEnabled]) + + return <FlagsContext.Provider value={flags}> + {children} + </FlagsContext.Provider> +} +``` + +The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed. diff --git a/.claude/skills/vercel-react-best-practices/rules/client-event-listeners.md b/.claude/skills/vercel-react-best-practices/rules/client-event-listeners.md new file mode 100644 index 0000000..aad4ae9 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/client-event-listeners.md @@ -0,0 +1,74 @@ +--- +title: Deduplicate Global Event Listeners +impact: LOW +impactDescription: single listener for N components +tags: client, swr, event-listeners, subscription +--- + +## Deduplicate Global Event Listeners + +Use `useSWRSubscription()` to share global event listeners across component instances. + +**Incorrect (N instances = N listeners):** + +```tsx +function useKeyboardShortcut(key: string, callback: () => void) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && e.key === key) { + callback() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [key, callback]) +} +``` + +When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener. + +**Correct (N instances = 1 listener):** + +```tsx +import useSWRSubscription from 'swr/subscription' + +// Module-level Map to track callbacks per key +const keyCallbacks = new Map<string, Set<() => void>>() + +function useKeyboardShortcut(key: string, callback: () => void) { + // Register this callback in the Map + useEffect(() => { + if (!keyCallbacks.has(key)) { + keyCallbacks.set(key, new Set()) + } + keyCallbacks.get(key)!.add(callback) + + return () => { + const set = keyCallbacks.get(key) + if (set) { + set.delete(callback) + if (set.size === 0) { + keyCallbacks.delete(key) + } + } + } + }, [key, callback]) + + useSWRSubscription('global-keydown', () => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && keyCallbacks.has(e.key)) { + keyCallbacks.get(e.key)!.forEach(cb => cb()) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }) +} + +function Profile() { + // Multiple shortcuts will share the same listener + useKeyboardShortcut('p', () => { /* ... */ }) + useKeyboardShortcut('k', () => { /* ... */ }) + // ... +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/client-localstorage-schema.md b/.claude/skills/vercel-react-best-practices/rules/client-localstorage-schema.md new file mode 100644 index 0000000..d30a1a7 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/client-localstorage-schema.md @@ -0,0 +1,71 @@ +--- +title: Version and Minimize localStorage Data +impact: MEDIUM +impactDescription: prevents schema conflicts, reduces storage size +tags: client, localStorage, storage, versioning, data-minimization +--- + +## Version and Minimize localStorage Data + +Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data. + +**Incorrect:** + +```typescript +// No version, stores everything, no error handling +localStorage.setItem('userConfig', JSON.stringify(fullUserObject)) +const data = localStorage.getItem('userConfig') +``` + +**Correct:** + +```typescript +const VERSION = 'v2' + +function saveConfig(config: { theme: string; language: string }) { + try { + localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config)) + } catch { + // Throws in incognito/private browsing, quota exceeded, or disabled + } +} + +function loadConfig() { + try { + const data = localStorage.getItem(`userConfig:${VERSION}`) + return data ? JSON.parse(data) : null + } catch { + return null + } +} + +// Migration from v1 to v2 +function migrate() { + try { + const v1 = localStorage.getItem('userConfig:v1') + if (v1) { + const old = JSON.parse(v1) + saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang }) + localStorage.removeItem('userConfig:v1') + } + } catch {} +} +``` + +**Store minimal fields from server responses:** + +```typescript +// User object has 20+ fields, only store what UI needs +function cachePrefs(user: FullUser) { + try { + localStorage.setItem('prefs:v1', JSON.stringify({ + theme: user.preferences.theme, + notifications: user.preferences.notifications + })) + } catch {} +} +``` + +**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled. + +**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags. diff --git a/.claude/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md b/.claude/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md new file mode 100644 index 0000000..ce39a88 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md @@ -0,0 +1,48 @@ +--- +title: Use Passive Event Listeners for Scrolling Performance +impact: MEDIUM +impactDescription: eliminates scroll delay caused by event listeners +tags: client, event-listeners, scrolling, performance, touch, wheel +--- + +## Use Passive Event Listeners for Scrolling Performance + +Add `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay. + +**Incorrect:** + +```typescript +useEffect(() => { + const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) + const handleWheel = (e: WheelEvent) => console.log(e.deltaY) + + document.addEventListener('touchstart', handleTouch) + document.addEventListener('wheel', handleWheel) + + return () => { + document.removeEventListener('touchstart', handleTouch) + document.removeEventListener('wheel', handleWheel) + } +}, []) +``` + +**Correct:** + +```typescript +useEffect(() => { + const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) + const handleWheel = (e: WheelEvent) => console.log(e.deltaY) + + document.addEventListener('touchstart', handleTouch, { passive: true }) + document.addEventListener('wheel', handleWheel, { passive: true }) + + return () => { + document.removeEventListener('touchstart', handleTouch) + document.removeEventListener('wheel', handleWheel) + } +}, []) +``` + +**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`. + +**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`. diff --git a/.claude/skills/vercel-react-best-practices/rules/client-swr-dedup.md b/.claude/skills/vercel-react-best-practices/rules/client-swr-dedup.md new file mode 100644 index 0000000..2a430f2 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/client-swr-dedup.md @@ -0,0 +1,56 @@ +--- +title: Use SWR for Automatic Deduplication +impact: MEDIUM-HIGH +impactDescription: automatic deduplication +tags: client, swr, deduplication, data-fetching +--- + +## Use SWR for Automatic Deduplication + +SWR enables request deduplication, caching, and revalidation across component instances. + +**Incorrect (no deduplication, each instance fetches):** + +```tsx +function UserList() { + const [users, setUsers] = useState([]) + useEffect(() => { + fetch('/api/users') + .then(r => r.json()) + .then(setUsers) + }, []) +} +``` + +**Correct (multiple instances share one request):** + +```tsx +import useSWR from 'swr' + +function UserList() { + const { data: users } = useSWR('/api/users', fetcher) +} +``` + +**For immutable data:** + +```tsx +import { useImmutableSWR } from '@/lib/swr' + +function StaticContent() { + const { data } = useImmutableSWR('/api/config', fetcher) +} +``` + +**For mutations:** + +```tsx +import { useSWRMutation } from 'swr/mutation' + +function UpdateButton() { + const { trigger } = useSWRMutation('/api/user', updateUser) + return <button onClick={() => trigger()}>Update</button> +} +``` + +Reference: [https://swr.vercel.app](https://swr.vercel.app) diff --git a/.claude/skills/vercel-react-best-practices/rules/js-batch-dom-css.md b/.claude/skills/vercel-react-best-practices/rules/js-batch-dom-css.md new file mode 100644 index 0000000..a62d84e --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-batch-dom-css.md @@ -0,0 +1,107 @@ +--- +title: Avoid Layout Thrashing +impact: MEDIUM +impactDescription: prevents forced synchronous layouts and reduces performance bottlenecks +tags: javascript, dom, css, performance, reflow, layout-thrashing +--- + +## Avoid Layout Thrashing + +Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow. + +**This is OK (browser batches style changes):** +```typescript +function updateElementStyles(element: HTMLElement) { + // Each line invalidates style, but browser batches the recalculation + element.style.width = '100px' + element.style.height = '200px' + element.style.backgroundColor = 'blue' + element.style.border = '1px solid black' +} +``` + +**Incorrect (interleaved reads and writes force reflows):** +```typescript +function layoutThrashing(element: HTMLElement) { + element.style.width = '100px' + const width = element.offsetWidth // Forces reflow + element.style.height = '200px' + const height = element.offsetHeight // Forces another reflow +} +``` + +**Correct (batch writes, then read once):** +```typescript +function updateElementStyles(element: HTMLElement) { + // Batch all writes together + element.style.width = '100px' + element.style.height = '200px' + element.style.backgroundColor = 'blue' + element.style.border = '1px solid black' + + // Read after all writes are done (single reflow) + const { width, height } = element.getBoundingClientRect() +} +``` + +**Correct (batch reads, then writes):** +```typescript +function avoidThrashing(element: HTMLElement) { + // Read phase - all layout queries first + const rect1 = element.getBoundingClientRect() + const offsetWidth = element.offsetWidth + const offsetHeight = element.offsetHeight + + // Write phase - all style changes after + element.style.width = '100px' + element.style.height = '200px' +} +``` + +**Better: use CSS classes** +```css +.highlighted-box { + width: 100px; + height: 200px; + background-color: blue; + border: 1px solid black; +} +``` +```typescript +function updateElementStyles(element: HTMLElement) { + element.classList.add('highlighted-box') + + const { width, height } = element.getBoundingClientRect() +} +``` + +**React example:** +```tsx +// Incorrect: interleaving style changes with layout queries +function Box({ isHighlighted }: { isHighlighted: boolean }) { + const ref = useRef<HTMLDivElement>(null) + + useEffect(() => { + if (ref.current && isHighlighted) { + ref.current.style.width = '100px' + const width = ref.current.offsetWidth // Forces layout + ref.current.style.height = '200px' + } + }, [isHighlighted]) + + return <div ref={ref}>Content</div> +} + +// Correct: toggle class +function Box({ isHighlighted }: { isHighlighted: boolean }) { + return ( + <div className={isHighlighted ? 'highlighted-box' : ''}> + Content + </div> + ) +} +``` + +Prefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain. + +See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations. diff --git a/.claude/skills/vercel-react-best-practices/rules/js-cache-function-results.md b/.claude/skills/vercel-react-best-practices/rules/js-cache-function-results.md new file mode 100644 index 0000000..180f8ac --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-cache-function-results.md @@ -0,0 +1,80 @@ +--- +title: Cache Repeated Function Calls +impact: MEDIUM +impactDescription: avoid redundant computation +tags: javascript, cache, memoization, performance +--- + +## Cache Repeated Function Calls + +Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render. + +**Incorrect (redundant computation):** + +```typescript +function ProjectList({ projects }: { projects: Project[] }) { + return ( + <div> + {projects.map(project => { + // slugify() called 100+ times for same project names + const slug = slugify(project.name) + + return <ProjectCard key={project.id} slug={slug} /> + })} + </div> + ) +} +``` + +**Correct (cached results):** + +```typescript +// Module-level cache +const slugifyCache = new Map<string, string>() + +function cachedSlugify(text: string): string { + if (slugifyCache.has(text)) { + return slugifyCache.get(text)! + } + const result = slugify(text) + slugifyCache.set(text, result) + return result +} + +function ProjectList({ projects }: { projects: Project[] }) { + return ( + <div> + {projects.map(project => { + // Computed only once per unique project name + const slug = cachedSlugify(project.name) + + return <ProjectCard key={project.id} slug={slug} /> + })} + </div> + ) +} +``` + +**Simpler pattern for single-value functions:** + +```typescript +let isLoggedInCache: boolean | null = null + +function isLoggedIn(): boolean { + if (isLoggedInCache !== null) { + return isLoggedInCache + } + + isLoggedInCache = document.cookie.includes('auth=') + return isLoggedInCache +} + +// Clear cache when auth changes +function onAuthChange() { + isLoggedInCache = null +} +``` + +Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components. + +Reference: [How we made the Vercel Dashboard twice as fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast) diff --git a/.claude/skills/vercel-react-best-practices/rules/js-cache-property-access.md b/.claude/skills/vercel-react-best-practices/rules/js-cache-property-access.md new file mode 100644 index 0000000..39eec90 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-cache-property-access.md @@ -0,0 +1,28 @@ +--- +title: Cache Property Access in Loops +impact: LOW-MEDIUM +impactDescription: reduces lookups +tags: javascript, loops, optimization, caching +--- + +## Cache Property Access in Loops + +Cache object property lookups in hot paths. + +**Incorrect (3 lookups × N iterations):** + +```typescript +for (let i = 0; i < arr.length; i++) { + process(obj.config.settings.value) +} +``` + +**Correct (1 lookup total):** + +```typescript +const value = obj.config.settings.value +const len = arr.length +for (let i = 0; i < len; i++) { + process(value) +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/js-cache-storage.md b/.claude/skills/vercel-react-best-practices/rules/js-cache-storage.md new file mode 100644 index 0000000..aa4a30c --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-cache-storage.md @@ -0,0 +1,70 @@ +--- +title: Cache Storage API Calls +impact: LOW-MEDIUM +impactDescription: reduces expensive I/O +tags: javascript, localStorage, storage, caching, performance +--- + +## Cache Storage API Calls + +`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory. + +**Incorrect (reads storage on every call):** + +```typescript +function getTheme() { + return localStorage.getItem('theme') ?? 'light' +} +// Called 10 times = 10 storage reads +``` + +**Correct (Map cache):** + +```typescript +const storageCache = new Map<string, string | null>() + +function getLocalStorage(key: string) { + if (!storageCache.has(key)) { + storageCache.set(key, localStorage.getItem(key)) + } + return storageCache.get(key) +} + +function setLocalStorage(key: string, value: string) { + localStorage.setItem(key, value) + storageCache.set(key, value) // keep cache in sync +} +``` + +Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components. + +**Cookie caching:** + +```typescript +let cookieCache: Record<string, string> | null = null + +function getCookie(name: string) { + if (!cookieCache) { + cookieCache = Object.fromEntries( + document.cookie.split('; ').map(c => c.split('=')) + ) + } + return cookieCache[name] +} +``` + +**Important (invalidate on external changes):** + +If storage can change externally (another tab, server-set cookies), invalidate cache: + +```typescript +window.addEventListener('storage', (e) => { + if (e.key) storageCache.delete(e.key) +}) + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + storageCache.clear() + } +}) +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/js-combine-iterations.md b/.claude/skills/vercel-react-best-practices/rules/js-combine-iterations.md new file mode 100644 index 0000000..044d017 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-combine-iterations.md @@ -0,0 +1,32 @@ +--- +title: Combine Multiple Array Iterations +impact: LOW-MEDIUM +impactDescription: reduces iterations +tags: javascript, arrays, loops, performance +--- + +## Combine Multiple Array Iterations + +Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop. + +**Incorrect (3 iterations):** + +```typescript +const admins = users.filter(u => u.isAdmin) +const testers = users.filter(u => u.isTester) +const inactive = users.filter(u => !u.isActive) +``` + +**Correct (1 iteration):** + +```typescript +const admins: User[] = [] +const testers: User[] = [] +const inactive: User[] = [] + +for (const user of users) { + if (user.isAdmin) admins.push(user) + if (user.isTester) testers.push(user) + if (!user.isActive) inactive.push(user) +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/js-early-exit.md b/.claude/skills/vercel-react-best-practices/rules/js-early-exit.md new file mode 100644 index 0000000..f46cb89 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-early-exit.md @@ -0,0 +1,50 @@ +--- +title: Early Return from Functions +impact: LOW-MEDIUM +impactDescription: avoids unnecessary computation +tags: javascript, functions, optimization, early-return +--- + +## Early Return from Functions + +Return early when result is determined to skip unnecessary processing. + +**Incorrect (processes all items even after finding answer):** + +```typescript +function validateUsers(users: User[]) { + let hasError = false + let errorMessage = '' + + for (const user of users) { + if (!user.email) { + hasError = true + errorMessage = 'Email required' + } + if (!user.name) { + hasError = true + errorMessage = 'Name required' + } + // Continues checking all users even after error found + } + + return hasError ? { valid: false, error: errorMessage } : { valid: true } +} +``` + +**Correct (returns immediately on first error):** + +```typescript +function validateUsers(users: User[]) { + for (const user of users) { + if (!user.email) { + return { valid: false, error: 'Email required' } + } + if (!user.name) { + return { valid: false, error: 'Name required' } + } + } + + return { valid: true } +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/js-flatmap-filter.md b/.claude/skills/vercel-react-best-practices/rules/js-flatmap-filter.md new file mode 100644 index 0000000..ee0edf0 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-flatmap-filter.md @@ -0,0 +1,60 @@ +--- +title: Use flatMap to Map and Filter in One Pass +impact: LOW-MEDIUM +impactDescription: eliminates intermediate array +tags: javascript, arrays, flatMap, filter, performance +--- + +## Use flatMap to Map and Filter in One Pass + +**Impact: LOW-MEDIUM (eliminates intermediate array)** + +Chaining `.map().filter(Boolean)` creates an intermediate array and iterates twice. Use `.flatMap()` to transform and filter in a single pass. + +**Incorrect (2 iterations, intermediate array):** + +```typescript +const userNames = users + .map(user => user.isActive ? user.name : null) + .filter(Boolean) +``` + +**Correct (1 iteration, no intermediate array):** + +```typescript +const userNames = users.flatMap(user => + user.isActive ? [user.name] : [] +) +``` + +**More examples:** + +```typescript +// Extract valid emails from responses +// Before +const emails = responses + .map(r => r.success ? r.data.email : null) + .filter(Boolean) + +// After +const emails = responses.flatMap(r => + r.success ? [r.data.email] : [] +) + +// Parse and filter valid numbers +// Before +const numbers = strings + .map(s => parseInt(s, 10)) + .filter(n => !isNaN(n)) + +// After +const numbers = strings.flatMap(s => { + const n = parseInt(s, 10) + return isNaN(n) ? [] : [n] +}) +``` + +**When to use:** +- Transforming items while filtering some out +- Conditional mapping where some inputs produce no output +- Parsing/validating where invalid inputs should be skipped diff --git a/.claude/skills/vercel-react-best-practices/rules/js-hoist-regexp.md b/.claude/skills/vercel-react-best-practices/rules/js-hoist-regexp.md new file mode 100644 index 0000000..dae3fef --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-hoist-regexp.md @@ -0,0 +1,45 @@ +--- +title: Hoist RegExp Creation +impact: LOW-MEDIUM +impactDescription: avoids recreation +tags: javascript, regexp, optimization, memoization +--- + +## Hoist RegExp Creation + +Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`. + +**Incorrect (new RegExp every render):** + +```tsx +function Highlighter({ text, query }: Props) { + const regex = new RegExp(`(${query})`, 'gi') + const parts = text.split(regex) + return <>{parts.map((part, i) => ...)}</> +} +``` + +**Correct (memoize or hoist):** + +```tsx +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function Highlighter({ text, query }: Props) { + const regex = useMemo( + () => new RegExp(`(${escapeRegex(query)})`, 'gi'), + [query] + ) + const parts = text.split(regex) + return <>{parts.map((part, i) => ...)}</> +} +``` + +**Warning (global regex has mutable state):** + +Global regex (`/g`) has mutable `lastIndex` state: + +```typescript +const regex = /foo/g +regex.test('foo') // true, lastIndex = 3 +regex.test('foo') // false, lastIndex = 0 +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/js-index-maps.md b/.claude/skills/vercel-react-best-practices/rules/js-index-maps.md new file mode 100644 index 0000000..9d357a0 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-index-maps.md @@ -0,0 +1,37 @@ +--- +title: Build Index Maps for Repeated Lookups +impact: LOW-MEDIUM +impactDescription: 1M ops to 2K ops +tags: javascript, map, indexing, optimization, performance +--- + +## Build Index Maps for Repeated Lookups + +Multiple `.find()` calls by the same key should use a Map. + +**Incorrect (O(n) per lookup):** + +```typescript +function processOrders(orders: Order[], users: User[]) { + return orders.map(order => ({ + ...order, + user: users.find(u => u.id === order.userId) + })) +} +``` + +**Correct (O(1) per lookup):** + +```typescript +function processOrders(orders: Order[], users: User[]) { + const userById = new Map(users.map(u => [u.id, u])) + + return orders.map(order => ({ + ...order, + user: userById.get(order.userId) + })) +} +``` + +Build map once (O(n)), then all lookups are O(1). +For 1000 orders × 1000 users: 1M ops → 2K ops. diff --git a/.claude/skills/vercel-react-best-practices/rules/js-length-check-first.md b/.claude/skills/vercel-react-best-practices/rules/js-length-check-first.md new file mode 100644 index 0000000..8b89573 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-length-check-first.md @@ -0,0 +1,49 @@ +--- +title: Early Length Check for Array Comparisons +impact: MEDIUM-HIGH +impactDescription: avoids expensive operations when lengths differ +tags: javascript, arrays, performance, optimization, comparison +--- + +## Early Length Check for Array Comparisons + +When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal. + +In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops). + +**Incorrect (always runs expensive comparison):** + +```typescript +function hasChanges(current: string[], original: string[]) { + // Always sorts and joins, even when lengths differ + return current.sort().join() !== original.sort().join() +} +``` + +Two O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings. + +**Correct (O(1) length check first):** + +```typescript +function hasChanges(current: string[], original: string[]) { + // Early return if lengths differ + if (current.length !== original.length) { + return true + } + // Only sort when lengths match + const currentSorted = current.toSorted() + const originalSorted = original.toSorted() + for (let i = 0; i < currentSorted.length; i++) { + if (currentSorted[i] !== originalSorted[i]) { + return true + } + } + return false +} +``` + +This new approach is more efficient because: +- It avoids the overhead of sorting and joining the arrays when lengths differ +- It avoids consuming memory for the joined strings (especially important for large arrays) +- It avoids mutating the original arrays +- It returns early when a difference is found diff --git a/.claude/skills/vercel-react-best-practices/rules/js-min-max-loop.md b/.claude/skills/vercel-react-best-practices/rules/js-min-max-loop.md new file mode 100644 index 0000000..4b6656e --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-min-max-loop.md @@ -0,0 +1,82 @@ +--- +title: Use Loop for Min/Max Instead of Sort +impact: LOW +impactDescription: O(n) instead of O(n log n) +tags: javascript, arrays, performance, sorting, algorithms +--- + +## Use Loop for Min/Max Instead of Sort + +Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower. + +**Incorrect (O(n log n) - sort to find latest):** + +```typescript +interface Project { + id: string + name: string + updatedAt: number +} + +function getLatestProject(projects: Project[]) { + const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt) + return sorted[0] +} +``` + +Sorts the entire array just to find the maximum value. + +**Incorrect (O(n log n) - sort for oldest and newest):** + +```typescript +function getOldestAndNewest(projects: Project[]) { + const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt) + return { oldest: sorted[0], newest: sorted[sorted.length - 1] } +} +``` + +Still sorts unnecessarily when only min/max are needed. + +**Correct (O(n) - single loop):** + +```typescript +function getLatestProject(projects: Project[]) { + if (projects.length === 0) return null + + let latest = projects[0] + + for (let i = 1; i < projects.length; i++) { + if (projects[i].updatedAt > latest.updatedAt) { + latest = projects[i] + } + } + + return latest +} + +function getOldestAndNewest(projects: Project[]) { + if (projects.length === 0) return { oldest: null, newest: null } + + let oldest = projects[0] + let newest = projects[0] + + for (let i = 1; i < projects.length; i++) { + if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i] + if (projects[i].updatedAt > newest.updatedAt) newest = projects[i] + } + + return { oldest, newest } +} +``` + +Single pass through the array, no copying, no sorting. + +**Alternative (Math.min/Math.max for small arrays):** + +```typescript +const numbers = [5, 2, 8, 1, 9] +const min = Math.min(...numbers) +const max = Math.max(...numbers) +``` + +This works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability. diff --git a/.claude/skills/vercel-react-best-practices/rules/js-request-idle-callback.md b/.claude/skills/vercel-react-best-practices/rules/js-request-idle-callback.md new file mode 100644 index 0000000..d81927e --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-request-idle-callback.md @@ -0,0 +1,105 @@ +--- +title: Defer Non-Critical Work with requestIdleCallback +impact: MEDIUM +impactDescription: keeps UI responsive during background tasks +tags: javascript, performance, idle, scheduling, analytics +--- + +## Defer Non-Critical Work with requestIdleCallback + +**Impact: MEDIUM (keeps UI responsive during background tasks)** + +Use `requestIdleCallback()` to schedule non-critical work during browser idle periods. This keeps the main thread free for user interactions and animations, reducing jank and improving perceived performance. + +**Incorrect (blocks main thread during user interaction):** + +```typescript +function handleSearch(query: string) { + const results = searchItems(query) + setResults(results) + + // These block the main thread immediately + analytics.track('search', { query }) + saveToRecentSearches(query) + prefetchTopResults(results.slice(0, 3)) +} +``` + +**Correct (defers non-critical work to idle time):** + +```typescript +function handleSearch(query: string) { + const results = searchItems(query) + setResults(results) + + // Defer non-critical work to idle periods + requestIdleCallback(() => { + analytics.track('search', { query }) + }) + + requestIdleCallback(() => { + saveToRecentSearches(query) + }) + + requestIdleCallback(() => { + prefetchTopResults(results.slice(0, 3)) + }) +} +``` + +**With timeout for required work:** + +```typescript +// Ensure analytics fires within 2 seconds even if browser stays busy +requestIdleCallback( + () => analytics.track('page_view', { path: location.pathname }), + { timeout: 2000 } +) +``` + +**Chunking large tasks:** + +```typescript +function processLargeDataset(items: Item[]) { + let index = 0 + + function processChunk(deadline: IdleDeadline) { + // Process items while we have idle time (aim for <50ms chunks) + while (index < items.length && deadline.timeRemaining() > 0) { + processItem(items[index]) + index++ + } + + // Schedule next chunk if more items remain + if (index < items.length) { + requestIdleCallback(processChunk) + } + } + + requestIdleCallback(processChunk) +} +``` + +**With fallback for unsupported browsers:** + +```typescript +const scheduleIdleWork = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1)) + +scheduleIdleWork(() => { + // Non-critical work +}) +``` + +**When to use:** + +- Analytics and telemetry +- Saving state to localStorage/IndexedDB +- Prefetching resources for likely next actions +- Processing non-urgent data transformations +- Lazy initialization of non-critical features + +**When NOT to use:** + +- User-initiated actions that need immediate feedback +- Rendering updates the user is waiting for +- Time-sensitive operations diff --git a/.claude/skills/vercel-react-best-practices/rules/js-set-map-lookups.md b/.claude/skills/vercel-react-best-practices/rules/js-set-map-lookups.md new file mode 100644 index 0000000..680a489 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-set-map-lookups.md @@ -0,0 +1,24 @@ +--- +title: Use Set/Map for O(1) Lookups +impact: LOW-MEDIUM +impactDescription: O(n) to O(1) +tags: javascript, set, map, data-structures, performance +--- + +## Use Set/Map for O(1) Lookups + +Convert arrays to Set/Map for repeated membership checks. + +**Incorrect (O(n) per check):** + +```typescript +const allowedIds = ['a', 'b', 'c', ...] +items.filter(item => allowedIds.includes(item.id)) +``` + +**Correct (O(1) per check):** + +```typescript +const allowedIds = new Set(['a', 'b', 'c', ...]) +items.filter(item => allowedIds.has(item.id)) +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md b/.claude/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md new file mode 100644 index 0000000..eae8b3f --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md @@ -0,0 +1,57 @@ +--- +title: Use toSorted() Instead of sort() for Immutability +impact: MEDIUM-HIGH +impactDescription: prevents mutation bugs in React state +tags: javascript, arrays, immutability, react, state, mutation +--- + +## Use toSorted() Instead of sort() for Immutability + +`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation. + +**Incorrect (mutates original array):** + +```typescript +function UserList({ users }: { users: User[] }) { + // Mutates the users prop array! + const sorted = useMemo( + () => users.sort((a, b) => a.name.localeCompare(b.name)), + [users] + ) + return <div>{sorted.map(renderUser)}</div> +} +``` + +**Correct (creates new array):** + +```typescript +function UserList({ users }: { users: User[] }) { + // Creates new sorted array, original unchanged + const sorted = useMemo( + () => users.toSorted((a, b) => a.name.localeCompare(b.name)), + [users] + ) + return <div>{sorted.map(renderUser)}</div> +} +``` + +**Why this matters in React:** + +1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only +2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior + +**Browser support (fallback for older browsers):** + +`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator: + +```typescript +// Fallback for older browsers +const sorted = [...items].sort((a, b) => a.value - b.value) +``` + +**Other immutable array methods:** + +- `.toSorted()` - immutable sort +- `.toReversed()` - immutable reverse +- `.toSpliced()` - immutable splice +- `.with()` - immutable element replacement diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-activity.md b/.claude/skills/vercel-react-best-practices/rules/rendering-activity.md new file mode 100644 index 0000000..c957a49 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-activity.md @@ -0,0 +1,26 @@ +--- +title: Use Activity Component for Show/Hide +impact: MEDIUM +impactDescription: preserves state/DOM +tags: rendering, activity, visibility, state-preservation +--- + +## Use Activity Component for Show/Hide + +Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility. + +**Usage:** + +```tsx +import { Activity } from 'react' + +function Dropdown({ isOpen }: Props) { + return ( + <Activity mode={isOpen ? 'visible' : 'hidden'}> + <ExpensiveMenu /> + </Activity> + ) +} +``` + +Avoids expensive re-renders and state loss. diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md b/.claude/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md new file mode 100644 index 0000000..646744c --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md @@ -0,0 +1,47 @@ +--- +title: Animate SVG Wrapper Instead of SVG Element +impact: LOW +impactDescription: enables hardware acceleration +tags: rendering, svg, css, animation, performance +--- + +## Animate SVG Wrapper Instead of SVG Element + +Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead. + +**Incorrect (animating SVG directly - no hardware acceleration):** + +```tsx +function LoadingSpinner() { + return ( + <svg + className="animate-spin" + width="24" + height="24" + viewBox="0 0 24 24" + > + <circle cx="12" cy="12" r="10" stroke="currentColor" /> + </svg> + ) +} +``` + +**Correct (animating wrapper div - hardware accelerated):** + +```tsx +function LoadingSpinner() { + return ( + <div className="animate-spin"> + <svg + width="24" + height="24" + viewBox="0 0 24 24" + > + <circle cx="12" cy="12" r="10" stroke="currentColor" /> + </svg> + </div> + ) +} +``` + +This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations. diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-conditional-render.md b/.claude/skills/vercel-react-best-practices/rules/rendering-conditional-render.md new file mode 100644 index 0000000..7e866f5 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-conditional-render.md @@ -0,0 +1,40 @@ +--- +title: Use Explicit Conditional Rendering +impact: LOW +impactDescription: prevents rendering 0 or NaN +tags: rendering, conditional, jsx, falsy-values +--- + +## Use Explicit Conditional Rendering + +Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render. + +**Incorrect (renders "0" when count is 0):** + +```tsx +function Badge({ count }: { count: number }) { + return ( + <div> + {count && <span className="badge">{count}</span>} + </div> + ) +} + +// When count = 0, renders: <div>0</div> +// When count = 5, renders: <div><span class="badge">5</span></div> +``` + +**Correct (renders nothing when count is 0):** + +```tsx +function Badge({ count }: { count: number }) { + return ( + <div> + {count > 0 ? <span className="badge">{count}</span> : null} + </div> + ) +} + +// When count = 0, renders: <div></div> +// When count = 5, renders: <div><span class="badge">5</span></div> +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-content-visibility.md b/.claude/skills/vercel-react-best-practices/rules/rendering-content-visibility.md new file mode 100644 index 0000000..aa66563 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-content-visibility.md @@ -0,0 +1,38 @@ +--- +title: CSS content-visibility for Long Lists +impact: HIGH +impactDescription: faster initial render +tags: rendering, css, content-visibility, long-lists +--- + +## CSS content-visibility for Long Lists + +Apply `content-visibility: auto` to defer off-screen rendering. + +**CSS:** + +```css +.message-item { + content-visibility: auto; + contain-intrinsic-size: 0 80px; +} +``` + +**Example:** + +```tsx +function MessageList({ messages }: { messages: Message[] }) { + return ( + <div className="overflow-y-auto h-screen"> + {messages.map(msg => ( + <div key={msg.id} className="message-item"> + <Avatar user={msg.author} /> + <div>{msg.content}</div> + </div> + ))} + </div> + ) +} +``` + +For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render). diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md b/.claude/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md new file mode 100644 index 0000000..32d2f3f --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md @@ -0,0 +1,46 @@ +--- +title: Hoist Static JSX Elements +impact: LOW +impactDescription: avoids re-creation +tags: rendering, jsx, static, optimization +--- + +## Hoist Static JSX Elements + +Extract static JSX outside components to avoid re-creation. + +**Incorrect (recreates element every render):** + +```tsx +function LoadingSkeleton() { + return <div className="animate-pulse h-20 bg-gray-200" /> +} + +function Container() { + return ( + <div> + {loading && <LoadingSkeleton />} + </div> + ) +} +``` + +**Correct (reuses same element):** + +```tsx +const loadingSkeleton = ( + <div className="animate-pulse h-20 bg-gray-200" /> +) + +function Container() { + return ( + <div> + {loading && loadingSkeleton} + </div> + ) +} +``` + +This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render. + +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary. diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md b/.claude/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md new file mode 100644 index 0000000..5cf0e79 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md @@ -0,0 +1,82 @@ +--- +title: Prevent Hydration Mismatch Without Flickering +impact: MEDIUM +impactDescription: avoids visual flicker and hydration errors +tags: rendering, ssr, hydration, localStorage, flicker +--- + +## Prevent Hydration Mismatch Without Flickering + +When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates. + +**Incorrect (breaks SSR):** + +```tsx +function ThemeWrapper({ children }: { children: ReactNode }) { + // localStorage is not available on server - throws error + const theme = localStorage.getItem('theme') || 'light' + + return ( + <div className={theme}> + {children} + </div> + ) +} +``` + +Server-side rendering will fail because `localStorage` is undefined. + +**Incorrect (visual flickering):** + +```tsx +function ThemeWrapper({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState('light') + + useEffect(() => { + // Runs after hydration - causes visible flash + const stored = localStorage.getItem('theme') + if (stored) { + setTheme(stored) + } + }, []) + + return ( + <div className={theme}> + {children} + </div> + ) +} +``` + +Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content. + +**Correct (no flicker, no hydration mismatch):** + +```tsx +function ThemeWrapper({ children }: { children: ReactNode }) { + return ( + <> + <div id="theme-wrapper"> + {children} + </div> + <script + dangerouslySetInnerHTML={{ + __html: ` + (function() { + try { + var theme = localStorage.getItem('theme') || 'light'; + var el = document.getElementById('theme-wrapper'); + if (el) el.className = theme; + } catch (e) {} + })(); + `, + }} + /> + </> + ) +} +``` + +The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch. + +This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values. diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md b/.claude/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md new file mode 100644 index 0000000..24ba251 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md @@ -0,0 +1,30 @@ +--- +title: Suppress Expected Hydration Mismatches +impact: LOW-MEDIUM +impactDescription: avoids noisy hydration warnings for known differences +tags: rendering, hydration, ssr, nextjs +--- + +## Suppress Expected Hydration Mismatches + +In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it. + +**Incorrect (known mismatch warnings):** + +```tsx +function Timestamp() { + return <span>{new Date().toLocaleString()}</span> +} +``` + +**Correct (suppress expected mismatch only):** + +```tsx +function Timestamp() { + return ( + <span suppressHydrationWarning> + {new Date().toLocaleString()} + </span> + ) +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-resource-hints.md b/.claude/skills/vercel-react-best-practices/rules/rendering-resource-hints.md new file mode 100644 index 0000000..1290bef --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-resource-hints.md @@ -0,0 +1,85 @@ +--- +title: Use React DOM Resource Hints +impact: HIGH +impactDescription: reduces load time for critical resources +tags: rendering, preload, preconnect, prefetch, resource-hints +--- + +## Use React DOM Resource Hints + +**Impact: HIGH (reduces load time for critical resources)** + +React DOM provides APIs to hint the browser about resources it will need. These are especially useful in server components to start loading resources before the client even receives the HTML. + +- **`prefetchDNS(href)`**: Resolve DNS for a domain you expect to connect to +- **`preconnect(href)`**: Establish connection (DNS + TCP + TLS) to a server +- **`preload(href, options)`**: Fetch a resource (stylesheet, font, script, image) you'll use soon +- **`preloadModule(href)`**: Fetch an ES module you'll use soon +- **`preinit(href, options)`**: Fetch and evaluate a stylesheet or script +- **`preinitModule(href)`**: Fetch and evaluate an ES module + +**Example (preconnect to third-party APIs):** + +```tsx +import { preconnect, prefetchDNS } from 'react-dom' + +export default function App() { + prefetchDNS('https://analytics.example.com') + preconnect('https://api.example.com') + + return <main>{/* content */}</main> +} +``` + +**Example (preload critical fonts and styles):** + +```tsx +import { preload, preinit } from 'react-dom' + +export default function RootLayout({ children }) { + // Preload font file + preload('/fonts/inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' }) + + // Fetch and apply critical stylesheet immediately + preinit('/styles/critical.css', { as: 'style' }) + + return ( + <html> + <body>{children}</body> + </html> + ) +} +``` + +**Example (preload modules for code-split routes):** + +```tsx +import { preloadModule, preinitModule } from 'react-dom' + +function Navigation() { + const preloadDashboard = () => { + preloadModule('/dashboard.js', { as: 'script' }) + } + + return ( + <nav> + <a href="/dashboard" onMouseEnter={preloadDashboard}> + Dashboard + </a> + </nav> + ) +} +``` + +**When to use each:** + +| API | Use case | +|-----|----------| +| `prefetchDNS` | Third-party domains you'll connect to later | +| `preconnect` | APIs or CDNs you'll fetch from immediately | +| `preload` | Critical resources needed for current page | +| `preloadModule` | JS modules for likely next navigation | +| `preinit` | Stylesheets/scripts that must execute early | +| `preinitModule` | ES modules that must execute early | + +Reference: [React DOM Resource Preloading APIs](https://react.dev/reference/react-dom#resource-preloading-apis) diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-script-defer-async.md b/.claude/skills/vercel-react-best-practices/rules/rendering-script-defer-async.md new file mode 100644 index 0000000..ee275ed --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-script-defer-async.md @@ -0,0 +1,68 @@ +--- +title: Use defer or async on Script Tags +impact: HIGH +impactDescription: eliminates render-blocking +tags: rendering, script, defer, async, performance +--- + +## Use defer or async on Script Tags + +**Impact: HIGH (eliminates render-blocking)** + +Script tags without `defer` or `async` block HTML parsing while the script downloads and executes. This delays First Contentful Paint and Time to Interactive. + +- **`defer`**: Downloads in parallel, executes after HTML parsing completes, maintains execution order +- **`async`**: Downloads in parallel, executes immediately when ready, no guaranteed order + +Use `defer` for scripts that depend on DOM or other scripts. Use `async` for independent scripts like analytics. + +**Incorrect (blocks rendering):** + +```tsx +export default function Document() { + return ( + <html> + <head> + <script src="https://example.com/analytics.js" /> + <script src="/scripts/utils.js" /> + </head> + <body>{/* content */}</body> + </html> + ) +} +``` + +**Correct (non-blocking):** + +```tsx +export default function Document() { + return ( + <html> + <head> + {/* Independent script - use async */} + <script src="https://example.com/analytics.js" async /> + {/* DOM-dependent script - use defer */} + <script src="/scripts/utils.js" defer /> + </head> + <body>{/* content */}</body> + </html> + ) +} +``` + +**Note:** In Next.js, prefer the `next/script` component with `strategy` prop instead of raw script tags: + +```tsx +import Script from 'next/script' + +export default function Page() { + return ( + <> + <Script src="https://example.com/analytics.js" strategy="afterInteractive" /> + <Script src="/scripts/utils.js" strategy="beforeInteractive" /> + </> + ) +} +``` + +Reference: [MDN - Script element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer) diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-svg-precision.md b/.claude/skills/vercel-react-best-practices/rules/rendering-svg-precision.md new file mode 100644 index 0000000..6d77128 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-svg-precision.md @@ -0,0 +1,28 @@ +--- +title: Optimize SVG Precision +impact: LOW +impactDescription: reduces file size +tags: rendering, svg, optimization, svgo +--- + +## Optimize SVG Precision + +Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered. + +**Incorrect (excessive precision):** + +```svg +<path d="M 10.293847 20.847362 L 30.938472 40.192837" /> +``` + +**Correct (1 decimal place):** + +```svg +<path d="M 10.3 20.8 L 30.9 40.2" /> +``` + +**Automate with SVGO:** + +```bash +npx svgo --precision=1 --multipass icon.svg +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md b/.claude/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md new file mode 100644 index 0000000..0c1b0b9 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md @@ -0,0 +1,75 @@ +--- +title: Use useTransition Over Manual Loading States +impact: LOW +impactDescription: reduces re-renders and improves code clarity +tags: rendering, transitions, useTransition, loading, state +--- + +## Use useTransition Over Manual Loading States + +Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions. + +**Incorrect (manual loading state):** + +```tsx +function SearchResults() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + const handleSearch = async (value: string) => { + setIsLoading(true) + setQuery(value) + const data = await fetchResults(value) + setResults(data) + setIsLoading(false) + } + + return ( + <> + <input onChange={(e) => handleSearch(e.target.value)} /> + {isLoading && <Spinner />} + <ResultsList results={results} /> + </> + ) +} +``` + +**Correct (useTransition with built-in pending state):** + +```tsx +import { useTransition, useState } from 'react' + +function SearchResults() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isPending, startTransition] = useTransition() + + const handleSearch = (value: string) => { + setQuery(value) // Update input immediately + + startTransition(async () => { + // Fetch and update results + const data = await fetchResults(value) + setResults(data) + }) + } + + return ( + <> + <input onChange={(e) => handleSearch(e.target.value)} /> + {isPending && <Spinner />} + <ResultsList results={results} /> + </> + ) +} +``` + +**Benefits:** + +- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)` +- **Error resilience**: Pending state correctly resets even if the transition throws +- **Better responsiveness**: Keeps the UI responsive during updates +- **Interrupt handling**: New transitions automatically cancel pending ones + +Reference: [useTransition](https://react.dev/reference/react/useTransition) diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-defer-reads.md b/.claude/skills/vercel-react-best-practices/rules/rerender-defer-reads.md new file mode 100644 index 0000000..e867c95 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-defer-reads.md @@ -0,0 +1,39 @@ +--- +title: Defer State Reads to Usage Point +impact: MEDIUM +impactDescription: avoids unnecessary subscriptions +tags: rerender, searchParams, localStorage, optimization +--- + +## Defer State Reads to Usage Point + +Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks. + +**Incorrect (subscribes to all searchParams changes):** + +```tsx +function ShareButton({ chatId }: { chatId: string }) { + const searchParams = useSearchParams() + + const handleShare = () => { + const ref = searchParams.get('ref') + shareChat(chatId, { ref }) + } + + return <button onClick={handleShare}>Share</button> +} +``` + +**Correct (reads on demand, no subscription):** + +```tsx +function ShareButton({ chatId }: { chatId: string }) { + const handleShare = () => { + const params = new URLSearchParams(window.location.search) + const ref = params.get('ref') + shareChat(chatId, { ref }) + } + + return <button onClick={handleShare}>Share</button> +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-dependencies.md b/.claude/skills/vercel-react-best-practices/rules/rerender-dependencies.md new file mode 100644 index 0000000..47a4d92 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-dependencies.md @@ -0,0 +1,45 @@ +--- +title: Narrow Effect Dependencies +impact: LOW +impactDescription: minimizes effect re-runs +tags: rerender, useEffect, dependencies, optimization +--- + +## Narrow Effect Dependencies + +Specify primitive dependencies instead of objects to minimize effect re-runs. + +**Incorrect (re-runs on any user field change):** + +```tsx +useEffect(() => { + console.log(user.id) +}, [user]) +``` + +**Correct (re-runs only when id changes):** + +```tsx +useEffect(() => { + console.log(user.id) +}, [user.id]) +``` + +**For derived state, compute outside effect:** + +```tsx +// Incorrect: runs on width=767, 766, 765... +useEffect(() => { + if (width < 768) { + enableMobileMode() + } +}, [width]) + +// Correct: runs only on boolean transition +const isMobile = width < 768 +useEffect(() => { + if (isMobile) { + enableMobileMode() + } +}, [isMobile]) +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md b/.claude/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md new file mode 100644 index 0000000..3d9fe40 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md @@ -0,0 +1,40 @@ +--- +title: Calculate Derived State During Rendering +impact: MEDIUM +impactDescription: avoids redundant renders and state drift +tags: rerender, derived-state, useEffect, state +--- + +## Calculate Derived State During Rendering + +If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead. + +**Incorrect (redundant state and effect):** + +```tsx +function Form() { + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const [fullName, setFullName] = useState('') + + useEffect(() => { + setFullName(firstName + ' ' + lastName) + }, [firstName, lastName]) + + return <p>{fullName}</p> +} +``` + +**Correct (derive during render):** + +```tsx +function Form() { + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const fullName = firstName + ' ' + lastName + + return <p>{fullName}</p> +} +``` + +References: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-derived-state.md b/.claude/skills/vercel-react-best-practices/rules/rerender-derived-state.md new file mode 100644 index 0000000..e5c899f --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-derived-state.md @@ -0,0 +1,29 @@ +--- +title: Subscribe to Derived State +impact: MEDIUM +impactDescription: reduces re-render frequency +tags: rerender, derived-state, media-query, optimization +--- + +## Subscribe to Derived State + +Subscribe to derived boolean state instead of continuous values to reduce re-render frequency. + +**Incorrect (re-renders on every pixel change):** + +```tsx +function Sidebar() { + const width = useWindowWidth() // updates continuously + const isMobile = width < 768 + return <nav className={isMobile ? 'mobile' : 'desktop'} /> +} +``` + +**Correct (re-renders only when boolean changes):** + +```tsx +function Sidebar() { + const isMobile = useMediaQuery('(max-width: 767px)') + return <nav className={isMobile ? 'mobile' : 'desktop'} /> +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md b/.claude/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md new file mode 100644 index 0000000..b004ef4 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md @@ -0,0 +1,74 @@ +--- +title: Use Functional setState Updates +impact: MEDIUM +impactDescription: prevents stale closures and unnecessary callback recreations +tags: react, hooks, useState, useCallback, callbacks, closures +--- + +## Use Functional setState Updates + +When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references. + +**Incorrect (requires state as dependency):** + +```tsx +function TodoList() { + const [items, setItems] = useState(initialItems) + + // Callback must depend on items, recreated on every items change + const addItems = useCallback((newItems: Item[]) => { + setItems([...items, ...newItems]) + }, [items]) // ❌ items dependency causes recreations + + // Risk of stale closure if dependency is forgotten + const removeItem = useCallback((id: string) => { + setItems(items.filter(item => item.id !== id)) + }, []) // ❌ Missing items dependency - will use stale items! + + return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} /> +} +``` + +The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value. + +**Correct (stable callbacks, no stale closures):** + +```tsx +function TodoList() { + const [items, setItems] = useState(initialItems) + + // Stable callback, never recreated + const addItems = useCallback((newItems: Item[]) => { + setItems(curr => [...curr, ...newItems]) + }, []) // ✅ No dependencies needed + + // Always uses latest state, no stale closure risk + const removeItem = useCallback((id: string) => { + setItems(curr => curr.filter(item => item.id !== id)) + }, []) // ✅ Safe and stable + + return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} /> +} +``` + +**Benefits:** + +1. **Stable callback references** - Callbacks don't need to be recreated when state changes +2. **No stale closures** - Always operates on the latest state value +3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks +4. **Prevents bugs** - Eliminates the most common source of React closure bugs + +**When to use functional updates:** + +- Any setState that depends on the current state value +- Inside useCallback/useMemo when state is needed +- Event handlers that reference state +- Async operations that update state + +**When direct updates are fine:** + +- Setting state to a static value: `setCount(0)` +- Setting state from props/arguments only: `setName(newName)` +- State doesn't depend on previous value + +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs. diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md b/.claude/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md new file mode 100644 index 0000000..4ecb350 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md @@ -0,0 +1,58 @@ +--- +title: Use Lazy State Initialization +impact: MEDIUM +impactDescription: wasted computation on every render +tags: react, hooks, useState, performance, initialization +--- + +## Use Lazy State Initialization + +Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once. + +**Incorrect (runs on every render):** + +```tsx +function FilteredList({ items }: { items: Item[] }) { + // buildSearchIndex() runs on EVERY render, even after initialization + const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items)) + const [query, setQuery] = useState('') + + // When query changes, buildSearchIndex runs again unnecessarily + return <SearchResults index={searchIndex} query={query} /> +} + +function UserProfile() { + // JSON.parse runs on every render + const [settings, setSettings] = useState( + JSON.parse(localStorage.getItem('settings') || '{}') + ) + + return <SettingsForm settings={settings} onChange={setSettings} /> +} +``` + +**Correct (runs only once):** + +```tsx +function FilteredList({ items }: { items: Item[] }) { + // buildSearchIndex() runs ONLY on initial render + const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items)) + const [query, setQuery] = useState('') + + return <SearchResults index={searchIndex} query={query} /> +} + +function UserProfile() { + // JSON.parse runs only on initial render + const [settings, setSettings] = useState(() => { + const stored = localStorage.getItem('settings') + return stored ? JSON.parse(stored) : {} + }) + + return <SettingsForm settings={settings} onChange={setSettings} /> +} +``` + +Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations. + +For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary. diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md b/.claude/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md new file mode 100644 index 0000000..6357049 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md @@ -0,0 +1,38 @@ +--- + +title: Extract Default Non-primitive Parameter Value from Memoized Component to Constant +impact: MEDIUM +impactDescription: restores memoization by using a constant for default value +tags: rerender, memo, optimization + +--- + +## Extract Default Non-primitive Parameter Value from Memoized Component to Constant + +When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`. + +To address this issue, extract the default value into a constant. + +**Incorrect (`onClick` has different values on every rerender):** + +```tsx +const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) { + // ... +}) + +// Used without optional onClick +<UserAvatar /> +``` + +**Correct (stable default value):** + +```tsx +const NOOP = () => {}; + +const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) { + // ... +}) + +// Used without optional onClick +<UserAvatar /> +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-memo.md b/.claude/skills/vercel-react-best-practices/rules/rerender-memo.md new file mode 100644 index 0000000..f8982ab --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-memo.md @@ -0,0 +1,44 @@ +--- +title: Extract to Memoized Components +impact: MEDIUM +impactDescription: enables early returns +tags: rerender, memo, useMemo, optimization +--- + +## Extract to Memoized Components + +Extract expensive work into memoized components to enable early returns before computation. + +**Incorrect (computes avatar even when loading):** + +```tsx +function Profile({ user, loading }: Props) { + const avatar = useMemo(() => { + const id = computeAvatarId(user) + return <Avatar id={id} /> + }, [user]) + + if (loading) return <Skeleton /> + return <div>{avatar}</div> +} +``` + +**Correct (skips computation when loading):** + +```tsx +const UserAvatar = memo(function UserAvatar({ user }: { user: User }) { + const id = useMemo(() => computeAvatarId(user), [user]) + return <Avatar id={id} /> +}) + +function Profile({ user, loading }: Props) { + if (loading) return <Skeleton /> + return ( + <div> + <UserAvatar user={user} /> + </div> + ) +} +``` + +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders. diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md b/.claude/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md new file mode 100644 index 0000000..dd58a1a --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md @@ -0,0 +1,45 @@ +--- +title: Put Interaction Logic in Event Handlers +impact: MEDIUM +impactDescription: avoids effect re-runs and duplicate side effects +tags: rerender, useEffect, events, side-effects, dependencies +--- + +## Put Interaction Logic in Event Handlers + +If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action. + +**Incorrect (event modeled as state + effect):** + +```tsx +function Form() { + const [submitted, setSubmitted] = useState(false) + const theme = useContext(ThemeContext) + + useEffect(() => { + if (submitted) { + post('/api/register') + showToast('Registered', theme) + } + }, [submitted, theme]) + + return <button onClick={() => setSubmitted(true)}>Submit</button> +} +``` + +**Correct (do it in the handler):** + +```tsx +function Form() { + const theme = useContext(ThemeContext) + + function handleSubmit() { + post('/api/register') + showToast('Registered', theme) + } + + return <button onClick={handleSubmit}>Submit</button> +} +``` + +Reference: [Should this code move to an event handler?](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler) diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-no-inline-components.md b/.claude/skills/vercel-react-best-practices/rules/rerender-no-inline-components.md new file mode 100644 index 0000000..d97592a --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-no-inline-components.md @@ -0,0 +1,82 @@ +--- +title: Don't Define Components Inside Components +impact: HIGH +impactDescription: prevents remount on every render +tags: rerender, components, remount, performance +--- + +## Don't Define Components Inside Components + +**Impact: HIGH (prevents remount on every render)** + +Defining a component inside another component creates a new component type on every render. React sees a different component each time and fully remounts it, destroying all state and DOM. + +A common reason developers do this is to access parent variables without passing props. Always pass props instead. + +**Incorrect (remounts on every render):** + +```tsx +function UserProfile({ user, theme }) { + // Defined inside to access `theme` - BAD + const Avatar = () => ( + <img + src={user.avatarUrl} + className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'} + /> + ) + + // Defined inside to access `user` - BAD + const Stats = () => ( + <div> + <span>{user.followers} followers</span> + <span>{user.posts} posts</span> + </div> + ) + + return ( + <div> + <Avatar /> + <Stats /> + </div> + ) +} +``` + +Every time `UserProfile` renders, `Avatar` and `Stats` are new component types. React unmounts the old instances and mounts new ones, losing any internal state, running effects again, and recreating DOM nodes. + +**Correct (pass props instead):** + +```tsx +function Avatar({ src, theme }: { src: string; theme: string }) { + return ( + <img + src={src} + className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'} + /> + ) +} + +function Stats({ followers, posts }: { followers: number; posts: number }) { + return ( + <div> + <span>{followers} followers</span> + <span>{posts} posts</span> + </div> + ) +} + +function UserProfile({ user, theme }) { + return ( + <div> + <Avatar src={user.avatarUrl} theme={theme} /> + <Stats followers={user.followers} posts={user.posts} /> + </div> + ) +} +``` + +**Symptoms of this bug:** +- Input fields lose focus on every keystroke +- Animations restart unexpectedly +- `useEffect` cleanup/setup runs on every parent render +- Scroll position resets inside the component diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md b/.claude/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md new file mode 100644 index 0000000..59dfab0 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md @@ -0,0 +1,35 @@ +--- +title: Do not wrap a simple expression with a primitive result type in useMemo +impact: LOW-MEDIUM +impactDescription: wasted computation on every render +tags: rerender, useMemo, optimization +--- + +## Do not wrap a simple expression with a primitive result type in useMemo + +When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`. +Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself. + +**Incorrect:** + +```tsx +function Header({ user, notifications }: Props) { + const isLoading = useMemo(() => { + return user.isLoading || notifications.isLoading + }, [user.isLoading, notifications.isLoading]) + + if (isLoading) return <Skeleton /> + // return some markup +} +``` + +**Correct:** + +```tsx +function Header({ user, notifications }: Props) { + const isLoading = user.isLoading || notifications.isLoading + + if (isLoading) return <Skeleton /> + // return some markup +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-split-combined-hooks.md b/.claude/skills/vercel-react-best-practices/rules/rerender-split-combined-hooks.md new file mode 100644 index 0000000..89d8056 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-split-combined-hooks.md @@ -0,0 +1,64 @@ +--- +title: Split Combined Hook Computations +impact: MEDIUM +impactDescription: avoids recomputing independent steps +tags: rerender, useMemo, useEffect, dependencies, optimization +--- + +## Split Combined Hook Computations + +When a hook contains multiple independent tasks with different dependencies, split them into separate hooks. A combined hook reruns all tasks when any dependency changes, even if some tasks don't use the changed value. + +**Incorrect (changing `sortOrder` recomputes filtering):** + +```tsx +const sortedProducts = useMemo(() => { + const filtered = products.filter((p) => p.category === category) + const sorted = filtered.toSorted((a, b) => + sortOrder === "asc" ? a.price - b.price : b.price - a.price + ) + return sorted +}, [products, category, sortOrder]) +``` + +**Correct (filtering only recomputes when products or category change):** + +```tsx +const filteredProducts = useMemo( + () => products.filter((p) => p.category === category), + [products, category] +) + +const sortedProducts = useMemo( + () => + filteredProducts.toSorted((a, b) => + sortOrder === "asc" ? a.price - b.price : b.price - a.price + ), + [filteredProducts, sortOrder] +) +``` + +This pattern also applies to `useEffect` when combining unrelated side effects: + +**Incorrect (both effects run when either dependency changes):** + +```tsx +useEffect(() => { + analytics.trackPageView(pathname) + document.title = `${pageTitle} | My App` +}, [pathname, pageTitle]) +``` + +**Correct (effects run independently):** + +```tsx +useEffect(() => { + analytics.trackPageView(pathname) +}, [pathname]) + +useEffect(() => { + document.title = `${pageTitle} | My App` +}, [pageTitle]) +``` + +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, it automatically optimizes dependency tracking and may handle some of these cases for you. diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-transitions.md b/.claude/skills/vercel-react-best-practices/rules/rerender-transitions.md new file mode 100644 index 0000000..d99f43f --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-transitions.md @@ -0,0 +1,40 @@ +--- +title: Use Transitions for Non-Urgent Updates +impact: MEDIUM +impactDescription: maintains UI responsiveness +tags: rerender, transitions, startTransition, performance +--- + +## Use Transitions for Non-Urgent Updates + +Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness. + +**Incorrect (blocks UI on every scroll):** + +```tsx +function ScrollTracker() { + const [scrollY, setScrollY] = useState(0) + useEffect(() => { + const handler = () => setScrollY(window.scrollY) + window.addEventListener('scroll', handler, { passive: true }) + return () => window.removeEventListener('scroll', handler) + }, []) +} +``` + +**Correct (non-blocking updates):** + +```tsx +import { startTransition } from 'react' + +function ScrollTracker() { + const [scrollY, setScrollY] = useState(0) + useEffect(() => { + const handler = () => { + startTransition(() => setScrollY(window.scrollY)) + } + window.addEventListener('scroll', handler, { passive: true }) + return () => window.removeEventListener('scroll', handler) + }, []) +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-use-deferred-value.md b/.claude/skills/vercel-react-best-practices/rules/rerender-use-deferred-value.md new file mode 100644 index 0000000..619c04b --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-use-deferred-value.md @@ -0,0 +1,59 @@ +--- +title: Use useDeferredValue for Expensive Derived Renders +impact: MEDIUM +impactDescription: keeps input responsive during heavy computation +tags: rerender, useDeferredValue, optimization, concurrent +--- + +## Use useDeferredValue for Expensive Derived Renders + +When user input triggers expensive computations or renders, use `useDeferredValue` to keep the input responsive. The deferred value lags behind, allowing React to prioritize the input update and render the expensive result when idle. + +**Incorrect (input feels laggy while filtering):** + +```tsx +function Search({ items }: { items: Item[] }) { + const [query, setQuery] = useState('') + const filtered = items.filter(item => fuzzyMatch(item, query)) + + return ( + <> + <input value={query} onChange={e => setQuery(e.target.value)} /> + <ResultsList results={filtered} /> + </> + ) +} +``` + +**Correct (input stays snappy, results render when ready):** + +```tsx +function Search({ items }: { items: Item[] }) { + const [query, setQuery] = useState('') + const deferredQuery = useDeferredValue(query) + const filtered = useMemo( + () => items.filter(item => fuzzyMatch(item, deferredQuery)), + [items, deferredQuery] + ) + const isStale = query !== deferredQuery + + return ( + <> + <input value={query} onChange={e => setQuery(e.target.value)} /> + <div style={{ opacity: isStale ? 0.7 : 1 }}> + <ResultsList results={filtered} /> + </div> + </> + ) +} +``` + +**When to use:** + +- Filtering/searching large lists +- Expensive visualizations (charts, graphs) reacting to input +- Any derived state that causes noticeable render delays + +**Note:** Wrap the expensive computation in `useMemo` with the deferred value as a dependency, otherwise it still runs on every render. + +Reference: [React useDeferredValue](https://react.dev/reference/react/useDeferredValue) diff --git a/.claude/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md b/.claude/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md new file mode 100644 index 0000000..cf04b81 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md @@ -0,0 +1,73 @@ +--- +title: Use useRef for Transient Values +impact: MEDIUM +impactDescription: avoids unnecessary re-renders on frequent updates +tags: rerender, useref, state, performance +--- + +## Use useRef for Transient Values + +When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render. + +**Incorrect (renders every update):** + +```tsx +function Tracker() { + const [lastX, setLastX] = useState(0) + + useEffect(() => { + const onMove = (e: MouseEvent) => setLastX(e.clientX) + window.addEventListener('mousemove', onMove) + return () => window.removeEventListener('mousemove', onMove) + }, []) + + return ( + <div + style={{ + position: 'fixed', + top: 0, + left: lastX, + width: 8, + height: 8, + background: 'black', + }} + /> + ) +} +``` + +**Correct (no re-render for tracking):** + +```tsx +function Tracker() { + const lastXRef = useRef(0) + const dotRef = useRef<HTMLDivElement>(null) + + useEffect(() => { + const onMove = (e: MouseEvent) => { + lastXRef.current = e.clientX + const node = dotRef.current + if (node) { + node.style.transform = `translateX(${e.clientX}px)` + } + } + window.addEventListener('mousemove', onMove) + return () => window.removeEventListener('mousemove', onMove) + }, []) + + return ( + <div + ref={dotRef} + style={{ + position: 'fixed', + top: 0, + left: 0, + width: 8, + height: 8, + background: 'black', + transform: 'translateX(0px)', + }} + /> + ) +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/server-after-nonblocking.md b/.claude/skills/vercel-react-best-practices/rules/server-after-nonblocking.md new file mode 100644 index 0000000..e8f5b26 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/server-after-nonblocking.md @@ -0,0 +1,73 @@ +--- +title: Use after() for Non-Blocking Operations +impact: MEDIUM +impactDescription: faster response times +tags: server, async, logging, analytics, side-effects +--- + +## Use after() for Non-Blocking Operations + +Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response. + +**Incorrect (blocks response):** + +```tsx +import { logUserAction } from '@/app/utils' + +export async function POST(request: Request) { + // Perform mutation + await updateDatabase(request) + + // Logging blocks the response + const userAgent = request.headers.get('user-agent') || 'unknown' + await logUserAction({ userAgent }) + + return new Response(JSON.stringify({ status: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} +``` + +**Correct (non-blocking):** + +```tsx +import { after } from 'next/server' +import { headers, cookies } from 'next/headers' +import { logUserAction } from '@/app/utils' + +export async function POST(request: Request) { + // Perform mutation + await updateDatabase(request) + + // Log after response is sent + after(async () => { + const userAgent = (await headers()).get('user-agent') || 'unknown' + const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous' + + logUserAction({ sessionCookie, userAgent }) + }) + + return new Response(JSON.stringify({ status: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} +``` + +The response is sent immediately while logging happens in the background. + +**Common use cases:** + +- Analytics tracking +- Audit logging +- Sending notifications +- Cache invalidation +- Cleanup tasks + +**Important notes:** + +- `after()` runs even if the response fails or redirects +- Works in Server Actions, Route Handlers, and Server Components + +Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after) diff --git a/.claude/skills/vercel-react-best-practices/rules/server-auth-actions.md b/.claude/skills/vercel-react-best-practices/rules/server-auth-actions.md new file mode 100644 index 0000000..ee82c04 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/server-auth-actions.md @@ -0,0 +1,96 @@ +--- +title: Authenticate Server Actions Like API Routes +impact: CRITICAL +impactDescription: prevents unauthorized access to server mutations +tags: server, server-actions, authentication, security, authorization +--- + +## Authenticate Server Actions Like API Routes + +**Impact: CRITICAL (prevents unauthorized access to server mutations)** + +Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly. + +Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation." + +**Incorrect (no authentication check):** + +```typescript +'use server' + +export async function deleteUser(userId: string) { + // Anyone can call this! No auth check + await db.user.delete({ where: { id: userId } }) + return { success: true } +} +``` + +**Correct (authentication inside the action):** + +```typescript +'use server' + +import { verifySession } from '@/lib/auth' +import { unauthorized } from '@/lib/errors' + +export async function deleteUser(userId: string) { + // Always check auth inside the action + const session = await verifySession() + + if (!session) { + throw unauthorized('Must be logged in') + } + + // Check authorization too + if (session.user.role !== 'admin' && session.user.id !== userId) { + throw unauthorized('Cannot delete other users') + } + + await db.user.delete({ where: { id: userId } }) + return { success: true } +} +``` + +**With input validation:** + +```typescript +'use server' + +import { verifySession } from '@/lib/auth' +import { z } from 'zod' + +const updateProfileSchema = z.object({ + userId: z.string().uuid(), + name: z.string().min(1).max(100), + email: z.string().email() +}) + +export async function updateProfile(data: unknown) { + // Validate input first + const validated = updateProfileSchema.parse(data) + + // Then authenticate + const session = await verifySession() + if (!session) { + throw new Error('Unauthorized') + } + + // Then authorize + if (session.user.id !== validated.userId) { + throw new Error('Can only update own profile') + } + + // Finally perform the mutation + await db.user.update({ + where: { id: validated.userId }, + data: { + name: validated.name, + email: validated.email + } + }) + + return { success: true } +} +``` + +Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication) diff --git a/.claude/skills/vercel-react-best-practices/rules/server-cache-lru.md b/.claude/skills/vercel-react-best-practices/rules/server-cache-lru.md new file mode 100644 index 0000000..ef6938a --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/server-cache-lru.md @@ -0,0 +1,41 @@ +--- +title: Cross-Request LRU Caching +impact: HIGH +impactDescription: caches across requests +tags: server, cache, lru, cross-request +--- + +## Cross-Request LRU Caching + +`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache. + +**Implementation:** + +```typescript +import { LRUCache } from 'lru-cache' + +const cache = new LRUCache<string, any>({ + max: 1000, + ttl: 5 * 60 * 1000 // 5 minutes +}) + +export async function getUser(id: string) { + const cached = cache.get(id) + if (cached) return cached + + const user = await db.user.findUnique({ where: { id } }) + cache.set(id, user) + return user +} + +// Request 1: DB query, result cached +// Request 2: cache hit, no DB query +``` + +Use when sequential user actions hit multiple endpoints needing the same data within seconds. + +**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis. + +**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching. + +Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache) diff --git a/.claude/skills/vercel-react-best-practices/rules/server-cache-react.md b/.claude/skills/vercel-react-best-practices/rules/server-cache-react.md new file mode 100644 index 0000000..87c9ca3 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/server-cache-react.md @@ -0,0 +1,76 @@ +--- +title: Per-Request Deduplication with React.cache() +impact: MEDIUM +impactDescription: deduplicates within request +tags: server, cache, react-cache, deduplication +--- + +## Per-Request Deduplication with React.cache() + +Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most. + +**Usage:** + +```typescript +import { cache } from 'react' + +export const getCurrentUser = cache(async () => { + const session = await auth() + if (!session?.user?.id) return null + return await db.user.findUnique({ + where: { id: session.user.id } + }) +}) +``` + +Within a single request, multiple calls to `getCurrentUser()` execute the query only once. + +**Avoid inline objects as arguments:** + +`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits. + +**Incorrect (always cache miss):** + +```typescript +const getUser = cache(async (params: { uid: number }) => { + return await db.user.findUnique({ where: { id: params.uid } }) +}) + +// Each call creates new object, never hits cache +getUser({ uid: 1 }) +getUser({ uid: 1 }) // Cache miss, runs query again +``` + +**Correct (cache hit):** + +```typescript +const getUser = cache(async (uid: number) => { + return await db.user.findUnique({ where: { id: uid } }) +}) + +// Primitive args use value equality +getUser(1) +getUser(1) // Cache hit, returns cached result +``` + +If you must pass objects, pass the same reference: + +```typescript +const params = { uid: 1 } +getUser(params) // Query runs +getUser(params) // Cache hit (same reference) +``` + +**Next.js-Specific Note:** + +In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks: + +- Database queries (Prisma, Drizzle, etc.) +- Heavy computations +- Authentication checks +- File system operations +- Any non-fetch async work + +Use `React.cache()` to deduplicate these operations across your component tree. + +Reference: [React.cache documentation](https://react.dev/reference/react/cache) diff --git a/.claude/skills/vercel-react-best-practices/rules/server-dedup-props.md b/.claude/skills/vercel-react-best-practices/rules/server-dedup-props.md new file mode 100644 index 0000000..fb24a25 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/server-dedup-props.md @@ -0,0 +1,65 @@ +--- +title: Avoid Duplicate Serialization in RSC Props +impact: LOW +impactDescription: reduces network payload by avoiding duplicate serialization +tags: server, rsc, serialization, props, client-components +--- + +## Avoid Duplicate Serialization in RSC Props + +**Impact: LOW (reduces network payload by avoiding duplicate serialization)** + +RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server. + +**Incorrect (duplicates array):** + +```tsx +// RSC: sends 6 strings (2 arrays × 3 items) +<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} /> +``` + +**Correct (sends 3 strings):** + +```tsx +// RSC: send once +<ClientList usernames={usernames} /> + +// Client: transform there +'use client' +const sorted = useMemo(() => [...usernames].sort(), [usernames]) +``` + +**Nested deduplication behavior:** + +Deduplication works recursively. Impact varies by data type: + +- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated +- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference + +```tsx +// string[] - duplicates everything +usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings + +// object[] - duplicates array structure only +users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4) +``` + +**Operations breaking deduplication (create new references):** + +- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]` +- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())` + +**More examples:** + +```tsx +// ❌ Bad +<C users={users} active={users.filter(u => u.active)} /> +<C product={product} productName={product.name} /> + +// ✅ Good +<C users={users} /> +<C product={product} /> +// Do filtering/destructuring in client +``` + +**Exception:** Pass derived data when transformation is expensive or client doesn't need original. diff --git a/.claude/skills/vercel-react-best-practices/rules/server-hoist-static-io.md b/.claude/skills/vercel-react-best-practices/rules/server-hoist-static-io.md new file mode 100644 index 0000000..25205c2 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/server-hoist-static-io.md @@ -0,0 +1,149 @@ +--- +title: Hoist Static I/O to Module Level +impact: HIGH +impactDescription: avoids repeated file/network I/O per request +tags: server, io, performance, next.js, route-handlers, og-image +--- + +## Hoist Static I/O to Module Level + +**Impact: HIGH (avoids repeated file/network I/O per request)** + +When loading static assets (fonts, logos, images, config files) in route handlers or server functions, hoist the I/O operation to module level. Module-level code runs once when the module is first imported, not on every request. This eliminates redundant file system reads or network fetches that would otherwise run on every invocation. + +**Incorrect (reads font file on every request):** + +```typescript +// app/api/og/route.tsx +import { ImageResponse } from 'next/og' + +export async function GET(request: Request) { + // Runs on EVERY request - expensive! + const fontData = await fetch( + new URL('./fonts/Inter.ttf', import.meta.url) + ).then(res => res.arrayBuffer()) + + const logoData = await fetch( + new URL('./images/logo.png', import.meta.url) + ).then(res => res.arrayBuffer()) + + return new ImageResponse( + <div style={{ fontFamily: 'Inter' }}> + <img src={logoData} /> + Hello World + </div>, + { fonts: [{ name: 'Inter', data: fontData }] } + ) +} +``` + +**Correct (loads once at module initialization):** + +```typescript +// app/api/og/route.tsx +import { ImageResponse } from 'next/og' + +// Module-level: runs ONCE when module is first imported +const fontData = fetch( + new URL('./fonts/Inter.ttf', import.meta.url) +).then(res => res.arrayBuffer()) + +const logoData = fetch( + new URL('./images/logo.png', import.meta.url) +).then(res => res.arrayBuffer()) + +export async function GET(request: Request) { + // Await the already-started promises + const [font, logo] = await Promise.all([fontData, logoData]) + + return new ImageResponse( + <div style={{ fontFamily: 'Inter' }}> + <img src={logo} /> + Hello World + </div>, + { fonts: [{ name: 'Inter', data: font }] } + ) +} +``` + +**Correct (synchronous fs at module level):** + +```typescript +// app/api/og/route.tsx +import { ImageResponse } from 'next/og' +import { readFileSync } from 'fs' +import { join } from 'path' + +// Synchronous read at module level - blocks only during module init +const fontData = readFileSync( + join(process.cwd(), 'public/fonts/Inter.ttf') +) + +const logoData = readFileSync( + join(process.cwd(), 'public/images/logo.png') +) + +export async function GET(request: Request) { + return new ImageResponse( + <div style={{ fontFamily: 'Inter' }}> + <img src={logoData} /> + Hello World + </div>, + { fonts: [{ name: 'Inter', data: fontData }] } + ) +} +``` + +**Incorrect (reads config on every call):** + +```typescript +import fs from 'node:fs/promises' + +export async function processRequest(data: Data) { + const config = JSON.parse( + await fs.readFile('./config.json', 'utf-8') + ) + const template = await fs.readFile('./template.html', 'utf-8') + + return render(template, data, config) +} +``` + +**Correct (hoists config and template to module level):** + +```typescript +import fs from 'node:fs/promises' + +const configPromise = fs + .readFile('./config.json', 'utf-8') + .then(JSON.parse) +const templatePromise = fs.readFile('./template.html', 'utf-8') + +export async function processRequest(data: Data) { + const [config, template] = await Promise.all([ + configPromise, + templatePromise, + ]) + + return render(template, data, config) +} +``` + +When to use this pattern: + +- Loading fonts for OG image generation +- Loading static logos, icons, or watermarks +- Reading configuration files that don't change at runtime +- Loading email templates or other static templates +- Any static asset that's the same across all requests + +When not to use this pattern: + +- Assets that vary per request or user +- Files that may change during runtime (use caching with TTL instead) +- Large files that would consume too much memory if kept loaded +- Sensitive data that shouldn't persist in memory + +With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute), module-level caching is especially effective because multiple concurrent requests share the same function instance. The static assets stay loaded in memory across requests without cold start penalties. + +In traditional serverless, each cold start re-executes module-level code, but subsequent warm invocations reuse the loaded assets until the instance is recycled. diff --git a/.claude/skills/vercel-react-best-practices/rules/server-no-shared-module-state.md b/.claude/skills/vercel-react-best-practices/rules/server-no-shared-module-state.md new file mode 100644 index 0000000..ff7d9f2 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/server-no-shared-module-state.md @@ -0,0 +1,50 @@ +--- +title: Avoid Shared Module State for Request Data +impact: HIGH +impactDescription: prevents concurrency bugs and request data leaks +tags: server, rsc, ssr, concurrency, security, state +--- + +## Avoid Shared Module State for Request Data + +For React Server Components and client components rendered during SSR, avoid using mutable module-level variables to share request-scoped data. Server renders can run concurrently in the same process. If one render writes to shared module state and another render reads it, you can get race conditions, cross-request contamination, and security bugs where one user's data appears in another user's response. + +Treat module scope on the server as process-wide shared memory, not request-local state. + +**Incorrect (request data leaks across concurrent renders):** + +```tsx +let currentUser: User | null = null + +export default async function Page() { + currentUser = await auth() + return <Dashboard /> +} + +async function Dashboard() { + return <div>{currentUser?.name}</div> +} +``` + +If two requests overlap, request A can set `currentUser`, then request B overwrites it before request A finishes rendering `Dashboard`. + +**Correct (keep request data local to the render tree):** + +```tsx +export default async function Page() { + const user = await auth() + return <Dashboard user={user} /> +} + +function Dashboard({ user }: { user: User | null }) { + return <div>{user?.name}</div> +} +``` + +Safe exceptions: + +- Immutable static assets or config loaded once at module scope +- Shared caches intentionally designed for cross-request reuse and keyed correctly +- Process-wide singletons that do not store request- or user-specific mutable data + +For static assets and config, see [Hoist Static I/O to Module Level](./server-hoist-static-io.md). diff --git a/.claude/skills/vercel-react-best-practices/rules/server-parallel-fetching.md b/.claude/skills/vercel-react-best-practices/rules/server-parallel-fetching.md new file mode 100644 index 0000000..1affc83 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/server-parallel-fetching.md @@ -0,0 +1,83 @@ +--- +title: Parallel Data Fetching with Component Composition +impact: CRITICAL +impactDescription: eliminates server-side waterfalls +tags: server, rsc, parallel-fetching, composition +--- + +## Parallel Data Fetching with Component Composition + +React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching. + +**Incorrect (Sidebar waits for Page's fetch to complete):** + +```tsx +export default async function Page() { + const header = await fetchHeader() + return ( + <div> + <div>{header}</div> + <Sidebar /> + </div> + ) +} + +async function Sidebar() { + const items = await fetchSidebarItems() + return <nav>{items.map(renderItem)}</nav> +} +``` + +**Correct (both fetch simultaneously):** + +```tsx +async function Header() { + const data = await fetchHeader() + return <div>{data}</div> +} + +async function Sidebar() { + const items = await fetchSidebarItems() + return <nav>{items.map(renderItem)}</nav> +} + +export default function Page() { + return ( + <div> + <Header /> + <Sidebar /> + </div> + ) +} +``` + +**Alternative with children prop:** + +```tsx +async function Header() { + const data = await fetchHeader() + return <div>{data}</div> +} + +async function Sidebar() { + const items = await fetchSidebarItems() + return <nav>{items.map(renderItem)}</nav> +} + +function Layout({ children }: { children: ReactNode }) { + return ( + <div> + <Header /> + {children} + </div> + ) +} + +export default function Page() { + return ( + <Layout> + <Sidebar /> + </Layout> + ) +} +``` diff --git a/.claude/skills/vercel-react-best-practices/rules/server-parallel-nested-fetching.md b/.claude/skills/vercel-react-best-practices/rules/server-parallel-nested-fetching.md new file mode 100644 index 0000000..be1dc25 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/server-parallel-nested-fetching.md @@ -0,0 +1,34 @@ +--- +title: Parallel Nested Data Fetching +impact: CRITICAL +impactDescription: eliminates server-side waterfalls +tags: server, rsc, parallel-fetching, promise-chaining +--- + +## Parallel Nested Data Fetching + +When fetching nested data in parallel, chain dependent fetches within each item's promise so a slow item doesn't block the rest. + +**Incorrect (a single slow item blocks all nested fetches):** + +```tsx +const chats = await Promise.all( + chatIds.map(id => getChat(id)) +) + +const chatAuthors = await Promise.all( + chats.map(chat => getUser(chat.author)) +) +``` + +If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 chats can't start loading even though their data is ready. + +**Correct (each item chains its own nested fetch):** + +```tsx +const chatAuthors = await Promise.all( + chatIds.map(id => getChat(id).then(chat => getUser(chat.author))) +) +``` + +Each item independently chains `getChat` → `getUser`, so a slow chat doesn't block author fetches for the others. diff --git a/.claude/skills/vercel-react-best-practices/rules/server-serialization.md b/.claude/skills/vercel-react-best-practices/rules/server-serialization.md new file mode 100644 index 0000000..39c5c41 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/rules/server-serialization.md @@ -0,0 +1,38 @@ +--- +title: Minimize Serialization at RSC Boundaries +impact: HIGH +impactDescription: reduces data transfer size +tags: server, rsc, serialization, props +--- + +## Minimize Serialization at RSC Boundaries + +The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses. + +**Incorrect (serializes all 50 fields):** + +```tsx +async function Page() { + const user = await fetchUser() // 50 fields + return <Profile user={user} /> +} + +'use client' +function Profile({ user }: { user: User }) { + return <div>{user.name}</div> // uses 1 field +} +``` + +**Correct (serializes only 1 field):** + +```tsx +async function Page() { + const user = await fetchUser() + return <Profile name={user.name} /> +} + +'use client' +function Profile({ name }: { name: string }) { + return <div>{name}</div> +} +``` diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9f2c7e8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Normalize text to LF in the repository and in every checkout, on every OS. +* text=auto eol=lf + +# Vendored upstream skill: markdown hard line breaks (trailing double spaces) are intentional and +# hash-pinned in .agents/skill-sources.json, so whitespace checks are off for these files only. +.agents/skills/vercel-react-best-practices/** -whitespace +.claude/skills/vercel-react-best-practices/** -whitespace diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e4bb61f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,37 @@ +<!-- Agents and humans: fill every section. Write "n/a" rather than deleting one. --> + +## What and why + +<!-- One or two sentences. Link the issue or decision if there is one. --> + +## Review guide + +<!-- Where the human reviewer should spend attention: +- Read carefully: files/functions that carry the risk, and why. +- Skim: mechanical changes (renames, formatting, generated code, fixtures). +- Least confident about: … +- Tests removed, weakened or skipped: list each with the reason, or "none". --> + +## Task size + +<!-- T0 / T1 / T2 (see AGENTS.md#task-size). For T2, name each AGENTS.md invariant touched and its transaction, lease or provider boundary. --> + +## Checks + +<!-- One line per planned check: PASS / FAIL / NOT RUN, the command, scope and counts. Local runs are not hosted CI. --> + +| Result | Command | Scope / notes | +| ------ | ------- | ------------- | +| | | | + +## Unverified boundaries + +<!-- What this change could affect that no executed check covers (e.g. hosted Neon HTTP, GitHub OAuth, OpenRouter, Resend/webhook delivery, browser). --> + +## Review + +<!-- T1: self-review with ponytail-review. T2 or CI/guard change: independent reviewer (codex-review or its fallback, reviewed SHA) and a summary of findings and their disposition; /security-review for auth, ownership, cron auth or secrets. engineering-workflow runs: task slug and final verdict. --> + +## Docs and temporary files + +<!-- Owning documents updated, or why none needed. What remains under .tmp/<task-slug>/ (kept until merge or rejection), or "none". Architecture decision records consulted (T2), or "none applies". --> diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99c9f6c..b523b67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,9 @@ -name: Agent workflow integrity +name: CI on: + # PR branches run once via pull_request; main runs after merge. push: + branches: [main] pull_request: workflow_dispatch: @@ -9,11 +11,12 @@ permissions: contents: read concurrency: - group: agent-workflow-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: agent-workflow: + name: Agent workflow integrity (${{ matrix.os }}) strategy: fail-fast: false matrix: @@ -30,4 +33,86 @@ jobs: package-manager-cache: false # These checks need neither application dependencies nor credentials. - run: node scripts/sync-agent-skills.mjs --check - - run: node --test tests/agent-skills-sync.test.mjs + - run: node scripts/check-engineering-workflow.mjs + - run: node --test tests/agent-skills-sync.test.mjs tests/check-pr-review-signals.test.mjs tests/check-migration-history.test.mjs tests/engineering-workflow-state.test.mjs + + migrations: + name: Migration history is append-only + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Reject edits to existing migrations, snapshots or journal entries + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: node scripts/check-migration-history.mjs "$BASE_SHA" HEAD + + review-signals: + name: Review signals + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + # Lists T2-area files in the job summary and fails on focused or newly skipped tests. + - name: Check T2 areas and test changes + id: signals + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: node scripts/check-pr-review-signals.mjs "$BASE_SHA" HEAD + - name: Sync review labels + if: ${{ !cancelled() && steps.signals.outputs.t2 != '' && github.event.pull_request.head.repo.full_name == github.repository }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + T2: ${{ steps.signals.outputs.t2 }} + GUARD: ${{ steps.signals.outputs.guard }} + run: | + sync() { # label, wanted, description + if [ "$2" = "true" ]; then + gh label create "$1" --color B60205 --description "$3" --force + gh pr edit "$PR" --add-label "$1" + else + gh pr edit "$PR" --remove-label "$1" || true + fi + } + sync t2-area "$T2" "Touches a T2 area: read carefully" + sync guard-change "$GUARD" "Changes CI or a guard script: independent review" + + app: + name: Lint, typecheck and offline tests + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + NEXT_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + - uses: pnpm/action-setup@v6 + with: + cache: true + run_install: false + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + # No database, provider or app URL is configured: SQL/HTTP cases skip by their env gates. + # No build: Next.js prerendering would query a database. The test glob lives here, not in + # package.json, so narrowing what CI runs is always a workflow (guard) change. + - run: node --test tests/*.test.mjs diff --git a/.gitignore b/.gitignore index 7b8da95..8470320 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ # misc .DS_Store +# agent task scratch space and host-created worktrees +/.tmp/ +/.claude/worktrees/ *.pem # debug diff --git a/AGENTS.md b/AGENTS.md index 65013d1..45ba0a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,78 +2,115 @@ **Status:** current -## Start here and source of truth +StackPulse tracks GitHub releases for developer stacks, summarises them with OpenRouter and shows a filterable feed. It is non-profit and has no billing. Classify the [task size](#task-size) first; T1/T2 tasks then use [docs/AGENT_START_HERE.md](docs/AGENT_START_HERE.md) before editing behavior. -1. Confirm the repository/working directory and inspect `git status`. Preserve unrelated work. -2. Read [README.md](README.md), [docs/AGENT_START_HERE.md](docs/AGENT_START_HERE.md), applicable nested `AGENTS.md`/`CLAUDE.md`, and the task's owning documents before non-trivial work. -3. Inspect existing implementations, callers, package scripts and configuration. Code/configuration establish current behavior; docs provide intent. Surface disagreements rather than silently trusting stale prose. -4. State assumptions and a short plan with verifiable outcomes for multi-step work. Discover facts independently; ask only about consequential unresolved decisions. Skip heavy planning for trivial, sufficiently specified tasks. +## Start here -This file owns shared repository rules and skill routing. [AI workflow](docs/operations/ai-workflow.md) owns skill maintenance and compatibility checks; [verification](docs/operations/verification.md) owns commands and service isolation. `CLAUDE.md` imports this guide with `@AGENTS.md`. Cursor uses this guide and the existing `.cursor/rules/`; do not duplicate the whole workflow there. +1. Work on a task branch in your own worktree (see [Git and PRs](#git-and-prs)); inspect `git status` there and preserve existing work. If the worktree's `AGENTS.md` or `CLAUDE.md` differs from the copy loaded at session start, the worktree copy wins. +2. Classify the task with [Task size](#task-size), state the size you chose, and read only what it requires. The size is the highest row that any changed file or behavior matches; reclassify upward as soon as the change reaches a T2 area. +3. Code and configuration establish current behavior; docs provide intent. Surface disagreements instead of trusting stale prose. State the intended change and its verification; keep the diff surgical. -## Project boundaries +## Task size -- Next.js 16 App Router / React 19; pnpm is pinned in `package.json`. Use existing tooling and conventions. -- PostgreSQL on Neon through Drizzle's **neon-http** adapter. A local PostgreSQL URL does not make the production HTTP driver work over TCP. Local SQL tests use an explicit test transport; hosted Neon behavior is a separate boundary. -- GitHub-only Better Auth; preserve server-side sessions, ownership checks, origin validation and safe return paths. No billing: do not reintroduce removed Lemon Squeezy behavior without an explicit task. -- Release ingestion scans registry stacks plus followed custom repositories. Preserve deduplication, bounded retries, run finalization and recovery from unknown writes. -- OpenRouter inference can spend money; Resend and Slack/Discord can send real notifications. Do not start cron, seed/backfill, workers or live provider calls as a verification shortcut. -- Agent Skill Updates has separate ingestion/model settings and approval-gated hosted verification. Preserve lease fencing, durable checkpoints, [its staging gates](docs/operations/skill-staging-verification.md), disabled cron registration, budget limits and rollout sequence. -- Preserve the architecture, domain contracts and additive migration history. Do not replace reviewed migrations with schema push or regenerate old snapshots to fix a task. +| Size | What counts | Read | Skills | Verify | Review | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| **T0 trivial** | Copy, typo, label/style token, comment or doc wording; no behavior change | This file and the touched files | None | Cheapest relevant check | Re-read the diff | +| **T1 standard** | Bug fix, UI/page change, feed or filter behavior, public stack/skill pages, MCP read tools, read-only queries, small feature or refactor outside T2 | T0 + `package.json`, the code entry points of the [reading matrix](docs/AGENT_START_HERE.md#read-by-task) row, and the one document owning the behavior you change | `ponytail`; `systematic-debugging` when the cause is unknown | [Verification commands](#verification-commands) for the touched scope | `ponytail-review` of your own diff | +| **T2 high-risk** | Schema/migrations; auth, sessions, return paths, origin checks; user-owned writes (server actions, votes, webhooks); cron routes, cron auth and `vercel.json` schedules; release/skill ingestion, retries, leases and checkpoints; AI calls and budgets; digest/webhook sends and unsubscribe; scripts that write production data or call paid providers; secrets | T1 + `README.md`, every document in the matrix row and any [architecture decision](docs/architecture/decisions/index.html) whose invariant the change could affect | T1 skills, `grilling` for unresolved decisions, `supabase-postgres-best-practices` for SQL | T1 + real isolated PostgreSQL when SQL, transaction, lock or persistence behavior changes | Independent reviewer (see [Skill routing](#skill-routing)) | -## Engineering and autonomous execution +Skill text saying "non-trivial" means T1 or T2. A bug with an unknown cause takes the size of the area it is in. `engineering-workflow` runs only when the user asks for it, normally for T2. `docs/README.md` is a navigation index, not required reading. Verification follows the boundary actually touched: a T2-area change that alters no SQL or persistence behavior (for example, message text only) still gets T2 reading and review, but not a database run. -- Make the smallest complete, correct change. Every changed line should serve the request. No speculative features, abstractions, unrelated refactors, formatting or cleanup of pre-existing dead code. -- Reuse existing utilities, dependencies and platform capabilities. Match neighboring patterns. Remove imports/functions made unused by your own change; preserve validation, error handling, security, accessibility and meaningful edge cases. -- Check affected callers, integrations, fixtures and tests before editing shared behavior. For a bug, verify the cause and reproduce it where practical; use a focused regression for meaningful behavior. -- Inspect current official documentation when implementing new or unfamiliar APIs. Review relevant package upgrades when needed; do not upgrade unrelated dependencies as incidental cleanup. Prefer pnpm for new projects. -- Resolve routine reversible choices independently. Ask before implementing unresolved high-risk decisions; already-approved decisions do not need another interview. -- Never expose or commit secrets. No production data changes/migrations, cloud-connected tests, paid inference, real notifications, deployment, commit or push without explicit session authorization. Never use an unverified `.env` database as a test fallback. -- Use disposable, test-owned resources for destructive tests. Clean up only this run's resources. Do not change global/user agent settings, install lifecycle hooks/plugins, or create skill symlinks/junctions. -- Do not fabricate product claims, metrics, users or verification results. Distinguish implemented, inspected, tested and externally verified evidence. +## Verification commands -## Conditional skill routing +Run from the root of your worktree. [Verification](docs/operations/verification.md) owns setup, isolation and the detailed boundaries. -Load the repository-local `.agents/skills/<name>/SKILL.md` in Codex or `.claude/skills/<name>/SKILL.md` in Claude Code. When same-name personal/plugin skills exist, explicitly read the local file; do not combine competing versions. Select skills by task relevance and explicit user requests. Upstream "always" or session-wide wording must not activate unrelated skills. Once selected, follow the relevant procedure and confirmation gates. Prefer one primary skill per activity and load supporting references only as needed. +| Scope | Commands | +| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Docs only | `git diff --check`; if links changed, confirm the relative links and anchors resolve | +| Application code | `pnpm lint`, `pnpm typecheck`, and the touched suite: `pnpm test:cron`, `test:theme`, `test:votes` or `test:skills` | +| SQL, migrations, persistence, locks | Above + the suite's database cases against a disposable local PostgreSQL: `VOTE_TEST_DATABASE_URL` for votes, `SKILL_TEST_DATABASE_URL` + `SKILL_TEST_TARGET_FILE` (owned native cluster) for skills and cron scheduling. Unavailable: report NOT RUN | +| Hosted Neon HTTP, GitHub OAuth, OpenRouter, Resend, Slack/Discord | No automated check: NOT RUN unless the user approves a run in an isolated environment | +| Agent skills or workflow files | `node scripts/sync-agent-skills.mjs --check`, `node scripts/check-engineering-workflow.mjs`, `pnpm test:agent-workflow` | +| Before opening a PR (T1/T2) | `pnpm lint`, `pnpm typecheck`, `pnpm test` | -| Activity | Routing | -| --- | --- | -| Planning | Use `grilling` for consequential unresolved requirements, architecture trade-offs or high-risk design choices. Inspect first, discover facts independently, separate approved choices from open ones, ask focused questions with recommendations, and respect its confirmation gate before implementation. Skip it for trivial or well-specified work. `grill-me` is explicit-only and delegates to `grilling`. | -| Implementation | Apply `ponytail` to meaningful code changes: reuse existing patterns and deliver the smallest complete solution, preserving project/domain safety and affected integrations. | -| Debugging | Use `systematic-debugging` for non-trivial bugs, recurring/intermittent failures and unexplained tests: evidence, hypotheses, root cause and regression verification. Skip the full process for isolated fixes with an established cause. | -| PostgreSQL | Use `supabase-postgres-best-practices` for schema/migrations, queries/indexes, transactions/locks/concurrency, connections, performance or database security. Read relevant references only. Do not assume Supabase infrastructure; Neon architecture and project safety take precedence. | -| Review | For non-trivial changes, apply `ponytail-review` with the existing `eastbase-review-pr` overlay. Trace affected callers/integrations, correctness, regressions, concurrency, security, tests, performance and unnecessary complexity. Prefer an independent or isolated context when available. Report findings before applying fixes; a review itself changes no code. Avoid redundant passes. | -| Audit | Use `ponytail-audit` for explicit whole-codebase or major subsystem audits, never automatically for each feature. | +`pnpm build` is not a default check: Next.js prerendering queries the database configured in `.env`. Run it only against a verified isolated database. Report each check as PASS, FAIL or NOT RUN with command, scope, counts (including skips) and reason. Zero executed tests or default-skipped SQL cases are not integration evidence. Local runs are not hosted CI. -Retain specialized environment-provided skills when available: `eastbase-premium-ui` before user-facing UI work, `eastbase-review-pr` for product/auth/AI-cost/data/UX risk, and `eastbase-launch-check` only when a launch/go-live check is wanted. Launch checks return a verdict with blockers and follow-ups; ordinary implementation does not trigger them. These overlays complement general engineering skills. If a required specialized skill is unavailable, report the gap and inspect relevant owning docs; do not claim the gate passed. +## Project context -For React/Next.js behavior, data fetching, rendering, refactoring and performance work, conditionally use available `vercel-react-best-practices`. Use available `agent-browser` when actual browser interaction provides meaningful UI evidence, with isolated data. Do not automatically install/vendor either optional capability. Skip browser work for irrelevant backend/documentation tasks and report material verification gaps. +- Next.js 16 App Router, React 19, TypeScript 7 (`tsc`; TypeScript 6 stays as `tsc6` for typescript-eslint). pnpm is pinned in `packageManager`. Tests use `node:test`; there is no Vitest or Jest. +- PostgreSQL on Neon through Drizzle's **neon-http** adapter (`src/db/index.ts`); migrations in `drizzle/`. Database tests run the same adapter over a local test transport. +- GitHub-only Better Auth. Vercel hosts the app and its cron schedules (`vercel.json`). OpenRouter via `src/lib/ai.ts`; Resend for the digest; Slack/Discord user webhooks. +- No Redis, broker, worker or Docker stack. Isolated database tests need a local cluster; the skill suites additionally require a workspace-owned native cluster. -## Windows shell (Command Prompt) +## Project invariants + +- Production uses Drizzle neon-http. A local TCP PostgreSQL URL does not prove the hosted HTTP transport. +- Migrations are append-only, reviewed forward migrations. No `db:push` against shared databases; do not regenerate or edit old migrations or snapshots. +- Auth is GitHub-only Better Auth with server-side sessions; redirects go through `safeReturnPath` (`src/lib/auth-return-path.ts`). +- Cookie-authenticated mutations require a same-origin `Origin` header (the vote route); never trust forwarded host headers. +- Users read and write only their own preferences, custom repos, votes and webhook settings; derive identity from the session, never from input. +- Cron routes require the `CRON_SECRET` bearer token, compared in constant time (`src/lib/cron-auth.ts`). +- Release ingestion keeps deduplication (`release_updates_tech_version_unique`), bounded retries, run finalization and recovery from unknown writes. +- Skill Updates keeps lease-token fencing (`lease_until`), durable checkpoints and budget limits. Its cron stays unregistered in `vercel.json` until the [staging gates](docs/operations/skill-staging-verification.md) pass. +- Webhook targets are HTTPS Slack/Discord hosts only (`src/lib/webhooks.ts`). +- Billing stays removed: do not reintroduce Lemon Squeezy behavior without an explicit task. + +## Scope and safety + +- Never print or commit secrets or `.env` values. Never use an unverified `.env` database as a test fallback; test suites refuse non-loopback targets and must keep doing so. +- No production data changes or migrations, cloud-connected tests, deployment or remote push without explicit session authorization. +- OpenRouter spends money; Resend and Slack/Discord send real messages. Do not start cron, seed, backfill, `skills` scripts, workers or live provider calls as a verification shortcut. +- Use disposable, test-owned resources for destructive tests and clean up only this run's resources. No global Docker prune or shared volumes. +- Do not change global/user agent settings, install hooks or plugins, or create skill symlinks/junctions. +- Documentation claims must point to implementation or executed checks. Separate implemented, inspected, tested and externally verified. No fabricated users, metrics or readiness claims. + +## Files and artifacts + +Default to creating no new files. The deliverable is the code and tests the task needs; plans, reviews and evidence go in the chat, commit message or PR description. -Use `cmd.exe` for terminal commands on Windows. Chain necessary commands with `&&`; use `set VAR=value` for environment variables. Only use PowerShell when explicitly requested. Preserve `.cursor/rules/windows-cmd-shell.mdc` and `.vscode/settings.json`, including the `cmd.exe` automation profile. Editor shell setup and known Cursor limitations are documented in [local development](docs/operations/local-development.md#windows-editor-shell). +- **Temporary files** live only in one ignored directory per task: `.tmp/<task-slug>/` (`engineering-workflow` uses `.tmp/agent-workflows/<task-slug>/`). Never write to the `.tmp/` root or to tracked paths. Keep at most `plan.md`, one `review-<n>.md` per review (plus its `review-brief.md` and `review-prompt.md`), a `pr.md` draft and one compact verification summary (commands, exit codes, pass/fail/skip counts, short failure excerpts). `engineering-workflow` keeps its own fixed set (its SKILL.md step 3). A task worktree (`.tmp/<task-slug>/worktree`, or `.claude/worktrees/<name>` when the host creates it) is a checkout, not an artifact: remove it only with `git worktree remove` after its branch is merged or abandoned. Do not save full runner output, duplicate logs, source snapshots or diff copies. Overwrite your own working files; reviewer reports stay immutable. +- **Cleanup:** a task's `.tmp` files are its resume source. Delete them only when the task is finalized (PR merged or rejected, or the human accepted or rejected the result) or the user abandons it. At the end of each session, list what remains and why. Never read or reuse another task's `.tmp` directory unless asked. +- **Permanent docs:** create a new Markdown file only when the user asks or a new subsystem has no owning document; otherwise edit the owning document. Every new or edited doc carries a status: `current`, `planned`, `shipped`, `historical` or `superseded`; move completed plans and stale reviews to `docs/archive/`. No per-round, per-review or per-checkpoint reports in `docs/`, and no run logs appended to `docs/operations/verification.md`. A durable architecture decision gets one short ADR in `docs/architecture/decisions/` (context, decision, consequences), linked from its index. +- **Size limits:** plan ≤200 lines; review = findings only, ≤150 lines; decision ≤1 page. Needing more is a signal to split the task. +- **Reading budget:** read what your task size requires, then only what the code leads you to. `docs/audits/` and `docs/archive/` are historical and opt-in; open one only when the task names it. Never read whole folders "for context". +- **Finish report** lists every file created, kept or deleted, including temporary ones. -## UI and documentation +## Skill routing -- For UI/pages/styling, read [docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md), follow project tokens/layout patterns, and prefer design-system colors. No linear-gradient background unless requested or necessary. -- Treat documentation as working memory, verified against implementation. Update owning docs for durable changes to architecture, routes/APIs, env vars, schema/migrations, auth/security, operations/testing/deployment or user-visible behavior. -- Avoid documentation churn for tiny refactors, incidental implementation details or temporary debugging. Prefer one current source of truth per topic and preserve architecture records/navigation indexes. -- Every new/edited doc needs a status: `current`, `planned`, `shipped`, `historical` or `superseded`. Move completed plans/stale reviews to `docs/archive/`. Record valuable lessons in existing owning docs; do not create a parallel casebook by default. +Use skills conditionally and in proportion to the task; this routing and explicit user requests decide activation, and upstream "always" or session-wide wording does not extend it. Load the repository copy from `.agents/skills/<name>/SKILL.md` in Codex or `.claude/skills/<name>/SKILL.md` in Claude Code; when same-name personal or plugin skills exist, read the local file explicitly and do not combine versions. Codex uses `$skill-name`; Claude Code uses `/skill-name`. Once selected, follow the skill's procedure and confirmation gates. Project invariants and safety rules take precedence over code-minimalism heuristics. Provenance and maintenance: [AI workflow](docs/operations/ai-workflow.md). + +- **Orchestration (explicit requests only):** `engineering-workflow` coordinates planning, fresh independent review, verification, bounded corrections and human approval. Never auto-trigger it or let it commit, push or activate cron/providers. +- **Planning:** inspect first. For T2, or T1 with unresolved product decisions, use `grilling` for consequential open choices: focused questions with recommendations, approval before implementing. When the user asks for a single approval, list open decisions with recommendations instead of interviewing round by round. Skip it for trivial or well-specified work. +- **Implementation:** `ponytail` for T1/T2 code: reuse existing patterns, smallest complete change, check affected callers, focused tests for meaningful logic. +- **Debugging:** `systematic-debugging` for non-trivial, recurring or unexplained failures: reproduce, gather evidence, test hypotheses, verify with a regression. Skip it when the cause is established. +- **PostgreSQL:** `supabase-postgres-best-practices` for schema, migrations, queries, indexes, transactions, locks, connections or performance. Read only relevant references; never assume Supabase infrastructure. Neon and the invariants above take precedence. +- **Review:** T1/T2 changes get `ponytail-review` of the complete affected flow. T2 and CI or guard changes also need an independent reviewer: run `codex-review` (Codex, read-only, against `origin/main`; it covers designs too and falls back to a fresh read-only subagent). Give the reviewer the brief, acceptance criteria and diff, not your conclusions. Changes touching auth, user ownership, cron auth or secrets also get Claude Code's `/security-review`. Report findings before fixing them; a review changes no code. +- **Audit:** `ponytail-audit` only for explicit whole-repository or major-subsystem audits. + +## Frontend skills + +- Read [docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md) before UI, page or styling work and use its tokens, frames, typography and copy voice. No linear-gradient backgrounds unless requested or necessary. +- `frontend-design` sets visual direction when building new UI or reshaping existing UI; skip it for small fixes. Its output stays within `DESIGN_SYSTEM.md` conventions. +- `vercel-react-best-practices` for T1/T2 React/Next.js behavior, data fetching, rendering or performance work; read only the `rules/` files the task needs. Skip it for copy or styling-only changes. Next.js 16 APIs may differ from training data: check `node_modules/next/dist/docs/`. +- Browser verification: when the host has a browser tool (`agent-browser` or the desktop app's built-in browser), check user-facing interaction or layout against `pnpm dev` on localhost with isolated data. Otherwise report browser verification as NOT RUN. Never run browser mutations against production. + +## Windows shell (Command Prompt) -## Risk-based verification and Definition of Done +Use `cmd.exe` for terminal commands on Windows. Chain commands with `&&`; set environment variables with `set VAR=value`. Use PowerShell only when explicitly requested. Preserve `.cursor/rules/windows-cmd-shell.mdc` and `.vscode/settings.json`, including the `cmd.exe` automation profile; see [local development](docs/operations/local-development.md#windows-editor-shell). Commands in skills are written to run in both Command Prompt and a POSIX shell. -Select checks by affected risk using [verification](docs/operations/verification.md#risk-based-verification). Documentation, styling and pure logic need proportionate static/unit/browser checks, not containers. Migrations, PostgreSQL constraints/locking/persistence/concurrency and infrastructure boundaries need application-level tests against real isolated services; use Docker where suitable or the existing verified native cluster. Mocks and a healthy container do not prove integration. +## Git and PRs -If Docker or another required isolated environment is unavailable, mark affected checks **NOT RUN**, explain the missing evidence, and continue safe unrelated work. Do not bypass restrictions or declare unverified integration production-ready. No global Docker prune, unknown/shared volumes, production secrets, developer-project teardown or live provider consumers. Follow the detailed [Docker safety policy](docs/operations/verification.md#safe-docker-execution). +- One branch per task from freshly fetched `origin/main`, without upstream tracking so a bare `git push` cannot target `main`: `git fetch origin`, then `git worktree add --no-track -b <type>/<slug> .tmp/<slug>/worktree origin/main` (or use a worktree the host creates in `.claude/worktrees/`). Types: `feat/`, `fix/`, `chore/`, `docs/`; the slug does not repeat the type. If the branch or worktree already exists, choose a new slug unless resuming that task. Never commit to `main`. +- Commit locally on the task branch when a checkpoint passes its checks, using Conventional Commits. A user instruction not to commit overrides this, and `engineering-workflow` runs keep their own no-automatic-commit rule. +- Prepare and open PRs with the local `ship-pr` skill. Push, PR creation and merge need explicit user authorization in the session. Merging is always the human's decision: squash merge after required checks pass. +- Fill the PR description from the [template](.github/pull_request_template.md), including the **review guide** (what to read carefully, what is mechanical, where you are least confident, any removed or weakened tests). Evidence lives there, not in `docs/`. +- CI's _Review signals_ job labels PRs touching T2 paths `t2-area` (the label wins over a smaller claimed size) and PRs changing CI, guard scripts or the test-database ownership guard `guard-change` (independent review, no T2 reading). It fails when a changed test file contains a focused, skipped or todo test other than the `skip: !<condition> && '<reason>'` env gate or a `// allow-skip: <reason>` comment; every newly allowed skip is listed for human approval. Never weaken a test to make a check pass. +- Hosted CI counts only for the exact head SHA, read from the run (for example `gh pr checks`); before a push it is NOT RUN. +- Existing migrations are append-only: CI (`scripts/check-migration-history.mjs`) rejects PRs that modify, rename or delete an existing SQL migration or snapshot in `drizzle/`, or change, remove or reorder an existing journal entry. Generate a new migration only after rebasing onto the latest `main`; its journal entry needs a `when` greater than every existing entry (the drizzle migrator silently skips older ones; CI checks this). Rebase again if another PR adds a migration first. -Before declaring meaningful work done: +## Finish -- Requirements and domain/safety invariants hold; affected callers/integrations were reviewed. -- Relevant checks ran with fresh evidence; real-service gaps and skipped/zero tests are explicit. For meaningful changes, run relevant tests plus `pnpm typecheck` and `pnpm lint`, or explain omissions. -- Blocking review findings are resolved; `eastbase-review-pr` complements `ponytail-review` before completion. -- Documentation impact was assessed, owning docs/indexes updated when needed, and edited links/statuses checked. -- Temporary resources were safely cleaned up, or cleanup failure and exact run identity reported. -- Completion reports changes, exact commands, **PASS / FAIL / NOT RUN**, counts where available, environment/cleanup limits, unresolved assumptions/risks and next steps. A CI configuration or previous-session result is not fresh passing evidence; installation/discovery is not proof of model activation. +Run the checks your task size requires. Assess whether architecture, routes/APIs, env vars, schema, auth, cron, operations or user-visible behavior changed; update the owning document and its index when needed, and skip churn for incidental details. Report changed files, exact commands with PASS / FAIL / NOT RUN and counts, unverified boundaries, temporary files, assumptions and next steps. Commit, push and PRs follow [Git and PRs](#git-and-prs). <!-- BEGIN:nextjs-agent-rules --> diff --git a/CLAUDE.md b/CLAUDE.md index ab888f1..e4d4ba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ @AGENTS.md -Shared rules and skill routing live in `AGENTS.md`. Read [docs/AGENT_START_HERE.md](docs/AGENT_START_HERE.md) and the task's owning docs before non-trivial work. Use the repository-local `.claude/skills/` copies for the seven standard skills. +Classify the task from the Task size table in AGENTS.md. For T1/T2 tasks, the reading matrix in [docs/AGENT_START_HERE.md](docs/AGENT_START_HERE.md) then decides what to read; it does not decide the size. The shared guide owns project invariants, safety rules, skill routing and verification commands; [docs/operations/verification.md](docs/operations/verification.md) owns setup and detailed procedures. Use the repository-local `.claude/skills/` copies. <!-- BEGIN:nextjs-agent-rules --> diff --git a/docs/AGENT_START_HERE.md b/docs/AGENT_START_HERE.md index bf6ad3c..40d81b1 100644 --- a/docs/AGENT_START_HERE.md +++ b/docs/AGENT_START_HERE.md @@ -1,58 +1,70 @@ # Agent start here -**Status:** current -**Last verified:** 2026-08-12 (code + config in repo) +**Status:** current +**Last verified:** 2026-08-12 (product and stack truth); reading matrix paths checked 2026-10-24 StackPulse is a Next.js app that tracks GitHub releases for developer stacks, AI-summarises them via OpenRouter, and shows a filterable feed. Non-profit, self-hostable, no billing. ---- - -## Required read order +[AGENTS.md](../AGENTS.md) owns the rules: task size, invariants, safety, skill routing and verification commands. Classify the task there first, then read what its size requires from the matrix below. Trust code over docs when they disagree; surface drift and update docs only when the task changes durable behavior. -1. This file (every session) -2. [`README.md`](../README.md) — product overview and quick start -3. [`DESIGN_SYSTEM.md`](./DESIGN_SYSTEM.md) — **UI design system** + architecture (required for any UI/page work) -4. [`AGENTS.md`](../AGENTS.md) — shared rules/routing; [`CLAUDE.md`](../CLAUDE.md) imports them -5. Task-specific doc from [`docs/README.md`](./README.md) -6. Source files listed in the **source-of-truth map** below before changing behavior +--- -Trust code over docs when they disagree. Surface drift; update docs only when the task changes durable behavior. +## Read by task + +| Task | Documents (T2: all; T1: the one owning the behavior) | Code to inspect | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routes, pages, navigation, caching | [features/overview.md](./features/overview.md), [operations/navigation-and-caching.md](./operations/navigation-and-caching.md) | `src/app/**`, `src/proxy.ts`, `next.config.ts`, `loading.tsx` files | +| UI, styling, themes | [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) | `src/app/globals.css`, `src/components/**`, `theme-provider.tsx`, `theme-switcher.tsx`; `tests/theme.test.mjs` | +| Auth, sessions, return paths | [features/auth.md](./features/auth.md), [architecture/security.html](./architecture/security.html), [ADR-002](./architecture/decisions/002-better-auth.html) | `src/lib/auth.ts`, `auth-client.ts`, `auth-return-path.ts`, `src/app/api/auth/[...all]/route.ts` | +| Schema, migrations, SQL | [operations/database.md](./operations/database.md), [database-architecture.html](./database-architecture.html), [operations/verification.md](./operations/verification.md), [ADR-001](./architecture/decisions/001-drizzle-neon-http.html) | `src/db/schema.ts`, `src/db/index.ts`, `drizzle/`, `tests/*-db.test.mjs`, `tests/skill-migration.test.mjs` | +| Release ingestion, cron | [features/release-ingestion.md](./features/release-ingestion.md), [features/ai-summarization.md](./features/ai-summarization.md), [operations/deployment.md](./operations/deployment.md), [ADR-003](./architecture/decisions/003-ingestion-followed-stacks.html), [ADR-004](./architecture/decisions/004-ai-before-insert.html) | `src/lib/release-ingestion.ts`, `github.ts`, `cron-auth.ts`, `execution-budget.ts`, `src/app/api/cron/**`, `vercel.json`; `tests/cron-*.test.mjs` | +| AI summaries and release advice | [features/ai-summarization.md](./features/ai-summarization.md) | `src/lib/ai.ts`, `ai-resilience.ts`, `src/app/api/release-advice/route.ts` | +| Agent Skill Updates | [features/agent-skill-updates.md](./features/agent-skill-updates.md), [operations/skill-staging-verification.md](./operations/skill-staging-verification.md) | `src/lib/skill-*.ts`, `public-skills.ts`, `src/app/skills/`, `src/app/api/cron/fetch-skill-updates/`, `scripts/skill-*.ts`; `tests/skill-*.test.mjs` | +| Release feed, read state, votes | [features/release-feed.md](./features/release-feed.md), [features/public-stacks.md](./features/public-stacks.md), [features/auth.md](./features/auth.md#release-voting-oauth-smoke-test) | `src/lib/release-feed.ts`, `release-votes.ts`, `src/app/api/releases/**`, `src/components/dashboard/release-feed.tsx`, `src/components/releases/`; `tests/release-*.test.mjs` | +| Public stacks, SEO | [features/public-stacks.md](./features/public-stacks.md) | `src/lib/public-stacks.ts`, `public-releases.ts`, `src/app/stacks/**`, `sitemap.ts`, `robots.ts` | +| Preferences, custom repos, onboarding | [features/server-actions.md](./features/server-actions.md) | `src/lib/actions.ts`, `stack-import.ts`, `src/app/(dashboard)/` | +| Digest, notifications | [features/digest-signup.md](./features/digest-signup.md), [features/notifications.md](./features/notifications.md) | `src/lib/digest.ts`, `webhooks.ts`, `src/app/api/cron/send-digest/`, `src/app/api/digest/unsubscribe/` | +| MCP server | [features/mcp-server.md](./features/mcp-server.md) | `src/app/api/mcp/[transport]/route.ts`, `src/lib/upgrade-plan.ts` | +| Env, deployment, monitoring | [operations/environment-variables.md](./operations/environment-variables.md), [operations/deployment.md](./operations/deployment.md), [architecture/observability.html](./architecture/observability.html) | `.env.example`, `vercel.json`, `src/instrumentation*.ts`, `sentry.*.config.ts` | +| Setup, CI, scripts, agent workflow | [operations/local-development.md](./operations/local-development.md), [operations/verification.md](./operations/verification.md), [operations/ai-workflow.md](./operations/ai-workflow.md), [operations/typescript.md](./operations/typescript.md) | `package.json`, `.github/workflows/ci.yml`, `scripts/`, `.agents/skill-sources.json` | + +Architecture overviews live in [architecture/index.html](./architecture/index.html); durable decisions in [architecture/decisions/](./architecture/decisions/index.html). `docs/audits/` and `docs/archive/` are historical: open one only when the task names it. --- ## Current product truth -| Area | Truth (verified in code) | -|------|--------------------------| -| Auth | GitHub OAuth only via Better Auth. `/sign-up` redirects to `/sign-in`. | -| Onboarding | `/onboarding` — pick up to 30 registry stacks; up to 5 custom GitHub repos; **package.json import** resolves deps via npm registry into follow suggestions. | -| Feed | `/dashboard` — virtualised release feed with importance, read/unread, signal, tech, search filters. | -| AI chat | Authenticated POST `/api/release-advice` — upgrade Q&A on a release. | -| Public SEO | `/stacks`, `/stacks/[slug]` — public release pages (20 releases/stack). | -| Agent skills | `/skills` — ten curated sources, revision/tree detection, separate durable analysis cron. See [agent skill updates](./features/agent-skill-updates.md). | -| Digest | Landing + public stack pages collect emails into `digest_subscribers`. Weekly digest **sends via Resend** (`/api/cron/send-digest`, Mondays 14:00 UTC) when `RESEND_API_KEY` + `DIGEST_FROM_EMAIL` are set; one-click unsubscribe at `/digest/unsubscribe`. | -| Cron | Fetches releases for **all registry stacks** + custom repos followed by ≥1 user. Custom repos also fetch immediately on add. | -| MCP | Public read-only MCP server at `/api/mcp/mcp` (streamable HTTP) — `list_stacks`, `get_releases`, `get_upgrade_plan`, `search_releases`. See [features/mcp-server.md](./features/mcp-server.md). | -| Notifications | `/settings` — one Slack/Discord webhook per user (`user_webhooks`), importance threshold; cron dispatches new releases post-run. See [features/notifications.md](./features/notifications.md). | -| Billing | **None.** Lemon Squeezy columns removed in migration `0002`; do not reintroduce without explicit task. | -| Analytics | `@vercel/analytics` in root layout only. | -| Monitoring | `@sentry/nextjs` errors-only, gated on `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` (fully inert without them). Configs: `src/instrumentation.ts`, `src/instrumentation-client.ts`, root `sentry.*.config.ts`. | +| Area | Truth (verified in code) | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Auth | GitHub OAuth only via Better Auth. `/sign-up` redirects to `/sign-in`. | +| Onboarding | `/onboarding` — pick up to 30 registry stacks; up to 5 custom GitHub repos; **package.json import** resolves deps via npm registry into follow suggestions. | +| Feed | `/dashboard` — virtualised release feed with importance, read/unread, signal, tech, search filters. | +| AI chat | Authenticated POST `/api/release-advice` — upgrade Q&A on a release. | +| Public SEO | `/stacks`, `/stacks/[slug]` — public release pages (20 releases/stack). | +| Agent skills | `/skills` — ten curated sources, revision/tree detection, separate durable analysis cron. See [agent skill updates](./features/agent-skill-updates.md). | +| Digest | Landing + public stack pages collect emails into `digest_subscribers`. Weekly digest **sends via Resend** (`/api/cron/send-digest`, Mondays 14:00 UTC) when `RESEND_API_KEY` + `DIGEST_FROM_EMAIL` are set; one-click unsubscribe at `/digest/unsubscribe`. | +| Cron | Fetches releases for **all registry stacks** + custom repos followed by ≥1 user. Custom repos also fetch immediately on add. | +| MCP | Public read-only MCP server at `/api/mcp/mcp` (streamable HTTP) — `list_stacks`, `get_releases`, `get_upgrade_plan`, `search_releases`. See [features/mcp-server.md](./features/mcp-server.md). | +| Notifications | `/settings` — one Slack/Discord webhook per user (`user_webhooks`), importance threshold; cron dispatches new releases post-run. See [features/notifications.md](./features/notifications.md). | +| Billing | **None.** Lemon Squeezy columns removed in migration `0002`; do not reintroduce without explicit task. | +| Analytics | `@vercel/analytics` in root layout only. | +| Monitoring | `@sentry/nextjs` errors-only, gated on `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` (fully inert without them). Configs: `src/instrumentation.ts`, `src/instrumentation-client.ts`, root `sentry.*.config.ts`. | --- ## Stack truth (from `package.json`, config, code) -| Layer | Choice | -|-------|--------| -| Framework | Next.js 16 App Router, React 19 | -| Language | TypeScript 7 (`tsc`). TypeScript 6 stays installed as `tsc6` because typescript-eslint still needs the TS 6 compiler API — see [operations/typescript.md](./operations/typescript.md) | -| Styling | Tailwind CSS v4, shadcn (`base-nova`), hugeicons-react, motion | -| Auth | `better-auth` + `@better-auth/infra` dash plugin (optional) | -| Database | Neon Postgres (`@neondatabase/serverless`) + Drizzle ORM | -| AI | OpenRouter via OpenAI SDK (`src/lib/ai.ts`) | -| Data fetching (client) | TanStack Query, nuqs URL state | -| Hosting | Vercel + Vercel Cron (`vercel.json`) | -| Package manager | pnpm 11 | +| Layer | Choice | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Framework | Next.js 16 App Router, React 19 | +| Language | TypeScript 7 (`tsc`). TypeScript 6 stays installed as `tsc6` because typescript-eslint still needs the TS 6 compiler API — see [operations/typescript.md](./operations/typescript.md) | +| Styling | Tailwind CSS v4, shadcn (`base-nova`), hugeicons-react, motion | +| Auth | `better-auth` + `@better-auth/infra` dash plugin (optional) | +| Database | Neon Postgres (`@neondatabase/serverless`) + Drizzle ORM | +| AI | OpenRouter via OpenAI SDK (`src/lib/ai.ts`) | +| Data fetching (client) | TanStack Query, nuqs URL state | +| Hosting | Vercel + Vercel Cron (`vercel.json`) | +| Package manager | pnpm 11 | **Not used:** Prisma (ORM is Drizzle), Clerk/Supabase auth, Stripe/Lemon Squeezy billing, PostHog. @@ -60,131 +72,8 @@ Trust code over docs when they disagree. Surface drift; update docs only when th ## Known documentation drift -| Claim | Where | Code truth | -|-------|-------|------------| +| Claim | Where | Code truth | +| ----------------------- | --------------------- | ------------------------------------------------------------------------------------- | | GitHub OAuth "required" | README deploy section | Optional in dev (`auth.ts` skips provider if env missing); **required in production** | Old Drizzle snapshots (`drizzle/meta/0000_snapshot.json`, `0001`) still mention `lemonsqueezy_*` — historical migration artifacts only. - ---- - -## Source-of-truth map - -| Area | Code truth | Docs | Notes | -|------|------------|------|-------| -| Routes & pages | `src/app/**` | [features/overview.md](./features/overview.md) | App Router route groups | -| Auth | `src/lib/auth.ts`, `src/app/api/auth/[...all]/route.ts` | [features/auth.md](./features/auth.md) | GitHub OAuth, Better Auth tables | -| DB schema | `src/db/schema.ts`, `drizzle/` | [operations/database.md](./operations/database.md), [database-architecture.html](./database-architecture.html) | Migrations in `drizzle/` | -| Architecture (HTML) | `docs/architecture/` | [architecture/index.html](./architecture/index.html) | System, security, API, ADRs | -| Release ingestion | `src/lib/release-ingestion.ts`, `src/lib/github.ts` | [features/release-ingestion.md](./features/release-ingestion.md) | Cron + custom-repo trigger | -| Agent skill updates | `src/lib/skill-*.ts`, `src/lib/public-skills.ts`, `src/app/skills/` | [features/agent-skill-updates.md](./features/agent-skill-updates.md) | Separate cron, migration 0010, manual seed | -| AI summarisation | `src/lib/ai.ts` | [features/ai-summarization.md](./features/ai-summarization.md) | OpenRouter, Zod validation | -| Release feed | `src/lib/release-feed.ts`, `src/components/dashboard/release-feed.tsx` | [features/release-feed.md](./features/release-feed.md) | Filters, virtualisation, advice UI | -| Public stacks | `src/lib/public-stacks.ts`, `src/app/stacks/**` | [features/public-stacks.md](./features/public-stacks.md) | SEO pages, JSON-LD | -| Server actions | `src/lib/actions.ts` | [features/server-actions.md](./features/server-actions.md) | Prefs, digest, read state, custom repos | -| Env vars | `.env.example` | [operations/environment-variables.md](./operations/environment-variables.md) | Canonical list | -| Deploy & cron | `vercel.json`, README | [operations/deployment.md](./operations/deployment.md) | Vercel Cron auth via `CRON_SECRET` | -| Skill Updates staging | Guarded tests and diagnostic scripts | [operations/skill-staging-verification.md](./operations/skill-staging-verification.md) | Hosted checks require explicit approval; skill cron registration disabled | -| Local dev | `package.json` scripts | [operations/local-development.md](./operations/local-development.md) | Focused cron suite: `pnpm test:cron` | -| AI engineering workflow | `AGENTS.md`, `CLAUDE.md`, `.agents/skills/`, sync script | [operations/ai-workflow.md](./operations/ai-workflow.md), [operations/verification.md](./operations/verification.md) | Pinned local skills, conditional routing, integrity CI and risk-based checks | -| Navigation + caching | `next.config.ts`, `src/app/**/loading.tsx`, cached data helpers | [operations/navigation-and-caching.md](./operations/navigation-and-caching.md) | Cache Components + Partial Prefetching | -| Themes | `components/theme-provider.tsx`, `components/theme-switcher.tsx`, `app/globals.css` | [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md#theme-behavior-and-compatible-components) | Dark default; localStorage preference; root `data-theme`; Tailwind inline aliases | -| Digest | `src/lib/digest.ts`, `digestSubscribers` table, `subscribeToDigest` | [features/digest-signup.md](./features/digest-signup.md) | Capture + weekly Resend send + unsubscribe | -| Design system + architecture | `globals.css`, `src/components/**` | [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) | Tokens, layout, copy voice, badges | - ---- - -## Doc status guide - -| Folder | Purpose | Status labels | -|--------|---------|---------------| -| `docs/features/` | How each system works today | `current` | -| `docs/operations/` | Setup, deploy, env, DB, verification | `current` | -| `docs/strategy/` | Roadmap / product direction | `planned` or `current` when added | -| `docs/archive/` | Shipped plans, old audits, superseded docs | `historical`, `shipped`, `superseded` | - -When editing a doc, set status in the first lines. Move completed plans to `docs/archive/`. - ---- - -## Work rules for agents - -- Read this file + task doc before non-trivial changes. -- For UI, pages, or styling: read [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) and match tokens, frames, typography, and badge tones. -- Match existing patterns in neighboring files; minimal diff. -- Update docs when changing routes, APIs, env vars, schema, auth, cron, or user-visible behavior. -- Do not update docs for tiny refactors or obvious code-only changes. -- Prefer pnpm; on Windows use **Command Prompt (`cmd.exe`)** for shell commands — see `AGENTS.md` → **Windows shell** and `.cursor/rules/windows-cmd-shell.mdc`. -- Next.js 16 APIs may differ from training data — check `node_modules/next/dist/docs/` when unsure. -- No commits unless the user asks. - ---- - -## Verification commands - -```bash -node scripts/sync-agent-skills.mjs --check -pnpm test:agent-workflow # Sync integrity regression tests; no services -pnpm typecheck # TypeScript 7 -pnpm typecheck:ts6 # TypeScript 6, for comparing during the TS 7 migration -pnpm lint -pnpm test:cron # Mocked cron/GitHub recovery tests -pnpm test:theme # Theme initialization + light-palette contrast -pnpm build -git diff --check -``` - -Database (requires `.env` with `DATABASE_URL`): - -```bash -pnpm db:push # dev schema sync -pnpm db:migrate # apply migrations -pnpm db:seed # seed + sync the 90-stack registry -pnpm db:studio # Drizzle Studio -``` - -Manual cron (requires `CRON_SECRET`): - -```bash -curl -H "Authorization: Bearer %CRON_SECRET%" "http://localhost:3000/api/cron/fetch-releases" -``` - -Backfill AI summaries for existing releases: - -```bash -pnpm releases:backfill -- --limit=10 -``` - -Focused cron tests use Node's built-in runner with mocked external services; Vitest/Jest are not configured. - ---- - -## Environment variables - -See [operations/environment-variables.md](./operations/environment-variables.md). Quick reference: - -| Variable | Required | Purpose | -|----------|----------|---------| -| `DATABASE_URL` | yes | Neon Postgres | -| `BETTER_AUTH_SECRET` | prod | Session signing | -| `BETTER_AUTH_URL` | prod | Auth origin | -| `NEXT_PUBLIC_APP_URL` | prod | Public origin + AI referer | -| `GITHUB_CLIENT_ID/SECRET` | prod | GitHub OAuth | -| `OPENROUTER_API_KEY` | yes (for AI) | Release summarisation | -| `CRON_SECRET` | yes (cron) | Protects `/api/cron/fetch-releases` | -| `OPENROUTER_MODEL` | no | Default `deepseek/deepseek-chat` | -| `GITHUB_TOKEN` | yes (release cron) | GitHub API PAT; required by protected health check too | -| `BETTER_AUTH_API_KEY` | no | Better Auth Dash plugin | -| `RESEND_API_KEY` | no | Weekly digest emails | -| `DIGEST_FROM_EMAIL` | no | Verified Resend sender for digests | - ---- - -## Before ending a task - -- [ ] Changed behavior matches code paths you touched -- [ ] `pnpm typecheck && pnpm lint` pass (or note why not run) -- [ ] Docs updated if routes/APIs/env/schema/user-visible behavior changed -- [ ] Doc status + paths correct; no broken relative links in edited docs -- [ ] UI changes follow [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) (tokens, frames, copy voice) -- [ ] Drift called out to user if docs/copy still disagree with code diff --git a/docs/README.md b/docs/README.md index 728f7a4..1f094a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,16 +8,16 @@ Index for humans and AI agents. Agents should start at [AGENT_START_HERE.md](./A ## Folder guide -| Folder | Purpose | -|--------|---------| -| [AGENT_START_HERE.md](./AGENT_START_HERE.md) | Session entry point: read order, stack truth, drift, verification | -| [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) | **UI design system** (tokens, layout, copy) + architecture overview | -| [architecture/index.html](./architecture/index.html) | **Architecture docs (HTML)** — system, deployment, security, API, ADRs | -| [database-architecture.html](./database-architecture.html) | Database schema, indexes, data flows (HTML) | -| [features/](./features/) | How each product system works **today** | -| [operations/](./operations/) | Local setup, env, database, deployment, verification | -| [strategy/](./strategy/) | Roadmap and product direction (empty until added) | -| [archive/](./archive/) | Historical plans, reviews, superseded docs — **do not treat as current** | +| Folder | Purpose | +| ---------------------------------------------------------- | ------------------------------------------------------------------------ | +| [AGENT_START_HERE.md](./AGENT_START_HERE.md) | Session entry point: reading matrix by task, product/stack truth, drift | +| [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) | **UI design system** (tokens, layout, copy) + architecture overview | +| [architecture/index.html](./architecture/index.html) | **Architecture docs (HTML)** — system, deployment, security, API, ADRs | +| [database-architecture.html](./database-architecture.html) | Database schema, indexes, data flows (HTML) | +| [features/](./features/) | How each product system works **today** | +| [operations/](./operations/) | Local setup, env, database, deployment, verification | +| [strategy/](./strategy/) | Roadmap and product direction (empty until added) | +| [archive/](./archive/) | Historical plans, reviews, superseded docs — **do not treat as current** | --- @@ -25,31 +25,31 @@ Index for humans and AI agents. Agents should start at [AGENT_START_HERE.md](./A ### Features -| Doc | Topic | -|-----|-------| -| [overview.md](./features/overview.md) | Routes, API surface, app structure | -| [auth.md](./features/auth.md) | Better Auth + GitHub OAuth | -| [release-ingestion.md](./features/release-ingestion.md) | Cron, GitHub fetch, AI persist | -| [release-feed.md](./features/release-feed.md) | Dashboard feed, filters, read state | -| [ai-summarization.md](./features/ai-summarization.md) | OpenRouter summarisation + advice | -| [public-stacks.md](./features/public-stacks.md) | `/stacks` SEO pages | +| Doc | Topic | +| ----------------------------------------------------------- | ---------------------------------------------------------------------- | +| [overview.md](./features/overview.md) | Routes, API surface, app structure | +| [auth.md](./features/auth.md) | Better Auth + GitHub OAuth | +| [release-ingestion.md](./features/release-ingestion.md) | Cron, GitHub fetch, AI persist | +| [release-feed.md](./features/release-feed.md) | Dashboard feed, filters, read state | +| [ai-summarization.md](./features/ai-summarization.md) | OpenRouter summarisation + advice | +| [public-stacks.md](./features/public-stacks.md) | `/stacks` SEO pages | | [agent-skill-updates.md](./features/agent-skill-updates.md) | Curated skill revisions, durable ingestion, impact reports and rollout | -| [digest-signup.md](./features/digest-signup.md) | Email capture (no sender yet) | -| [server-actions.md](./features/server-actions.md) | `src/lib/actions.ts` | +| [digest-signup.md](./features/digest-signup.md) | Email capture (no sender yet) | +| [server-actions.md](./features/server-actions.md) | `src/lib/actions.ts` | ### Operations -| Doc | Topic | -|-----|-------| -| [local-development.md](./operations/local-development.md) | Install, dev server, scripts | -| [environment-variables.md](./operations/environment-variables.md) | Full env reference | -| [database.md](./operations/database.md) | Schema, migrations, seed | -| [deployment.md](./operations/deployment.md) | Vercel, cron, production checks | -| [verification.md](./operations/verification.md) | Typecheck, lint, build, manual checks | -| [ai-workflow.md](./operations/ai-workflow.md) | Shared agent instructions, pinned skills, synchronization and compatibility pilot | -| [skill-staging-verification.md](./operations/skill-staging-verification.md) | Skill Updates isolation, live approval gates, budgets and rollout sequence | -| [typescript.md](./operations/typescript.md) | Why TypeScript 7 and 6 are both installed | -| [navigation-and-caching.md](./operations/navigation-and-caching.md) | Cache Components, Partial Prefetching, Suspense, and cache policy | +| Doc | Topic | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| [local-development.md](./operations/local-development.md) | Install, dev server, scripts | +| [environment-variables.md](./operations/environment-variables.md) | Full env reference | +| [database.md](./operations/database.md) | Schema, migrations, seed | +| [deployment.md](./operations/deployment.md) | Vercel, cron, production checks | +| [verification.md](./operations/verification.md) | Typecheck, lint, build, manual checks | +| [ai-workflow.md](./operations/ai-workflow.md) | Pinned and local agent skills, synchronization, CI guards and compatibility pilot | +| [skill-staging-verification.md](./operations/skill-staging-verification.md) | Skill Updates isolation, live approval gates, budgets and rollout sequence | +| [typescript.md](./operations/typescript.md) | Why TypeScript 7 and 6 are both installed | +| [navigation-and-caching.md](./operations/navigation-and-caching.md) | Cache Components, Partial Prefetching, Suspense, and cache policy | --- @@ -71,7 +71,7 @@ Mark archived files with `Status: historical` or `Status: superseded` in the hea 2. **Status header** on every doc: `current`, `planned`, `shipped`, `historical`, or `superseded`. 3. **Trust code** — update docs when architecture, routes, env, schema, auth, cron, or user-visible behavior changes. 4. **Move, don't delete** — completed plans go to `archive/`. -5. **Link from AGENT_START_HERE** when adding a new feature or ops doc to the source-of-truth map. +5. **Link from AGENT_START_HERE** when adding a new feature or ops doc to the reading matrix. 6. **UI changes** must follow [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) for tokens, frames, typography, and badges. 7. **Relative links** — use paths from the doc's location; run link checks after moves. diff --git a/docs/operations/ai-workflow.md b/docs/operations/ai-workflow.md index 9be39e0..dd24842 100644 --- a/docs/operations/ai-workflow.md +++ b/docs/operations/ai-workflow.md @@ -1,33 +1,37 @@ # AI engineering workflow -**Status:** current · setup inspected 2026-10-21 +**Status:** current · setup inspected 2026-10-21, workflow v2 adopted 2026-10-24 -[AGENTS.md](../../AGENTS.md) owns shared rules and conditional routing. [CLAUDE.md](../../CLAUDE.md) imports it with `@AGENTS.md`; Cursor retains its existing project instructions and shell rule. [Verification](verification.md) owns check selection, commands and service isolation. This setup changes engineering infrastructure, not StackPulse application behavior. +[AGENTS.md](../../AGENTS.md) owns shared rules, task sizes and skill routing. [CLAUDE.md](../../CLAUDE.md) imports it with `@AGENTS.md`; Cursor retains its existing project instructions and shell rule. [Verification](verification.md) owns check selection, commands and service isolation. This setup changes engineering infrastructure, not StackPulse application behavior. ## Work loop -1. Inspect Git status, [agent start here](../AGENT_START_HERE.md), relevant owning docs, code and callers. Preserve unrelated work. +1. Classify the [task size](../../AGENTS.md#task-size), then read what it requires via [agent start here](../AGENT_START_HERE.md): owning docs, code and callers. Inspect Git status and preserve unrelated work. 2. Define the outcome, assumptions, affected boundaries and proportionate verification. Use Grilling only for consequential unresolved decisions; approved implementation does not need another interview. 3. Make the smallest complete change with existing patterns. Preserve auth/ownership, ingestion recovery, data and AI-cost safeguards. -4. Run relevant checks and review the complete affected flow. Ponytail Review supplies engineering review; the existing Eastbase overlay supplies product/data/AI-cost/UX review. Report findings before review fixes. Full audits and launch gates remain conditional. +4. Run relevant checks and review the complete affected flow. Ponytail Review supplies engineering review; T2 and CI/guard changes add an independent `codex-review`, and auth, ownership, cron-auth or secret changes add `/security-review`. Report findings before review fixes. Full audits and launch gates remain conditional. 5. Update owning docs for durable changes; report fresh PASS / FAIL / NOT RUN evidence, skips, limitations, cleanup and next steps. Installation, discovery and actual activation are separate claims. ## Pinned skill packages -All seven complete packages were copied from [Fintech Lab at `95757b737fbbf3b88cdab2dc4fb16572555b0f31`](https://github.com/daniel-tsx/fintech/tree/95757b737fbbf3b88cdab2dc4fb16572555b0f31/.agents/skills), not from current upstream HEAD or personal installations. The reference's directory trees were checked against its manifest: 61 canonical files, including seven MIT licenses. PostgreSQL guidance is included because StackPulse uses Neon PostgreSQL; it does not imply Supabase infrastructure. +Eight complete packages are vendored, copied byte-for-byte from Fintech Lab rather than from current upstream HEAD or personal installations. Six came from [`95757b737fbbf3b88cdab2dc4fb16572555b0f31`](https://github.com/daniel-tsx/fintech/tree/95757b737fbbf3b88cdab2dc4fb16572555b0f31/.agents/skills) on 2026-10-21; their hashes are unchanged at the current reference. `vercel-react-best-practices` and `frontend-design` came from [`7f5e1c243f7bfba51e1928a0860c1d88188e8ab8`](https://github.com/daniel-tsx/fintech/tree/7f5e1c243f7bfba51e1928a0860c1d88188e8ab8/.agents/skills) on 2026-10-24 with their manifest entries. `grill-me` was removed on 2026-10-24, as in the reference: `grilling` covers its trigger phrases. PostgreSQL guidance is included because StackPulse uses Neon PostgreSQL; it does not imply Supabase infrastructure. -| Skills | Reviewed upstream source revision | -| --- | --- | -| `ponytail`, `ponytail-review`, `ponytail-audit` | [DietrichGebert/ponytail · `09c434ab73ca43761c5d8b0c5d341926c2fba710`](https://github.com/DietrichGebert/ponytail/tree/09c434ab73ca43761c5d8b0c5d341926c2fba710/skills) | -| `grill-me`, `grilling` | [mattpocock/skills · `5beb35b040e3de97e54fa9e386d7e42d15c295c2`](https://github.com/mattpocock/skills/tree/5beb35b040e3de97e54fa9e386d7e42d15c295c2/skills/productivity) | -| `systematic-debugging` | [obra/superpowers · `bb92a77741419a4ab5f06e711a283343f1ada0c3`](https://github.com/obra/superpowers/tree/bb92a77741419a4ab5f06e711a283343f1ada0c3/skills/systematic-debugging) | -| `supabase-postgres-best-practices` | [supabase/agent-skills · `c9be0e931b7930f7d02126d04774d904c381e7d7`](https://github.com/supabase/agent-skills/tree/c9be0e931b7930f7d02126d04774d904c381e7d7/skills/supabase-postgres-best-practices) | +| Skills | Reviewed upstream source revision | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ponytail`, `ponytail-review`, `ponytail-audit` | [DietrichGebert/ponytail · `09c434ab73ca43761c5d8b0c5d341926c2fba710`](https://github.com/DietrichGebert/ponytail/tree/09c434ab73ca43761c5d8b0c5d341926c2fba710/skills) | +| `grilling` | [mattpocock/skills · `5beb35b040e3de97e54fa9e386d7e42d15c295c2`](https://github.com/mattpocock/skills/tree/5beb35b040e3de97e54fa9e386d7e42d15c295c2/skills/productivity) | +| `systematic-debugging` | [obra/superpowers · `bb92a77741419a4ab5f06e711a283343f1ada0c3`](https://github.com/obra/superpowers/tree/bb92a77741419a4ab5f06e711a283343f1ada0c3/skills/systematic-debugging) | +| `supabase-postgres-best-practices` | [supabase/agent-skills · `c9be0e931b7930f7d02126d04774d904c381e7d7`](https://github.com/supabase/agent-skills/tree/c9be0e931b7930f7d02126d04774d904c381e7d7/skills/supabase-postgres-best-practices) | +| `vercel-react-best-practices` | [vercel-labs/agent-skills · `063bee94c3f4df8453406c830b0a7df0f2860278`](https://github.com/vercel-labs/agent-skills/tree/063bee94c3f4df8453406c830b0a7df0f2860278/skills/react-best-practices) | +| `frontend-design` | [anthropics/skills · `dbd4588f9e1033efb41dad4bef2f7947c8993d44`](https://github.com/anthropics/skills/tree/dbd4588f9e1033efb41dad4bef2f7947c8993d44/skills/frontend-design) | [`.agents/skill-sources.json`](../../.agents/skill-sources.json) preserves the reference's upstream revisions, tree IDs, attribution, adaptations and SHA-256 hashes (UTF-8 with LF line endings). Its `reference` records the migration source; `installedFrom` identifies the actual pinned reference copy. Upstream tree IDs describe upstream packages, not locally adapted trees. Skill licenses apply to those vendored packages; application licensing is unchanged. Canonical sources are real files in `.agents/skills/`; `.claude/skills/` contains generated real copies. No junctions, symlinks, hooks, plugins, framework or global settings are installed. Preserve the reference's adaptations: -- Codex `grill-me` reads `../grilling/SKILL.md`; Claude's generated copy restores `Call the Skill tool with "grilling".` Its `disable-model-invocation: true` frontmatter and `agents/openai.yaml` policy retain explicit-only invocation. Grilling's confirmation gates remain intact. +- Both hosts receive identical copies; no per-host adaptation remains. Grilling's confirmation gates remain intact. +- Vercel React Best Practices omits the compiled 108 KB `AGENTS.md` (hosts can auto-load a nested `AGENTS.md` as directory instructions), points `SKILL.md` at `rules/`, and carries a repository-authored `LICENSE` note because upstream declares MIT but ships no file. Its Markdown hard line breaks are hash-pinned, so `.gitattributes` turns whitespace checks off for that tree only. +- Frontend Design is unmodified and keeps its Apache-2.0 `LICENSE.txt`. - Systematic Debugging retains its investigation procedure and references, with narrower activation and references to repository testing rather than requiring extra Superpowers skills. Its optional `find-polluter.sh` needs Bash; it is not automatically executed on Windows. - PostgreSQL discovery is narrowed; its body and references remain as reviewed. Load only relevant references. @@ -41,7 +45,7 @@ node scripts/sync-agent-skills.mjs --check node --test tests/agent-skills-sync.test.mjs ``` -With application tooling installed, `pnpm test:agent-workflow` runs the same regression tests. The dependency-free synchronizer validates all managed packages and hashes before any target writes. It rejects unsafe paths, duplicate names, links, missing source resources and unexpected files inside a managed target package. It repairs missing/stale target copies, normalizes CRLF for comparison, and preserves unrelated custom skills and Claude settings. `--check` is read-only; neither mode fetches upstream or removes files. ESLint and Prettier exclude only the two vendored skill trees so upstream examples/content are not treated as application code or reformatted; their integrity gate remains the hash check. +With application tooling installed, `pnpm test:agent-workflow` runs the same regression tests. The dependency-free synchronizer validates all managed packages and hashes before any target writes. It rejects unsafe paths, duplicate names, links, missing source resources or license (`LICENSE` or `LICENSE.txt`) and unexpected files inside a managed target package. It repairs missing/stale target copies, normalizes CRLF for comparison, and preserves unrelated custom skills and Claude settings. `--check` is read-only; neither mode fetches upstream or removes files. ESLint and Prettier exclude only the two vendored skill trees so upstream examples/content are not treated as application code or reformatted; their integrity gate remains the hash check. For a deliberate source update, retrieve the approved revision from the official source, review the complete package/adaptations/license, and update only truthful provenance and computed hashes. Edit canonical files, never generated copies; then synchronize, run integrity/regression checks, validate YAML/frontmatter and review the diff. Do not automatically upgrade skills. A hash match proves recorded content, not safety or model behavior. @@ -49,51 +53,58 @@ For a deliberate source update, retrieve the approved revision from the official [Codex discovers repository skills under `.agents/skills`](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills); duplicate names can appear together. [Claude Code resolves skills by scope](https://code.claude.com/docs/en/skills#resolve-skills-that-share-a-name), so a personal skill may win over a project skill. [Claude's `@path` import syntax](https://code.claude.com/docs/en/memory#import-additional-files) supports this shared `AGENTS.md` entry point. Hosts may cache discovery; use a fresh session after installation. These are documented capabilities, not evidence that this checkout's skills were activated. -On this machine, personal Codex and Claude skill directories both contain `ponytail`, `grilling`, `systematic-debugging` and `supabase-postgres-best-practices`. The session catalog also exposes plugin PostgreSQL variants. User/global settings remain unchanged. Explicitly read the repository-local path when a name collides; do not load multiple versions for one activity. The Eastbase review/launch/UI overlays and optional frontend/browser capabilities remain environment-provided, not vendored by this task. +On this machine, personal Codex and Claude skill directories both contain `ponytail`, `grilling`, `systematic-debugging` and `supabase-postgres-best-practices`. The session catalog also exposes plugin PostgreSQL variants. User/global settings remain unchanged. Explicitly read the repository-local path when a name collides; do not load multiple versions for one activity. Eastbase overlays are no longer part of the routing (removed 2026-10-24). Browser capabilities remain environment-provided, not vendored. -In a fresh Codex session from this checkout, inspect the skill selector or app-server `skills/list` with `cwds` set to this repository and `forceReload: true`. Confirm seven repository paths and no loader errors. In Claude Code, inspect the `/` skill menu and resolved paths, then use `/memory` to confirm the imported `AGENTS.md`. The installed `claude --version` reports `2.1.114`; current docs may describe newer UI commands, so prefer the supported `/` menu and `/memory` checks. Do not change global configuration to hide duplicates as part of repository adoption. +In a fresh Codex session from this checkout, inspect the skill selector or app-server `skills/list` with `cwds` set to this repository and `forceReload: true`. Confirm eleven repository paths (eight vendored, three local) and no loader errors. In Claude Code, inspect the `/` skill menu and resolved paths, then use `/memory` to confirm the imported `AGENTS.md`. The installed `claude --version` reports `2.1.114`; current docs may describe newer UI commands, so prefer the supported `/` menu and `/memory` checks. Do not change global configuration to hide duplicates as part of repository adoption. ## Read-only pilot prompts Use fresh sessions and say: **"Read-only workflow check. Do not edit files, start services, call external providers or implement anything. Explain routing and the next required evidence."** Then append one case below. Inspect the actual skill path loaded; do not infer activation from the model merely naming a skill. -| Append this request | Expected behavior | -| --- | --- | -| "Plan durable retries for Skill Updates; lease expiry and unknown writes remain unresolved." | Inspect facts, then Grilling with recommendations and a confirmation gate before implementation. | -| "Describe how you would implement this agreed change: improve the offline fixture assertion; acceptance criteria are specified." | Ponytail; no redundant interview; focused checks and eventual review. | -| "Investigate an intermittent cron test failure whose cause is unknown." | Systematic Debugging: reproduce, gather evidence and test hypotheses before a fix. | -| "Review a PostgreSQL index and lock order for skill lease acquisition." | PostgreSQL skill, relevant references and real isolated SQL evidence; no Supabase assumptions. | -| "Review the workflow diff and affected callers." | Ponytail Review plus Eastbase review overlay; report findings without edits. | -| "Audit the full release-ingestion subsystem." | Ponytail Audit for the requested audit scope; report without fixes. | -| "Correct one spelling error in this documentation." | Proportionate direct work; no heavy interview, audit or Docker. | -| "Verify a changed vote button's browser behavior." | Relevant browser/frontend capabilities if available; isolated data and honest gaps. | -| "Verify a migration changing uniqueness and concurrent writes." | Real isolated PostgreSQL application tests; Docker only when suitable, or existing owned native setup. | -| "Docker is unavailable for the required integration check." | NOT RUN with missing evidence, no false PASS or safety bypass. | -| Explicit `$grill-me` in Codex or `/grill-me` in Claude | Local explicit entry point delegates to Grilling; preserve the interview's final confirmation gate. | +| Append this request | Expected behavior | +| -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| "Plan durable retries for Skill Updates; lease expiry and unknown writes remain unresolved." | Inspect facts, then Grilling with recommendations and a confirmation gate before implementation. | +| "Describe how you would implement this agreed change: improve the offline fixture assertion; acceptance criteria are specified." | Ponytail; no redundant interview; focused checks and eventual review. | +| "Investigate an intermittent cron test failure whose cause is unknown." | Systematic Debugging: reproduce, gather evidence and test hypotheses before a fix. | +| "Review a PostgreSQL index and lock order for skill lease acquisition." | PostgreSQL skill, relevant references and real isolated SQL evidence; no Supabase assumptions. | +| "Review the workflow diff and affected callers." | Ponytail Review; `codex-review` because CI and guards changed; report findings without edits. | +| "Audit the full release-ingestion subsystem." | Ponytail Audit for the requested audit scope; report without fixes. | +| "Correct one spelling error in this documentation." | Proportionate direct work; no heavy interview, audit or Docker. | +| "Verify a changed vote button's browser behavior." | Relevant browser/frontend capabilities if available; isolated data and honest gaps. | +| "Verify a migration changing uniqueness and concurrent writes." | Real isolated PostgreSQL application tests; Docker only when suitable, or existing owned native setup. | +| "Docker is unavailable for the required integration check." | NOT RUN with missing evidence, no false PASS or safety bypass. | File/metadata validation and discovery are distinct from this interactive behavioral pilot. Do not run paid model sessions or a synthetic application change merely to claim compatibility. -## CI and intentional differences from the reference +## Local skills, guards and CI -There was no `.github/workflows/` CI configuration in this checkout. [The new workflow](../../.github/workflows/ci.yml) runs the skill integrity check and its regression tests on Windows and Linux using Node 24, before any application dependency installation (none is needed). It uses `contents: read` and disables checkout credential persistence. It does not deploy, access secrets, run migrations or claim application CI coverage. Hosted execution remains unverified until GitHub runs it. +Three skills are authored for this repository, adapted from Fintech Lab at `7f5e1c2` with its finance-specific wording generalized to StackPulse's T2 areas. They are not in the upstream manifest; `node scripts/check-engineering-workflow.mjs` checks that the Codex and Claude copies are identical and that the orchestration guards are present. + +- `codex-review`: independent read-only Codex review of a committed branch or design, then triage. The prompt is read from stdin (`codex exec … - < .tmp/<task-slug>/review-prompt.md`), so it runs in Command Prompt and POSIX shells. Falls back to a fresh read-only subagent. +- `ship-pr`: rebase, guards, pre-PR checks, review prerequisites and a template-based description; pushes or opens a PR only after explicit approval. +- `engineering-workflow`: explicit-only orchestration with `scripts/engineering-workflow-state.mjs` as its checkpoint/resume ledger under ignored `.tmp/agent-workflows/`. Two changes from the reference helper: an unused variable is removed, and re-verification after a correction may re-record `verification.json` (reviewer reports stay immutable), covered by `tests/engineering-workflow-state.test.mjs`. + +Guards: `scripts/check-pr-review-signals.mjs` labels `t2-area` and `guard-change` PRs from the path lists in the script, rejects focused, skipped and todo tests in the diff and anywhere in each changed test file (the `skip: !<condition> && '<reason>'` env gate and `// allow-skip: <reason>` pass), and lists removed tests, including those of deleted files, and assertion losses. A blocklist cannot be complete: disabling a gate variable itself (for example `const enabled = false`) is left to review. `scripts/check-migration-history.mjs` keeps `drizzle/` append-only. Their regression tests live in `tests/` and run with `pnpm test:agent-workflow`. + +[CI](../../.github/workflows/ci.yml) jobs: _Agent workflow integrity (ubuntu-latest / windows-latest)_, _Migration history is append-only_ and _Review signals_ (pull requests only), and _Lint, typecheck and offline tests_. It uses `contents: read` (plus label writes for _Review signals_), disables checkout credential persistence, configures no secrets or database, and runs no build or deploy. Hosted execution is unverified until GitHub runs it. Unlike Fintech Lab, StackPulse has no development Compose stack or generic integration runner. Existing Node SQL tests already enforce isolation, and the skill suites require a workspace-owned native cluster proof that container paths do not satisfy. This migration adds no Compose stack or runner and changes no test guards, application driver, dependency versions or deployment configuration. [Verification](verification.md#risk-based-verification) documents the real SQL/HTTP/hosted boundaries and the requirements for a future Docker runner. -## Setup verification · 2026-10-21 - -| Check | Evidence | -| --- | --- | -| `node scripts/sync-agent-skills.mjs` and `node scripts/sync-agent-skills.mjs --check` | PASS: seven packages, 61 files per host; canonical hashes match the pinned reference manifest. Claude differs only in the recorded Grill Me delegate. | -| `pnpm test:agent-workflow` / `node --test tests/agent-skills-sync.test.mjs` | PASS: nine tests, zero failed/skipped. Fresh sync, CRLF checkout, drift/missing-file repair, tampering, preflight before writes, unexpected-file preservation, custom settings/skills and unsafe paths/arguments are covered. Temporary fixtures are removed by test teardown. | -| `node --check scripts/sync-agent-skills.mjs` and `node --check tests/agent-skills-sync.test.mjs` | PASS: Node syntax checks. | -| `pnpm typecheck`, `pnpm lint`, `pnpm test:cron` | PASS: static checks plus 118 mocked cron tests, zero failed/skipped; no live provider or SQL claims. | -| `node node_modules/prettier/bin/prettier.cjs --check scripts/sync-agent-skills.mjs tests/agent-skills-sync.test.mjs .github/workflows/ci.yml package.json eslint.config.mjs` | PASS: authored script/test/CI configuration style. Vendored content is excluded, not rewritten. | -| `node node_modules/.cache/agent-workflow-inspect.mjs` and `node node_modules/.cache/agent-workflow-links.mjs` (one-off probes) | PASS: parsed 14 skill frontmatters and four host metadata files with the installed YAML parser, checked names/descriptions/explicit-only flags, and parsed CI YAML. Both Next.js blocks match the original; the Claude import is present. No skill links/junctions; statuses and 97 local links/anchors across eight edited instruction/docs files passed. Link rejection in the synchronizer is inspected, not tested by creating a symlink. | -| Codex discovery via `node node_modules/.cache/agent-workflow-inspect.mjs` (one-off probe) | PASS: desktop binary `0.163.0-alpha.10.2`, app-server initialization and `skills/list` with `forceReload: true` returned seven enabled repository paths and no loader errors, plus four user duplicates. No thread/model turn was started. | -| `node node_modules/.cache/agent-workflow-claude-discovery.mjs` (one-off probe) | PASS: Claude `2.1.114` streaming control initialization returned all seven `(project)` commands plus four user duplicates, including explicit `grill-me`. Hooks and MCP were disabled for that process; no model prompt was sent. | -| `git diff --check` and `node node_modules/.cache/agent-workflow-final-check.mjs` (one-off probe) | PASS for tracked edits and authored new files. Per-file `git -c core.autocrlf=false diff --no-index --check -- /dev/null <path>` inspected all 127 new files. Original trailing whitespace in `references/_contributing.md` is preserved in both vendored PostgreSQL packages to retain pinned hashes. The pre-existing untracked `docs/architecture/portfolio/` is preserved; no temporary test fixture directories remain. | -| Hosted CI, Claude `/memory`, actual conditional model activation | NOT RUN: loader/static checks do not establish an interactive imported context or model compliance. Run the read-only pilot prompts above in fresh sessions and inspect resolved paths. | -| `pnpm build`, SQL/Docker/hosted integration and live providers | NOT RUN: this change affects workflow files only. A build can read unverified `.env` data; no application/service boundary changed to justify those calls. No containers, database fixtures or cloud resources were created. | +## Setup verification · 2026-10-21 (historical) + +| Check | Evidence | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node scripts/sync-agent-skills.mjs` and `node scripts/sync-agent-skills.mjs --check` | PASS: seven packages, 61 files per host; canonical hashes match the pinned reference manifest. Claude differs only in the recorded Grill Me delegate. | +| `pnpm test:agent-workflow` / `node --test tests/agent-skills-sync.test.mjs` | PASS: nine tests, zero failed/skipped. Fresh sync, CRLF checkout, drift/missing-file repair, tampering, preflight before writes, unexpected-file preservation, custom settings/skills and unsafe paths/arguments are covered. Temporary fixtures are removed by test teardown. | +| `node --check scripts/sync-agent-skills.mjs` and `node --check tests/agent-skills-sync.test.mjs` | PASS: Node syntax checks. | +| `pnpm typecheck`, `pnpm lint`, `pnpm test:cron` | PASS: static checks plus 118 mocked cron tests, zero failed/skipped; no live provider or SQL claims. | +| `node node_modules/prettier/bin/prettier.cjs --check scripts/sync-agent-skills.mjs tests/agent-skills-sync.test.mjs .github/workflows/ci.yml package.json eslint.config.mjs` | PASS: authored script/test/CI configuration style. Vendored content is excluded, not rewritten. | +| `node node_modules/.cache/agent-workflow-inspect.mjs` and `node node_modules/.cache/agent-workflow-links.mjs` (one-off probes) | PASS: parsed 14 skill frontmatters and four host metadata files with the installed YAML parser, checked names/descriptions/explicit-only flags, and parsed CI YAML. Both Next.js blocks match the original; the Claude import is present. No skill links/junctions; statuses and 97 local links/anchors across eight edited instruction/docs files passed. Link rejection in the synchronizer is inspected, not tested by creating a symlink. | +| Codex discovery via `node node_modules/.cache/agent-workflow-inspect.mjs` (one-off probe) | PASS: desktop binary `0.163.0-alpha.10.2`, app-server initialization and `skills/list` with `forceReload: true` returned seven enabled repository paths and no loader errors, plus four user duplicates. No thread/model turn was started. | +| `node node_modules/.cache/agent-workflow-claude-discovery.mjs` (one-off probe) | PASS: Claude `2.1.114` streaming control initialization returned all seven `(project)` commands plus four user duplicates, including explicit `grill-me`. Hooks and MCP were disabled for that process; no model prompt was sent. | +| `git diff --check` and `node node_modules/.cache/agent-workflow-final-check.mjs` (one-off probe) | PASS for tracked edits and authored new files. Per-file `git -c core.autocrlf=false diff --no-index --check -- /dev/null <path>` inspected all 127 new files. Original trailing whitespace in `references/_contributing.md` is preserved in both vendored PostgreSQL packages to retain pinned hashes. The pre-existing untracked `docs/architecture/portfolio/` is preserved; no temporary test fixture directories remain. | +| Hosted CI, Claude `/memory`, actual conditional model activation | NOT RUN: loader/static checks do not establish an interactive imported context or model compliance. Run the read-only pilot prompts above in fresh sessions and inspect resolved paths. | +| `pnpm build`, SQL/Docker/hosted integration and live providers | NOT RUN: this change affects workflow files only. A build can read unverified `.env` data; no application/service boundary changed to justify those calls. No containers, database fixtures or cloud resources were created. | The initial sandboxed pnpm bootstrap could not resolve the registry, and the Codex loader probe timed out. The requested pinned pnpm 11.1.3 was then bootstrapped into the ignored workspace cache with approved network access; pnpm checks used the process-local prefix `set COREPACK_HOME=node_modules\.cache\workflow-corepack&&`. Read-only host discovery passed with approved host access. Global settings and dependency versions are unchanged. Local CLI discovery and tests establish readiness for a **read-only workflow pilot**, not completed behavioral validation or product launch readiness. diff --git a/docs/operations/verification.md b/docs/operations/verification.md index d661bc1..4c67a60 100644 --- a/docs/operations/verification.md +++ b/docs/operations/verification.md @@ -6,14 +6,14 @@ Select checks for the actual changed boundary. [AGENTS.md](../../AGENTS.md) owns routing and completion rules; [AI workflow](ai-workflow.md) owns skill integrity/discovery and read-only pilot prompts. -| Change | Required evidence | -| --- | --- | -| Documentation or trivial styling | Edited links/statuses, whitespace and relevant static/visual checks; no Docker requirement. | -| Agent workflow tooling | `node scripts/sync-agent-skills.mjs --check`, `pnpm test:agent-workflow`, YAML/frontmatter checks after skill edits, and applicable lint/type checks. Discovery and behavioral activation are separate. | -| Pure application logic | Focused unit tests plus applicable typecheck/lint/build checks; containers only when a real affected boundary needs them. | -| Migrations, SQL constraints/indexes, transactions, persistence, locks or concurrency | Relevant application integration/migration tests against real disposable PostgreSQL. Existing ownership-verified native PostgreSQL is suitable; Docker is an option, not a requirement to replace it. Mocks cannot prove SQL semantics. | -| New service boundaries | Real isolated application-level integration across those boundaries; introduce infrastructure only when existing behavior justifies it. StackPulse currently has no Redis/broker/worker stack. | -| Neon HTTP, auth, live AI or notifications | Separate provider-specific evidence in an explicitly approved isolated environment; local SQL does not verify hosted transport, OAuth, billing or delivery. | +| Change | Required evidence | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Documentation or trivial styling | Edited links/statuses, whitespace and relevant static/visual checks; no Docker requirement. | +| Agent workflow tooling | `node scripts/sync-agent-skills.mjs --check`, `node scripts/check-engineering-workflow.mjs`, `pnpm test:agent-workflow`, YAML/frontmatter checks after skill edits, and applicable lint/type checks. Discovery and behavioral activation are separate. | +| Pure application logic | Focused unit tests plus applicable typecheck/lint checks; `pnpm build` only against a verified isolated database; containers only when a real affected boundary needs them. | +| Migrations, SQL constraints/indexes, transactions, persistence, locks or concurrency | Relevant application integration/migration tests against real disposable PostgreSQL. Existing ownership-verified native PostgreSQL is suitable; Docker is an option, not a requirement to replace it. Mocks cannot prove SQL semantics. | +| New service boundaries | Real isolated application-level integration across those boundaries; introduce infrastructure only when existing behavior justifies it. StackPulse currently has no Redis/broker/worker stack. | +| Neon HTTP, auth, live AI or notifications | Separate provider-specific evidence in an explicitly approved isolated environment; local SQL does not verify hosted transport, OAuth, billing or delivery. | Always report exact command, scope, **PASS / FAIL / NOT RUN**, executed/passed/failed/skipped counts when available, environment limits, cleanup and remaining unverified boundaries. Zero executed tests or unexpected skips do not establish an integration PASS. A green process with intentionally skipped SQL/HTTP cases is only evidence for the cases executed. Do not replace missing integration evidence with mock results or a healthy service/container. @@ -26,7 +26,7 @@ If Docker or another required isolated environment cannot run, do not bypass res - `pnpm test:skills` mixes offline cases with SQL, local SDK/HTTP and migration-rehearsal cases guarded by both `SKILL_TEST_DATABASE_URL` and `SKILL_TEST_TARGET_FILE`. These verify workspace-owned data directory/PID/system identity before writes. Follow the [staging runbook](skill-staging-verification.md), not an unverified copied ownership proof. - Production uses `@neondatabase/serverless` over HTTP with Drizzle neon-http. Vote/skill SQL tests use a local TCP-backed test client. Skill HTTP tests additionally exercise the actual SDK via a loopback HTTP bridge; this still does not prove hosted Neon behavior. Cloud-connected tests require explicit approval and dedicated disposable targets. -There is no checked-in Compose stack or Docker integration runner. Do not invent `pnpm test:integration:docker` or treat these suites' default skips as Docker coverage. Containerizing the skill tests requires a reviewed ownership-proof design; do not weaken their native-cluster guard to make a container fit. Reuse existing application tests if a future task justifies a small runner, and test its isolation/failure/cleanup paths before enabling it. CI currently checks only agent workflow integrity; it provisions no database services. +There is no checked-in Compose stack or Docker integration runner. Do not invent `pnpm test:integration:docker` or treat these suites' default skips as Docker coverage. Containerizing the skill tests requires a reviewed ownership-proof design; do not weaken their native-cluster guard to make a container fit. Reuse existing application tests if a future task justifies a small runner, and test its isolation/failure/cleanup paths before enabling it. CI ([`ci.yml`](../../.github/workflows/ci.yml)) runs agent-workflow integrity, the migration-history and review-signal guards on PRs, and `pnpm lint`, `pnpm typecheck` and `node --test tests/*.test.mjs` (the same files as `pnpm test`) with no database, provider or app URL configured, so SQL/HTTP cases skip. It provisions no database services and runs no build. ### Safe Docker execution @@ -44,7 +44,9 @@ No Docker stack is required for this workflow migration, and no containers are s ```bash node scripts/sync-agent-skills.mjs --check -pnpm test:agent-workflow # Dependency-free sync regression tests; no app/service calls +node scripts/check-engineering-workflow.mjs +pnpm test:agent-workflow # Dependency-free sync and guard regression tests; no app/service calls +pnpm test # Every tests/*.test.mjs file; SQL/HTTP cases skip without their explicit targets pnpm typecheck # TypeScript 7 (`tsc`) pnpm typecheck:ts6 # TypeScript 6 (`tsc6`) — same tsconfig, for migration comparison pnpm lint @@ -108,7 +110,7 @@ git diff --check ## Link checks after doc moves -Search active docs for broken relative paths; ensure `docs/AGENT_START_HERE.md` map matches new files. UI work should match `docs/DESIGN_SYSTEM.md`. +Search active docs for broken relative paths; ensure the `docs/AGENT_START_HERE.md` reading matrix matches new files. UI work should match `docs/DESIGN_SYSTEM.md`. ## Provider drift audit diff --git a/eslint.config.mjs b/eslint.config.mjs index 5644f63..99ca94d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,9 @@ const eslintConfig = defineConfig([ 'out/**', 'build/**', 'next-env.d.ts', + // Task worktrees and scratch space are separate checkouts. + '.tmp/**', + '.claude/worktrees/**', // Pinned third-party skill examples are verified by sync-agent-skills.mjs. '.agents/skills/**', '.claude/skills/**', diff --git a/package.json b/package.json index 5014073..944ab62 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "typecheck:ts6": "tsc6 --noEmit", "test:cron": "node --test tests/cron-*.test.mjs", "test:theme": "node --test tests/theme.test.mjs", - "test:agent-workflow": "node --test tests/agent-skills-sync.test.mjs", + "test": "node --test tests/*.test.mjs", + "test:agent-workflow": "node --test tests/agent-skills-sync.test.mjs tests/check-pr-review-signals.test.mjs tests/check-migration-history.test.mjs tests/engineering-workflow-state.test.mjs", "test:skills": "node --test tests/skill-*.test.mjs", "skills": "tsx --env-file=.env scripts/skill-updates.ts", "skills:eval": "tsx scripts/evaluate-skill-updates.ts", diff --git a/scripts/check-engineering-workflow.mjs b/scripts/check-engineering-workflow.mjs new file mode 100644 index 0000000..1daeb96 --- /dev/null +++ b/scripts/check-engineering-workflow.mjs @@ -0,0 +1,81 @@ +// Dependency-free check for the locally authored, cross-host engineering-workflow skill. +import assert from 'node:assert/strict' +import { readFile, readdir, lstat } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const locations = ['.agents/skills/engineering-workflow', '.claude/skills/engineering-workflow'] +const expected = [ + 'SKILL.md', + 'references/acceptance.md', + 'references/correction.md', + 'references/planning.md', + 'references/review.md', + 'references/resume.md', +] +async function readTree(relative) { + const dir = join(root, relative) + assert(!(await lstat(dir)).isSymbolicLink(), `Skill directory must be real: ${relative}`) + const found = {} + async function visit(at, prefix = '') { + for (const entry of await readdir(at, { withFileTypes: true })) { + assert(!entry.isSymbolicLink(), `No skill symlink/junction: ${prefix}${entry.name}`) + const name = prefix + entry.name + if (entry.isDirectory()) await visit(join(at, entry.name), `${name}/`) + else { + assert(entry.isFile(), `Unsupported skill entry: ${name}`) + found[name] = (await readFile(join(at, entry.name), 'utf8')).replaceAll('\r\n', '\n') + } + } + } + await visit(dir) + assert.deepEqual(Object.keys(found).sort(), [...expected].sort(), `Unexpected files: ${relative}`) + return found +} +const [codex, claude] = await Promise.all(locations.map(readTree)) +assert.deepEqual(codex, claude, 'Codex and Claude workflow copies differ') +assert.match(codex['SKILL.md'], /^---\nname: engineering-workflow\ndescription: .+\n---/) +for (const file of expected.slice(1)) + assert(codex[file].trim().length > 100, `Empty reference: ${file}`) +for (const marker of [ + 'DESIGN_ONLY', + 'HUMAN_APPROVAL', + 'two rounds', + 'fresh', + 'No automatic commit', + 'resume.md', + 'engineering-workflow-state.mjs', +]) { + assert(codex['SKILL.md'].includes(marker), `Missing orchestration guard: ${marker}`) +} +const ledgerScript = ( + await readFile(join(root, 'scripts/engineering-workflow-state.mjs'), 'utf8') +).replaceAll('\r\n', '\n') +for (const marker of [ + 'sourceFingerprint', + 'BLOCKED_EVIDENCE_CHANGED', + 'correctionRoundsUsed', + 'rebaseline', +]) { + assert(ledgerScript.includes(marker), `Missing checkpoint/resume guard: ${marker}`) +} +// Other locally authored skills: one SKILL.md each, identical on both hosts. +for (const name of ['codex-review', 'ship-pr']) { + const [a, b] = await Promise.all( + ['.agents', '.claude'].map(async (host) => { + const dir = join(root, host, 'skills', name) + assert.deepEqual(await readdir(dir), ['SKILL.md'], `Unexpected files: ${host}/skills/${name}`) + return (await readFile(join(dir, 'SKILL.md'), 'utf8')).replaceAll('\r\n', '\n') + }), + ) + assert.equal(a, b, `Codex and Claude copies differ: ${name}`) + assert.match( + a, + new RegExp(`^---\nname: ${name}\ndescription: .+\n---`), + `Bad frontmatter: ${name}`, + ) +} +console.log( + 'engineering-workflow v1.1: 6 files mirrored; checkpoint/resume guards verified; codex-review and ship-pr mirrored', +) diff --git a/scripts/check-migration-history.mjs b/scripts/check-migration-history.mjs new file mode 100644 index 0000000..f1a8121 --- /dev/null +++ b/scripts/check-migration-history.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Append-only guard for drizzle/: existing SQL migrations and snapshots are +// byte-identical, and existing journal entries are an unchanged prefix of the new journal. +import { execFileSync } from 'node:child_process' +import { isDeepStrictEqual } from 'node:util' +import { fileURLToPath } from 'node:url' + +export const DIR = 'drizzle' +const JOURNAL = `${DIR}/meta/_journal.json` + +/** base/head: Map of repo-relative path -> file text. Returns a list of problems. */ +export function checkMigrationHistory(base, head) { + const problems = [] + for (const [path, text] of base) { + if (path === JOURNAL) continue + if (!head.has(path)) problems.push(`Deleted or renamed: ${path}`) + else if (head.get(path) !== text) problems.push(`Modified: ${path}`) + } + if (!base.has(JOURNAL)) return problems + if (!head.has(JOURNAL)) return [...problems, `Deleted: ${JOURNAL}`] + let before, after + try { + before = JSON.parse(base.get(JOURNAL)) + after = JSON.parse(head.get(JOURNAL)) + } catch { + return [...problems, `Unparseable: ${JOURNAL}`] + } + if (before.version !== after.version || before.dialect !== after.dialect) + problems.push('Journal version or dialect changed') + const old = before.entries ?? [], + next = after.entries ?? [] + old.forEach((entry, i) => { + if (!isDeepStrictEqual(entry, next[i])) + problems.push(`Journal entry ${i} (${entry.tag}) was changed, removed or reordered`) + }) + let latest = Math.max(0, ...old.map((entry) => entry.when ?? 0)) + for (let i = old.length; i < next.length; i++) { + const entry = next[i] + // drizzle-orm's migrator silently skips an entry whose `when` is not newer than the last applied one. + if (!(entry.when > latest)) + problems.push( + `New journal entry ${entry.tag} has when ${entry.when}, which must be greater than ${latest}`, + ) + latest = Math.max(latest, entry.when ?? 0) + if (entry.idx !== i) + problems.push(`New journal entry ${entry.tag} has idx ${entry.idx}, expected ${i}`) + if (!head.has(`${DIR}/${entry.tag}.sql`)) + problems.push(`New journal entry ${entry.tag} has no ${DIR}/${entry.tag}.sql`) + } + return problems +} + +function filesAt(ref) { + const git = (...args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 << 20 }) + const names = git('ls-tree', '-r', '--name-only', ref, '--', DIR).split('\n').filter(Boolean) + return new Map(names.map((name) => [name, git('show', `${ref}:${name}`)])) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [baseRef, headRef = 'HEAD'] = process.argv.slice(2) + if (!baseRef) { + console.error('Usage: node scripts/check-migration-history.mjs <base-ref> [head-ref]') + process.exit(2) + } + const problems = checkMigrationHistory(filesAt(baseRef), filesAt(headRef)) + if (problems.length) { + console.error('Existing migrations are append-only. Add a new forward migration instead:') + for (const p of problems) console.error(`- ${p}`) + process.exit(1) + } + console.log(`Migration history is append-only (${baseRef}..${headRef})`) +} diff --git a/scripts/check-pr-review-signals.mjs b/scripts/check-pr-review-signals.mjs new file mode 100644 index 0000000..2b5b100 --- /dev/null +++ b/scripts/check-pr-review-signals.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +// PR review signals: flags T2-area files so a human knows which PRs need a deep read, +// rejects focused or newly skipped tests, and lists removed test cases. +import { execFileSync } from 'node:child_process' +import { appendFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +// Paths whose changes are T2 (AGENTS.md#task-size) regardless of the size the PR claims. +export const T2_PATHS = [ + /^drizzle\//, + /^src\/db\/(schema|index|seed)\.ts$/, + /^src\/app\/api\/(auth|cron|digest|release-advice|webhooks)\//, + // Sign-in/sign-up pages pass the user-controlled return path into OAuth. + /^src\/app\/\(auth\)\//, + /^src\/app\/api\/releases\/[^/]+\/vote\//, + /^src\/app\/digest\/unsubscribe\//, + // Auth, ownership, cron auth, ingestion/lease recovery, AI spend and outbound notifications. + /^src\/lib\/(auth|auth-return-path|actions|cron-auth|execution-budget|release-ingestion|release-votes|github|ai|ai-resilience|digest|webhooks|skill-(ingestion|store|analysis|github))\.ts$/, + // Operator scripts that write production data or call paid providers. + /^scripts\/(skill-updates|skill-eval-budget|evaluate-skill-updates|backfill-release-intelligence)\.ts$/, + /^scripts\/verify-skill-neon\.mjs$/, + // Cron registration: adding a schedule activates a background job. + /^vercel\.json$/, +] +// CI, the guard and integrity scripts it runs, and the test-database ownership guard: weakening them removes +// protection from every later PR. They get a separate `guard-change` label (independent review), not T2 reading. +export const GUARD_PATHS = [ + /^\.github\/workflows\//, + /^scripts\/(check-migration-history|check-pr-review-signals|check-engineering-workflow|sync-agent-skills)\.mjs$/, + /^tests\/skill-test-database\.mjs$/, +] +const TEST_FILE = /(^|\/)tests?\/|\.(spec|test)\.[cm]?[jt]sx?$/ +const CODE_FILE = /\.[cm]?[jt]sx?$/ +// node:test focus: test.only/describe.only, test['only'], { only: true } or t.runOnly(true). +const FOCUS = + /\b(?:it|test|describe|suite)\.only\b|\[\s*['"]only['"]\s*\]|(?<![\w-])['"]?only['"]?\s*:\s*true\b|\brunOnly\s*\(\s*true/ +// node:test skips and todos (a failing todo test still exits 0): test.skip/test.todo, t.skip()/t.todo(), +// test['skip'], and the { skip } / { todo } options unless their value is the env gate below. +const SKIP_CALL = + /\b(?:it|test|describe|suite)\.(?:skip|todo)\b|\.\s*(?:skip|todo)\s*\(|\[\s*['"](?:skip|todo)['"]\s*\]/ +const SKIP_KEYS = /(?<![\w-])['"]?(?:skip|todo)['"]?\s*:/g +const ONLY_KEY_AT_END = /(?<![\w-])['"]?only['"]?\s*:\s*$/ +const SKIP_SHORTHAND = /[{,]\s*(?:skip|todo)\s*[,}]/ +// The repository's env gate, complete: `!<env var or identifier> && '<reason>'`, followed only by `,`, `}`, +// a comment or the end of the line. Its value may continue over the next one or two lines. +const GATE_HEAD = String.raw`!\s*(?!(?:true|false|null|undefined|NaN)\b)(?:process\.env\.[A-Za-z0-9_]+|[A-Za-z_$][\w$]*)\s*&&` +const REASON_TAIL = String.raw`(['"\`])(?:(?!\1).)*\1\s*(?:[,}].*|\/\/.*)?$` +const GATE = new RegExp(`^\\s*${GATE_HEAD}\\s*${REASON_TAIL}`) +const GATE_HEAD_ONLY = new RegExp(`^\\s*${GATE_HEAD}\\s*$`) +const GATE_REASON = new RegExp(`^\\s*${REASON_TAIL}`) +// A trailing `// allow-skip: <reason>` comment also allows a skip; added ones are listed for the human reviewer. +const ALLOW_SKIP = /\/\/\s*allow-skip:\s*(\S.*)$/ +export const skipReason = (line) => line.match(ALLOW_SKIP)?.[1].trim() ?? null + +/** Classifies consecutive lines of one test file: returns 'focus', 'skip' or null for each line. */ +function testLineScanner() { + // pending: what the next line must continue — a gate 'value', a gate 'reason', or an 'only' value. + let pending = null + // Returns the pending state after a skip/todo value, or 'skip' when the value is not the env gate. + const value = (text) => + text.trim() === '' + ? 'value' + : GATE.test(text) + ? null + : GATE_HEAD_ONLY.test(text) + ? 'reason' + : 'skip' + return { + reset: () => (pending = null), + scan(body) { + let ungated = false + const before = pending + pending = null + if (before === 'only' && /^\s*true\b/.test(body)) return 'focus' + if (before === 'value') { + const next = value(body) + if (next === 'skip') ungated = true + else pending = next + } else if (before === 'reason' && !GATE_REASON.test(body)) ungated = true + if (FOCUS.test(body)) return 'focus' + if (ONLY_KEY_AT_END.test(body)) pending = 'only' + // Every skip/todo option on the line is checked; its value runs to the end of the line. + for (const key of body.matchAll(SKIP_KEYS)) { + const next = value(body.slice(key.index + key[0].length)) + if (next === 'skip') ungated = true + else if (next) pending = next + } + if (ungated || SKIP_CALL.test(body) || SKIP_SHORTHAND.test(body)) return 'skip' + return null + }, + } +} + +const skipError = (file, body) => + `${file}: skipped or todo test without the "skip: !<condition> && '<reason>'" env gate or a "// allow-skip: <reason>" comment: ${body.trim()}` + +/** + * Whole-file check of a changed test file, so removing a gate line cannot leave an unconditional skip. + * Returns blocking errors and the allow-skip lines, which the caller compares with the base version. + */ +export function checkTestSource(file, text) { + const errors = [], + allowed = [] + if (!TEST_FILE.test(file) || !CODE_FILE.test(file)) return { errors, allowed } + const scanner = testLineScanner() + for (const body of text.split('\n')) { + const kind = scanner.scan(body) + if (kind === 'focus') errors.push(`${file}: focused test: ${body.trim()}`) + else if (kind === 'skip') { + const reason = skipReason(body) + if (reason) allowed.push({ file, reason, line: body.trim() }) + else errors.push(skipError(file, body)) + } + } + return { errors, allowed } +} +const ASSERTION = /\bexpect\(|\bassert(?:\.\w+)?\(/g +// Assertions that cannot fail. +const WEAK_MATCHER = /\bassert(?:\.ok)?\(\s*(?:true|1)\s*[,)]|\bassert\.doesNotThrow\(/ +const TITLE = /\b(?:it|test)\(\s*(['"`])(.+?)\1/ + +export function guardFiles(files) { + return files.filter((f) => GUARD_PATHS.some((re) => re.test(f))) +} + +export function t2Files(files) { + return files.filter((f) => T2_PATHS.some((re) => re.test(f))) +} + +/** diff: `git diff --unified=0` text. Returns { errors, removedTests, assertionSignals, skipsAdded }. */ +export function analyzeTestDiff(diff) { + const errors = [], + added = new Set(), + removed = [], + counts = new Map(), + weak = [], + skipsAdded = [] + const count = (file, key, body) => { + const c = counts.get(file) ?? { added: 0, removed: 0 } + c[key] += (body.match(ASSERTION) ?? []).length + counts.set(file, c) + } + let file = null, + oldFile = null + const scanner = testLineScanner() + for (const line of diff.split('\n')) { + if (line.startsWith('--- ')) { + oldFile = line.slice(4).replace(/^a\//, '') + continue + } + if (line.startsWith('+++ ')) { + // A deleted file's diff has `+++ /dev/null`; its removed tests still belong to the old path. + file = line.slice(4) === '/dev/null' ? oldFile : line.slice(4).replace(/^b\//, '') + scanner.reset() + continue + } + if (!file || !TEST_FILE.test(file)) continue + const body = line.slice(1) + if (line.startsWith('+')) { + const kind = CODE_FILE.test(file) ? scanner.scan(body) : null + if (kind === 'focus') errors.push(`${file}: focused test: ${body.trim()}`) + else if (kind === 'skip') { + const reason = skipReason(body) + if (reason) skipsAdded.push({ file, reason, line: body.trim() }) + else errors.push(skipError(file, body)) + } + if (WEAK_MATCHER.test(body)) weak.push({ file, line: body.trim() }) + count(file, 'added', body) + const t = body.match(TITLE) + if (t) added.add(t[2]) + continue + } + scanner.reset() + if (line.startsWith('-')) { + count(file, 'removed', body) + const t = body.match(TITLE) + if (t) removed.push({ file, title: t[2] }) + } + } + const assertionSignals = [ + ...[...counts] + .filter(([, c]) => c.removed > c.added) + .map( + ([file, c]) => + `${file}: ${c.removed - c.added} assertion(s) removed net (${c.removed} removed, ${c.added} added)`, + ), + ...weak.map((w) => `${w.file}: weak assertion added: ${w.line}`), + ] + return { + errors, + removedTests: removed.filter((r) => !added.has(r.title)), + assertionSignals, + skipsAdded, + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [base, head = 'HEAD'] = process.argv.slice(2) + if (!base) { + console.error('Usage: node scripts/check-pr-review-signals.mjs <base-ref> [head-ref]') + process.exit(2) + } + const git = (...a) => execFileSync('git', a, { encoding: 'utf8', maxBuffer: 256 << 20 }) + const files = git('diff', '--name-only', base, head).split('\n').filter(Boolean) + const t2 = t2Files(files), + guards = guardFiles(files) + const { + errors: diffErrors, + removedTests, + assertionSignals, + skipsAdded, + } = analyzeTestDiff(git('diff', '--unified=0', '--no-color', base, head)) + const show = (ref, file) => { + try { + return execFileSync('git', ['show', `${ref}:${file}`], { + encoding: 'utf8', + maxBuffer: 256 << 20, + stdio: ['ignore', 'pipe', 'ignore'], + }) + } catch { + return null // absent at that ref + } + } + const fileErrors = [] + for (const file of files.filter((f) => TEST_FILE.test(f) && CODE_FILE.test(f))) { + const text = show(head, file) + if (text === null) continue + const now = checkTestSource(file, text) + fileErrors.push(...now.errors) + // An allowed skip whose key line is unchanged never appears as an added line: list it from the file. + const before = checkTestSource(file, show(base, file) ?? '').allowed.map((s) => s.line) + for (const s of now.allowed) { + const i = before.indexOf(s.line) + if (i >= 0) before.splice(i, 1) + else if (!skipsAdded.some((a) => a.file === s.file && a.line === s.line)) skipsAdded.push(s) + } + } + const errors = [...new Set([...diffErrors, ...fileErrors])] + const lines = ['## Review signals', ''] + lines.push( + t2.length + ? `**T2 area touched** — read these files carefully:\n${t2.map((f) => `- \`${f}\``).join('\n')}` + : 'No T2-area files changed.', + ) + if (guards.length) + lines.push( + '', + `**CI or guard change** — needs an independent reviewer:\n${guards.map((f) => `- \`${f}\``).join('\n')}`, + ) + if (skipsAdded.length) + lines.push( + '', + `**Skips added** — approve each reason:\n${skipsAdded.map((s) => `- \`${s.file}\`: ${s.reason}`).join('\n')}`, + ) + if (removedTests.length) + lines.push( + '', + `**Removed test cases** — confirm each removal is intended:\n${removedTests.map((r) => `- \`${r.file}\`: ${r.title}`).join('\n')}`, + ) + if (assertionSignals.length) + lines.push( + '', + `**Assertion changes** — check that tests were not weakened:\n${assertionSignals.map((s) => `- ${s}`).join('\n')}`, + ) + if (errors.length) + lines.push('', `**Blocking test changes:**\n${errors.map((e) => `- ${e}`).join('\n')}`) + const report = lines.join('\n') + console.log(report) + if (process.env.GITHUB_STEP_SUMMARY) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, report + '\n') + if (process.env.GITHUB_OUTPUT) + appendFileSync( + process.env.GITHUB_OUTPUT, + `t2=${t2.length ? 'true' : 'false'}\nguard=${guards.length ? 'true' : 'false'}\n`, + ) + for (const r of removedTests) + console.log(`::warning file=${r.file}::Removed test case: ${r.title}`) + for (const s of assertionSignals) console.log(`::warning::${s}`) + for (const s of skipsAdded) console.log(`::warning file=${s.file}::Skip added: ${s.reason}`) + process.exit(errors.length ? 1 : 0) +} diff --git a/scripts/engineering-workflow-state.mjs b/scripts/engineering-workflow-state.mjs new file mode 100644 index 0000000..485fb9e --- /dev/null +++ b/scripts/engineering-workflow-state.mjs @@ -0,0 +1,552 @@ +#!/usr/bin/env node +// Local, dependency-free checkpoint/resume ledger. This is NOT an agent launcher +// or authorization mechanism; reviewer provenance and command results need external verification. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const workspace = join(repo, '.tmp', 'agent-workflows') +const MODES = new Set(['DESIGN_ONLY', 'MEDIUM', 'HIGH_RISK']) +const WRITE_STAGES = new Set(['PLAN', 'CORRECT_DESIGN', 'IMPLEMENT', 'CORRECT']) +const STATES = new Set([ + 'INSPECT', + 'PLAN', + 'PLAN_REVIEW', + 'CORRECT_DESIGN', + 'IMPLEMENT', + 'VERIFY', + 'CODE_REVIEW', + 'CORRECT', + 'FINAL_GATE', + 'HUMAN_APPROVAL', + 'BLOCKED', +]) +const gates = { + INSPECT: { READY: 'PLAN' }, + PLAN: { READY: 'PLAN_REVIEW' }, + PLAN_REVIEW: { PLAN_APPROVED: 'APPROVED_NEXT', PLAN_REQUEST_CHANGES: 'CORRECT_DESIGN' }, + CORRECT_DESIGN: { READY: 'PLAN_REVIEW' }, + IMPLEMENT: { READY: 'VERIFY' }, + VERIFY: { VERIFIED: 'CODE_REVIEW', FAILED: 'CORRECT' }, + CODE_REVIEW: { REVIEW_APPROVED: 'FINAL_GATE', REVIEW_REQUEST_CHANGES: 'CORRECT' }, + CORRECT: { READY: 'VERIFY' }, + FINAL_GATE: { PASSED: 'HUMAN_APPROVAL', BLOCKED: 'BLOCKED' }, +} +const [command, slug, ...raw] = process.argv.slice(2) +const json = (value) => JSON.stringify(value, null, 2) +function git(...args) { + return execFileSync('git', args, { + cwd: repo, + encoding: 'buffer', + stdio: ['ignore', 'pipe', 'pipe'], + }) +} +function gitText(...args) { + return git(...args) + .toString('utf8') + .trim() +} +function sha(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} +function field(hash, name, value) { + const b = Buffer.isBuffer(value) ? value : Buffer.from(String(value), 'utf8') + hash.update(Buffer.from(`${name}:${b.length}:`, 'utf8')) + hash.update(b) + hash.update(Buffer.from('\n')) +} +function sourceFingerprint() { + // HEAD + staged index + porcelain status + every nonignored tracked/untracked + // worktree file's bytes. Independent of mtime and ignored handoff artifacts. + const hash = createHash('sha256') + const head = gitText('rev-parse', 'HEAD') + field(hash, 'HEAD', head) + field(hash, 'INDEX', git('ls-files', '--stage', '-z')) + field(hash, 'STATUS', git('status', '--porcelain=v1', '-z', '--untracked-files=all')) + const names = git('ls-files', '--cached', '--others', '--exclude-standard', '-z') + .toString('utf8') + .split('\0') + .filter(Boolean) + .sort() + for (const name of names) { + field(hash, 'PATH', name) + const full = join(repo, name) + if (!existsSync(full)) { + field(hash, 'MISSING', '1') + continue + } + const st = lstatSync(full) + if (st.isSymbolicLink()) field(hash, 'SYMLINK', readlinkSync(full)) + else if (st.isFile()) field(hash, 'FILE', readFileSync(full)) + else if (st.isDirectory()) field(hash, 'SUBMODULE_OR_DIRECTORY', name) + else field(hash, 'SPECIAL', st.mode) + field(hash, 'MODE', st.mode & 0o111) + } + return { head, sha256: hash.digest('hex'), files: names.length } +} +function taskDir() { + assert( + slug && /^[a-z0-9][a-z0-9._-]{0,69}$/.test(slug) && !['.', '..'].includes(slug), + 'Task slug must be a safe lowercase identifier (max 70 chars)', + ) + return join(workspace, slug) +} +function assertIgnored(dir) { + assert( + gitText('rev-parse', '--show-toplevel').replaceAll('\\', '/').toLowerCase() === + repo.replaceAll('\\', '/').toLowerCase(), + 'Script must be located directly in the Git repository root/scripts', + ) + const rel = relative(repo, join(dir, 'state.json')).split(sep).join('/') + try { + git('check-ignore', '--quiet', '--', rel) + } catch { + throw new Error(`Refusing to store workflow state outside gitignored path: ${rel}`) + } +} +function stateFile(dir) { + return join(dir, 'state.json') +} +function load(dir) { + const p = stateFile(dir) + assert(existsSync(p), `Missing checkpoint ${p}; use init first`) + let state + try { + state = JSON.parse(readFileSync(p, 'utf8')) + } catch { + throw new Error( + 'Checkpoint JSON is damaged. Restore it; do not infer/auto-approve prior stages.', + ) + } + assert( + state.schema === 1 && + state.task === slug && + MODES.has(state.mode) && + STATES.has(state.stage) && + Number.isInteger(state.correctionRoundsUsed) && + state.correctionRoundsUsed >= 0 && + state.correctionRoundsUsed <= 2 && + Array.isArray(state.history) && + Array.isArray(state.artifacts), + 'Invalid checkpoint schema: fail closed', + ) + return state +} +function save(dir, state) { + state.updatedAt = new Date().toISOString() + const file = stateFile(dir), + tmp = join(dir, `state.${process.pid}.tmp`) + // Existing journal remains intact if write fails. Move atomically within same filesystem. + writeFileSync(tmp, json(state) + '\n', { flag: 'wx', mode: 0o600 }) + renameSync(tmp, file) +} +function options(argv) { + const opt = {} + for (let i = 0; i < argv.length; i += 2) { + const k = argv[i] + assert(k?.startsWith('--') && i + 1 < argv.length, `Expected --key value, got ${k}`) + assert(!Object.hasOwn(opt, k), `Duplicate option ${k}`) + opt[k] = argv[i + 1] + } + return opt +} +function evidence(dir, supplied) { + if (!supplied) return null + assert(!isAbsolute(supplied), 'Evidence path must be relative to the repository') + const full = resolve(repo, supplied), + r = relative(dir, full) + assert( + r && r !== '.' && !r.startsWith(`..${sep}`) && r !== '..' && !isAbsolute(r), + 'Evidence must be inside the ignored task workspace', + ) + assert( + existsSync(full) && lstatSync(full).isFile() && !lstatSync(full).isSymbolicLink(), + `Missing or unsafe evidence file ${supplied}`, + ) + const realDir = realpathSync(dir), + realFile = realpathSync(full), + rr = relative(realDir, realFile) + assert( + rr && rr !== '..' && !rr.startsWith(`..${sep}`) && !isAbsolute(rr), + 'Evidence symlinks may not escape task workspace', + ) + return { path: relative(repo, full).split(sep).join('/'), sha256: sha(readFileSync(full)) } +} +// Evidence a stage rewrites before re-recording it with the helper; reviewer reports are never refreshable. +const REFRESHABLE = { CORRECT_DESIGN: 'plan', CORRECT: 'verification', VERIFY: 'verification' } +function artifactProblems(state, dir, refreshableKind = null) { + const issues = [] + for (const a of state.artifacts) { + if (a.kind === refreshableKind) continue + try { + const now = evidence(dir, a.path) + if (now.sha256 !== a.sha256) issues.push(`Modified: ${a.path}`) + } catch { + issues.push(`Missing/unsafe: ${a.path}`) + } + } + return issues +} +function currentAssessment(state, dir) { + const now = sourceFingerprint() + const writing = WRITE_STAGES.has(state.stage) + const changed = now.sha256 !== state.revision.sha256 + const mismatched = artifactProblems(state, dir, REFRESHABLE[state.stage]) + const result = mismatched.length + ? 'BLOCKED_EVIDENCE_CHANGED' + : changed && !writing + ? 'BLOCKED_REVISION_CHANGED' + : changed && writing + ? 'IN_PROGRESS_EDIT' + : 'READY' + return { + result, + stage: state.stage, + revisionChanged: changed, + artifactProblems: mismatched, + correctionRoundsUsed: state.correctionRoundsUsed, + correctionBudgetRemaining: 2 - state.correctionRoundsUsed, + paused: state.paused ?? null, + base: state.base, + currentHead: now.head, + sourceFingerprint: now.sha256, + nextAction: + result === 'READY' || result === 'IN_PROGRESS_EDIT' + ? `Continue ${state.stage}; never infer a completed stage from an interruption` + : 'Stop, inspect and use explicitly reviewed rebaseline; approvals may not be reused', + } +} +function fail(message) { + throw new Error(message) +} +function parseEvidence(dir, opt, required = false) { + const a = evidence(dir, opt['--artifact']) + if (required) + assert( + a, + 'Stage requires --artifact <ignored task file>, containing independent or executable evidence', + ) + return a +} +function appendArtifact(state, a, kind) { + if (!a) return + const prior = state.artifacts.findIndex((x) => x.path === a.path) + if (prior >= 0) { + assert( + kind === 'plan' || kind === 'verification' || kind === 'final', + 'Reviewer findings must use a new immutable artifact filename per review attempt', + ) + state.artifacts.splice(prior, 1) + } + state.artifacts.push({ ...a, kind }) +} +function run() { + assert( + command && slug, + 'Usage: node scripts/engineering-workflow-state.mjs <init|resume|advance|pause|rebaseline> <task-slug> ...', + ) + const dir = taskDir() + assertIgnored(dir) + const lockFile = join(dir, '.checkpoint.lock') + const isWrite = ['init', 'advance', 'pause', 'rebaseline'].includes(command) + let locked = false + if (isWrite) { + mkdirSync(dir, { recursive: true }) + assert(!lstatSync(dir).isSymbolicLink(), 'Unsafe task workspace symlink') + try { + writeFileSync(lockFile, `${process.pid} ${new Date().toISOString()}\n`, { + flag: 'wx', + mode: 0o600, + }) + locked = true + } catch (e) { + if (e.code === 'EEXIST') + fail( + `Task checkpoint is locked; inspect ${lockFile} and verify no writer is running before manual cleanup`, + ) + throw e + } + } else if (command === 'resume' && existsSync(lockFile)) { + fail( + `A checkpoint writer is active or a stale lock remains at ${lockFile}; inspect before resuming`, + ) + } + try { + if (command === 'init') { + const [mode, ...rest] = raw + assert(MODES.has(mode), 'Usage: init <slug> DESIGN_ONLY|MEDIUM|HIGH_RISK') + assert(rest.length === 0, 'Unexpected arguments') + assert(!existsSync(stateFile(dir)), 'Checkpoint already exists; use resume, not init') + mkdirSync(dir, { recursive: true }) + const rev = sourceFingerprint() + const state = { + schema: 1, + task: slug, + mode, + base: rev.head, + stage: 'INSPECT', + revision: rev, + correctionRoundsUsed: 0, + artifacts: [], + approvals: { plan: null, code: null }, + verification: null, + paused: null, + history: [ + { + time: new Date().toISOString(), + stage: 'INSPECT', + event: 'INITIALIZED', + revision: rev.sha256, + }, + ], + } + save(dir, state) + console.log( + json({ + status: 'INITIALIZED', + task: slug, + mode, + stage: 'INSPECT', + sourceFingerprint: rev.sha256, + }), + ) + return + } + const state = load(dir) + if (command === 'resume') { + assert(raw.length === 0, 'resume takes no extra arguments') + console.log(json(currentAssessment(state, dir))) + return + } + if (command === 'pause') { + const [why, ...rest] = raw + assert( + ['USAGE_LIMIT', 'REVIEW_CAPACITY', 'PERMISSION', 'ENVIRONMENT', 'INTERRUPTED'].includes( + why, + ), + 'Invalid pause reason', + ) + assert(rest.length === 0, 'Unexpected pause arguments') + state.paused = { reason: why, at: new Date().toISOString(), stage: state.stage } + state.history.push({ time: state.paused.at, stage: state.stage, event: `PAUSED_${why}` }) + save(dir, state) + console.log(json({ status: 'PAUSED', stage: state.stage, reason: why })) + return + } + if (command === 'rebaseline') { + const [target, ...rest] = raw, + opt = options(rest) + assert(opt['--reason']?.trim().length >= 12, 'Explicit --reason (12+ chars) is required') + assert( + state.stage !== 'HUMAN_APPROVAL', + 'Human-approval gate requires fresh explicit human decision, not auto rebaseline', + ) + assert( + target === 'PLAN' || (state.mode !== 'DESIGN_ONLY' && target === 'VERIFY'), + 'Rebaseline must restart PLAN, or VERIFY with independently preserved plan approval', + ) + if (target === 'VERIFY') { + assert( + state.approvals.plan, + 'VERIFY rebaseline requires existing independently approved plan', + ) + assert( + state.artifacts.some((a) => a.kind === 'plan'), + 'VERIFY rebaseline requires an unchanged recorded plan artifact', + ) + } + const immutableReviewProblems = state.artifacts + .filter((a) => a.kind === 'review') + .filter((a) => { + try { + return evidence(dir, a.path).sha256 !== a.sha256 + } catch { + return true + } + }) + assert( + immutableReviewProblems.length === 0, + 'Prior reviewer artifact changed; restore original evidence before rebaseline', + ) + const now = sourceFingerprint() + state.stage = target + state.revision = now + state.paused = null + if (target === 'PLAN') state.approvals = { plan: null, code: null } + else state.approvals.code = null + state.verification = null + // Do not trust any stale artifact digest after an explicit rebaseline. + // Historical review evidence must still be intact; only updated plan/verification + // evidence is removed from the active snapshot, not deleted from disk. + state.artifacts = state.artifacts.filter( + (x) => x.kind === 'review' || (target === 'VERIFY' && x.kind === 'plan'), + ) + state.history.push({ + time: new Date().toISOString(), + stage: target, + event: 'EXPLICIT_REBASELINE', + reason: opt['--reason'], + revision: now.sha256, + }) + save(dir, state) + console.log( + json({ + status: 'REBASELINED_REAPPROVAL_REQUIRED', + stage: target, + correctionRoundsUsed: state.correctionRoundsUsed, + }), + ) + return + } + if (command === 'advance') { + const [verdict, ...rest] = raw, + opt = options(rest), + stage = state.stage + assert( + !['BLOCKED', 'HUMAN_APPROVAL'].includes(stage), + 'Cannot auto-advance a stopped/human-gated workflow', + ) + assert( + verdict && Object.hasOwn(gates[stage] ?? {}, verdict), + `Invalid ${stage} verdict ${verdict}`, + ) + const assessed = currentAssessment(state, dir) + assert( + ['READY', 'IN_PROGRESS_EDIT'].includes(assessed.result), + `Refuse advance: ${assessed.result}; use resume/rebaseline`, + ) + assert( + !assessed.revisionChanged || WRITE_STAGES.has(stage), + 'Unreviewed source revision change', + ) + const needsReview = stage === 'PLAN_REVIEW' || stage === 'CODE_REVIEW' + const requireArtifact = + needsReview || + stage === 'PLAN' || + stage === 'CORRECT_DESIGN' || + (stage === 'VERIFY' && verdict === 'VERIFIED') || + (stage === 'FINAL_GATE' && verdict === 'PASSED') + const a = parseEvidence(dir, opt, requireArtifact) + if (needsReview) { + assert( + opt['--reviewer-id'] && opt['--reviewer-id'] !== 'self', + 'Independent reviewer provenance required (--reviewer-id <fresh-agent/session-id>); must be verified externally', + ) + assert( + !state.history.some((h) => h.reviewer === opt['--reviewer-id']), + 'A fresh review cannot reuse a previously checkpointed reviewer/session ID', + ) + } + assert( + !opt['--reviewer-id'] || needsReview, + '--reviewer-id applies only to independent reviews', + ) + let next = gates[stage][verdict] + if (next === 'APPROVED_NEXT') next = state.mode === 'DESIGN_ONLY' ? 'FINAL_GATE' : 'IMPLEMENT' + if (['CORRECT_DESIGN', 'CORRECT'].includes(next)) { + if (state.correctionRoundsUsed === 2) + fail('Correction budget exhausted; stop and obtain human decision (do not reset counter)') + state.correctionRoundsUsed++ + } + const now = sourceFingerprint() + if (stage === 'PLAN' || stage === 'CORRECT_DESIGN') appendArtifact(state, a, 'plan') + else if (needsReview) appendArtifact(state, a, 'review') + else if (stage === 'VERIFY') { + appendArtifact(state, a, 'verification') + if (verdict === 'VERIFIED') state.verification = { revision: now.sha256, evidence: a } + } else if (stage === 'FINAL_GATE') appendArtifact(state, a, 'final') + else appendArtifact(state, a, 'handoff') + if (stage === 'PLAN_REVIEW') { + if (verdict === 'PLAN_APPROVED') + state.approvals.plan = { + revision: now.sha256, + reviewer: opt['--reviewer-id'], + evidence: a, + } + else state.approvals.plan = null + } + if (stage === 'CODE_REVIEW') { + if (verdict === 'REVIEW_APPROVED') + state.approvals.code = { + revision: now.sha256, + reviewer: opt['--reviewer-id'], + evidence: a, + } + else state.approvals.code = null + } + if (['IMPLEMENT', 'CORRECT'].includes(stage)) { + state.approvals.code = null + state.verification = null + } + if (stage === 'VERIFY' && verdict === 'FAILED') { + state.approvals.code = null + state.verification = null + } + if (stage === 'FINAL_GATE' && verdict === 'PASSED') { + if (state.mode === 'DESIGN_ONLY') + assert( + state.approvals.plan?.revision === now.sha256, + 'Design approval does not match source revision', + ) + else { + assert( + state.approvals.plan && state.approvals.code?.revision === now.sha256, + 'Missing code approval for current source revision', + ) + assert( + state.verification?.revision === now.sha256, + 'Mandatory verification not recorded for current revision', + ) + } + } + state.stage = next + state.revision = now + state.paused = null + state.history.push({ + time: new Date().toISOString(), + stage, + event: verdict, + next, + revision: now.sha256, + artifact: a?.path ?? null, + reviewer: opt['--reviewer-id'] ?? null, + correctionRoundsUsed: state.correctionRoundsUsed, + }) + save(dir, state) + console.log( + json({ + status: 'CHECKPOINT_SAVED', + completed: stage, + verdict, + next, + sourceFingerprint: now.sha256, + correctionRoundsUsed: state.correctionRoundsUsed, + }), + ) + return + } + fail(`Unknown command: ${command}`) + } finally { + if (locked) unlinkSync(lockFile) + } +} +try { + run() +} catch (e) { + console.error(`WORKFLOW_STATE_BLOCKED: ${e.message}`) + process.exitCode = 1 +} diff --git a/scripts/sync-agent-skills.mjs b/scripts/sync-agent-skills.mjs index 92d6755..6b63296 100644 --- a/scripts/sync-agent-skills.mjs +++ b/scripts/sync-agent-skills.mjs @@ -45,14 +45,6 @@ async function filesAt(directory, optional = false) { return files.sort() } -// Only the Codex entry point replaces Claude's Skill-tool invocation. -function claudeCopy(name, file, source) { - if (name !== 'grill-me' || file !== 'SKILL.md') return source - const codexCall = 'Read [../grilling/SKILL.md](../grilling/SKILL.md) and follow it.' - assert(source.includes(codexCall), 'Missing Grill Me adapter') - return source.replace(codexCall, 'Call the Skill tool with "grilling".') -} - for (const directory of ['.agents', '.agents/skills', '.claude', '.claude/skills']) { await realDirectory(join(root, directory), directory.startsWith('.claude')) } @@ -64,7 +56,10 @@ for (const skill of manifest.skills) { const source = join(root, '.agents/skills', skill.name) const target = join(root, '.claude/skills', skill.name) const files = Object.keys(skill.files).sort() - assert(files.includes('SKILL.md') && files.includes('LICENSE'), `Incomplete skill: ${skill.name}`) + assert( + files.includes('SKILL.md') && files.some((file) => /^LICENSE(\.txt)?$/.test(file)), + `Incomplete skill: ${skill.name}`, + ) for (const file of files) { assert( file.split('/').every((part) => /^[\w.-]+$/.test(part) && part !== '.' && part !== '..'), @@ -86,7 +81,7 @@ for (const skill of manifest.skills) { skill.files[file], `Source changed; review provenance and update its hash: ${skill.name}/${file}`, ) - copies.push([join(target, file), claudeCopy(skill.name, file, data)]) + copies.push([join(target, file), data]) } } for (const [destination, data] of copies) { diff --git a/tests/agent-skills-sync.test.mjs b/tests/agent-skills-sync.test.mjs index d719767..937235b 100644 --- a/tests/agent-skills-sync.test.mjs +++ b/tests/agent-skills-sync.test.mjs @@ -20,7 +20,9 @@ function fixture(t, crlf = false) { const directory = mkdtempSync(join(tmpdir(), 'stackpulse-agent-sync-')) t.after(() => rmSync(directory, { recursive: true, force: true })) const manifest = JSON.parse(readFileSync(join(root, '.agents/skill-sources.json'), 'utf8')) - manifest.skills = manifest.skills.filter((skill) => ['ponytail', 'grill-me'].includes(skill.name)) + manifest.skills = manifest.skills.filter((skill) => + ['ponytail', 'grilling', 'frontend-design'].includes(skill.name), + ) for (const skill of manifest.skills) { const source = join(root, '.agents/skills', skill.name) const target = join(directory, '.agents/skills', skill.name) @@ -67,7 +69,7 @@ function fixture(t, crlf = false) { } } -test('initial sync from a CRLF checkout creates full copies, adapts Grill Me and is repeatable', (t) => { +test('initial sync from a CRLF checkout creates identical full copies and is repeatable', (t) => { const f = fixture(t, true) assert.equal(f.run('--check').status, 1) assert.equal(existsSync(join(f.directory, '.claude')), false, '--check must not create files') @@ -77,15 +79,7 @@ test('initial sync from a CRLF checkout creates full copies, adapts Grill Me and for (const file of Object.keys(skill.files)) { const source = f.read(`.agents/skills/${skill.name}/${file}`).replaceAll('\r\n', '\n') const target = f.read(`.claude/skills/${skill.name}/${file}`) - assert.equal( - target, - skill.name === 'grill-me' && file === 'SKILL.md' - ? source.replace( - 'Read [../grilling/SKILL.md](../grilling/SKILL.md) and follow it.', - 'Call the Skill tool with "grilling".', - ) - : source, - ) + assert.equal(target, source) } } assert.equal(f.run().status, 0) @@ -98,7 +92,7 @@ test('check reports drift without changing it; sync repairs drift and missing fi f.write('.claude/skills/ponytail/SKILL.md', 'drift') assert.equal(f.run('--check').status, 1) assert.equal(f.read('.claude/skills/ponytail/SKILL.md'), 'drift') - rmSync(join(f.directory, '.claude/skills/grill-me/agents/openai.yaml')) + rmSync(join(f.directory, '.claude/skills/grilling/agents/openai.yaml')) assert.equal(f.run().status, 0) assert.equal(f.run('--check').status, 0) }) @@ -107,7 +101,7 @@ test('a changed source in a later package prevents ALL target writes', (t) => { const f = fixture(t) assert.equal(f.run().status, 0) f.write('.claude/skills/ponytail/SKILL.md', 'keep until all sources validate') - f.write('.agents/skills/grill-me/agents/openai.yaml', 'unreviewed edit') + f.write('.agents/skills/grilling/agents/openai.yaml', 'unreviewed edit') for (const args of [[], ['--check']]) { const result = f.run(...args) assert.equal(result.status, 1) @@ -126,14 +120,31 @@ test('unrecorded source resources and missing licenses fail integrity', (t) => { assert.equal(existsSync(join(f.directory, '.claude')), false) }) +test('a manifest entry without LICENSE or LICENSE.txt is incomplete', (t) => { + const f = fixture(t) + const skill = f.manifest.skills.find((s) => s.name === 'ponytail') + const license = skill.files.LICENSE + delete skill.files.LICENSE + for (const name of ['LICENSE-txt', 'LICENSExtxt']) { + skill.files[name] = license + f.write(`.agents/skills/ponytail/${name}`, f.read('.agents/skills/ponytail/LICENSE')) + } + rmSync(join(f.directory, '.agents/skills/ponytail/LICENSE')) + f.saveManifest() + const result = f.run() + assert.equal(result.status, 1) + assert.match(result.stderr, /Incomplete skill: ponytail/) + assert.equal(existsSync(join(f.directory, '.claude')), false) +}) + test('unexpected managed target files are preserved and never silently removed', (t) => { const f = fixture(t) assert.equal(f.run().status, 0) - f.write('.claude/skills/grill-me/custom.md', 'preserve me') + f.write('.claude/skills/grilling/custom.md', 'preserve me') const result = f.run() assert.equal(result.status, 1) assert.match(result.stderr, /Unexpected Claude files/) - assert.equal(f.read('.claude/skills/grill-me/custom.md'), 'preserve me') + assert.equal(f.read('.claude/skills/grilling/custom.md'), 'preserve me') }) test('unmanaged custom skills and existing Claude settings are untouched', (t) => { diff --git a/tests/check-migration-history.test.mjs b/tests/check-migration-history.test.mjs new file mode 100644 index 0000000..f33f96f --- /dev/null +++ b/tests/check-migration-history.test.mjs @@ -0,0 +1,109 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { checkMigrationHistory, DIR } from '../scripts/check-migration-history.mjs' + +const J = `${DIR}/meta/_journal.json` +const entry = (idx, tag) => ({ idx, version: '7', when: 1000 + idx, tag, breakpoints: true }) +const journal = (...entries) => JSON.stringify({ version: '7', dialect: 'postgresql', entries }) +const base = () => + new Map([ + [`${DIR}/0000_a.sql`, 'create table a();'], + [`${DIR}/0001_b.sql`, 'create table b();'], + [`${DIR}/meta/0000_snapshot.json`, '{"id":"0"}'], + [J, journal(entry(0, '0000_a'), entry(1, '0001_b'))], + ]) +const withChanges = (fn) => { + const head = base() + fn(head) + return checkMigrationHistory(base(), head) +} + +test('unchanged history passes', () => assert.deepEqual(checkMigrationHistory(base(), base()), [])) + +test('appending a migration, journal entry and snapshot passes', () => { + assert.deepEqual( + withChanges((h) => { + h.set(`${DIR}/0002_c.sql`, 'create table c();') + h.set(`${DIR}/meta/0002_snapshot.json`, '{"id":"2"}') + h.set(J, journal(entry(0, '0000_a'), entry(1, '0001_b'), entry(2, '0002_c'))) + }), + [], + ) +}) + +test('modified SQL migration fails', () => { + assert.match( + withChanges((h) => h.set(`${DIR}/0000_a.sql`, 'drop table a;')).join(), + /Modified: .*0000_a\.sql/, + ) +}) + +test('deleted SQL migration fails', () => { + assert.match( + withChanges((h) => h.delete(`${DIR}/0001_b.sql`)).join(), + /Deleted or renamed: .*0001_b\.sql/, + ) +}) + +test('renamed SQL migration fails', () => { + assert.match( + withChanges((h) => { + h.set(`${DIR}/0001_renamed.sql`, h.get(`${DIR}/0001_b.sql`)) + h.delete(`${DIR}/0001_b.sql`) + }).join(), + /Deleted or renamed/, + ) +}) + +test('modified or deleted snapshot fails', () => { + assert.match( + withChanges((h) => h.set(`${DIR}/meta/0000_snapshot.json`, '{"id":"x"}')).join(), + /Modified: .*0000_snapshot/, + ) + assert.match( + withChanges((h) => h.delete(`${DIR}/meta/0000_snapshot.json`)).join(), + /Deleted or renamed: .*0000_snapshot/, + ) +}) + +test('edited journal entry fails', () => { + assert.match( + withChanges((h) => + h.set(J, journal(entry(0, '0000_a'), { ...entry(1, '0001_b'), when: 1 })), + ).join(), + /entry 1 .*changed/, + ) +}) + +test('reordered journal entries fail', () => { + assert.match( + withChanges((h) => h.set(J, journal(entry(1, '0001_b'), entry(0, '0000_a')))).join(), + /reordered/, + ) +}) + +test('removed journal entry fails', () => { + assert.match( + withChanges((h) => h.set(J, journal(entry(0, '0000_a')))).join(), + /entry 1 .*removed/, + ) +}) + +test('appended journal entry needs its SQL file and next idx', () => { + const problems = withChanges((h) => + h.set(J, journal(entry(0, '0000_a'), entry(1, '0001_b'), entry(5, '0002_c'))), + ).join() + assert.match(problems, /idx 5, expected 2/) + assert.match(problems, /no .*0002_c\.sql/) +}) + +test('an appended journal entry must have a strictly newer when', () => { + const add = (when) => + withChanges((h) => { + h.set(`${DIR}/0002_c.sql`, 'create table c();') + h.set(J, journal(entry(0, '0000_a'), entry(1, '0001_b'), { ...entry(2, '0002_c'), when })) + }) + assert.deepEqual(add(5000), []) + assert.match(add(1001).join(), /when 1001, which must be greater than 1001/) + assert.match(add(900).join(), /when 900/) +}) diff --git a/tests/check-pr-review-signals.test.mjs b/tests/check-pr-review-signals.test.mjs new file mode 100644 index 0000000..d9b0d13 --- /dev/null +++ b/tests/check-pr-review-signals.test.mjs @@ -0,0 +1,260 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + analyzeTestDiff, + checkTestSource, + guardFiles, + skipReason, + t2Files, +} from '../scripts/check-pr-review-signals.mjs' + +// Keywords are split so this file never trips the guard itself. +const ONLY = 'test' + '.only', + SKIP = 'test' + '.skip', + SKIP_KEY = 'sk' + 'ip:', + TODO_KEY = 'to' + 'do:', + ONLY_KEY = 'on' + 'ly:', + ALLOW = '// allow-sk' + 'ip:', + WEAK = 'assert' + '.ok(true)' +const diff = (file, ...lines) => + [`--- a/${file}`, `+++ b/${file}`, '@@ -1 +1 @@', ...lines].join('\n') +const SPEC = 'tests/release-votes.test.mjs' +const root = fileURLToPath(new URL('../', import.meta.url)) +const clean = { errors: [], removedTests: [], assertionSignals: [], skipsAdded: [] } + +test('T2 paths are detected; others are not', () => { + const t2 = [ + 'drizzle/0011_x.sql', + 'drizzle/meta/_journal.json', + 'src/db/schema.ts', + 'src/lib/auth.ts', + 'src/lib/skill-store.ts', + 'src/app/api/cron/fetch-releases/route.ts', + 'src/app/api/releases/[releaseId]/vote/route.ts', + 'src/app/digest/unsubscribe/page.tsx', + 'src/app/(auth)/sign-in/page.tsx', + 'scripts/backfill-release-intelligence.ts', + 'scripts/evaluate-skill-updates.ts', + 'vercel.json', + ] + const other = [ + 'src/app/api/releases/route.ts', + 'src/app/page.tsx', + 'src/components/dashboard/release-feed.tsx', + 'src/lib/public-stacks.ts', + 'src/lib/skill-catalog.ts', + 'src/app/api/mcp/[transport]/route.ts', + 'docs/features/auth.md', + ] + assert.deepEqual(t2Files([...t2, ...other]), t2) +}) + +test('CI, guard and integrity scripts and the test-database ownership guard are guard changes, not T2', () => { + const files = [ + '.github/workflows/ci.yml', + 'scripts/check-migration-history.mjs', + 'scripts/check-pr-review-signals.mjs', + 'scripts/check-engineering-workflow.mjs', + 'scripts/sync-agent-skills.mjs', + 'tests/skill-test-database.mjs', + 'tests/release-votes-db.test.mjs', + 'package.json', + ] + assert.deepEqual(guardFiles(files), files.slice(0, 6)) + assert.deepEqual(t2Files(files), []) +}) + +test('ordinary new tests pass', () => { + assert.deepEqual( + analyzeTestDiff(diff(SPEC, "+test('counts one vote per user', () => { assert.equal(1, 1) })")), + clean, + ) +}) + +test('focused tests fail', () => { + for (const line of [ + `${ONLY}('x', () => {})`, + `test('x', { ${ONLY_KEY} true }, () => {})`, + "test['on" + "ly']('x', () => {})", + ' t.run' + 'Only(true)', + ]) { + assert.match(analyzeTestDiff(diff(SPEC, `+${line}`)).errors.join(), /focused/, line) + } + const split = diff(SPEC, `+test('x', { ${ONLY_KEY}`, '+ true }, () => {})') + assert.match(analyzeTestDiff(split).errors.join(), /focused/) +}) + +test('env-gated skips pass silently, on one line or split across lines', () => { + for (const lines of [ + [`+ { ${SKIP_KEY} !enabled && 'Set VOTE_TEST_DATABASE_URL' },`], + [ + `+ ${SKIP_KEY}`, + '+ !process.env.SKILL_TEST_DATABASE_URL &&', + "+ 'Requires an owned cluster',", + ], + ]) + assert.deepEqual(analyzeTestDiff(diff(SPEC, ...lines)), clean, lines.join()) +}) + +test('a skip with an allow-skip comment passes but is listed for the human', () => { + const r = analyzeTestDiff(diff(SPEC, `+${SKIP}('x', () => {}) ${ALLOW} GitHub API outage`)) + assert.deepEqual(r.errors, []) + assert.deepEqual( + r.skipsAdded.map((s) => s.reason), + ['GitHub API outage'], + ) +}) + +test('unconditional, todo or disguised skips fail', () => { + for (const lines of [ + [`+${SKIP}('x', () => {})`], + [`+${SKIP}('x', () => {}) // process.env`], + [`+test('x', { ${SKIP_KEY} true }, () => {})`], + [`+test('x', { ${SKIP_KEY} 'later' }, () => {})`], + [`+test('x', { ${TODO_KEY} true }, () => {})`], + [`+test('x', { ${TODO_KEY} 'later' }, () => {})`], + [`+ ${SKIP_KEY}`, "+ 'not gated',"], + [`+ { ${SKIP_KEY} !false && 'r' },`], + [`+ { ${SKIP_KEY} !0 && 'r' },`], + [`+ { ${SKIP_KEY} !enabled || 'r' },`], + [`+ { ${SKIP_KEY} 1 },`], + [`+ { ${SKIP_KEY} REASON },`], + [`+ { ${SKIP_KEY} (true) },`], + [`+ { ${SKIP_KEY} !enabled && 'r' || true },`], + [`+ { ${SKIP_KEY} !enabled && 'r', ${TODO_KEY} true },`], + [`+ ${SKIP_KEY}`, '+ !enabled &&', "+ 'r' || true,"], + ["+test('x', { 'sk" + "ip': true }, () => {})"], + ["+test('x', { sk" + 'ip }, () => {})'], + ["+test['sk" + "ip']('x', () => {})"], + ['+ t' + '.sk' + 'ip()'], + ['+ context' + '.sk' + 'ip()'], + ['+test' + '.to' + "do('x')"], + [`+${SKIP}('x', () => {}) ${ALLOW}`], + [`+${SKIP}('x', () => {}) ${ALLOW} `], + [`+${SKIP}('allow-skip: this is a title, not a comment', () => {})`], + ]) { + assert.equal(skipReason(lines.at(-1).slice(1)), null, lines.join()) + assert.match(analyzeTestDiff(diff(SPEC, ...lines)).errors.join(), /skipped/, lines.join()) + } +}) + +test('the whole-file check catches a skip made unconditional by deleting its gate line', () => { + const gated = [ + "test('x', {", + ` ${SKIP_KEY}`, + ' !process.env.SKILL_TEST_DATABASE_URL &&', + " 'Requires an owned cluster',", + '}, fn)', + ] + assert.deepEqual(checkTestSource(SPEC, gated.join('\n')), { errors: [], allowed: [] }) + // A `--unified=0` diff of this change contains only the removed gate line, which adds nothing. + assert.deepEqual(analyzeTestDiff(diff(SPEC, `-${gated[2]}`)).errors, []) + const ungated = gated.filter((_, i) => i !== 2) + assert.match(checkTestSource(SPEC, ungated.join('\n')).errors.join(), /skipped/) + // Justified with allow-skip on the reason line instead: allowed, and reported for approval. + ungated[2] += ` ${ALLOW} local PostgreSQL unavailable` + const justified = checkTestSource(SPEC, ungated.join('\n')) + assert.deepEqual(justified.errors, []) + assert.deepEqual( + justified.allowed.map((s) => s.reason), + ['local PostgreSQL unavailable'], + ) + assert.deepEqual(checkTestSource('tests/fixtures/x.json', `{ "${SKIP_KEY} true }`).errors, []) + assert.deepEqual(checkTestSource('src/lib/x.ts', `${SKIP}('x')`).errors, []) +}) + +test('the CLI blocks a removed gate and lists a newly justified skip whose key line is unchanged', (t) => { + const directory = mkdtempSync(join(tmpdir(), 'stackpulse-review-signals-')) + t.after(() => rmSync(directory, { recursive: true, force: true })) + const git = (...args) => + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { + cwd: directory, + encoding: 'utf8', + }).trim() + const file = join(directory, 'tests/x.test.mjs') + const lines = [ + "test('x', {", + ` ${SKIP_KEY}`, + ' !process.env.SKILL_TEST_DATABASE_URL &&', + " 'Requires an owned cluster',", + '}, fn)', + ] + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, lines.join('\n')) + git('init', '-q') + git('add', '-A') + git('commit', '-qm', 'base') + const base = git('rev-parse', 'HEAD') + const run = () => + spawnSync(process.execPath, [join(root, 'scripts/check-pr-review-signals.mjs'), base, 'HEAD'], { + cwd: directory, + encoding: 'utf8', + windowsHide: true, + }) + writeFileSync(file, lines.filter((_, i) => i !== 2).join('\n')) + git('commit', '-qam', 'remove gate') + const blocked = run() + assert.equal(blocked.status, 1, blocked.stdout) + assert.match(blocked.stdout, /Blocking test changes[\s\S]*Requires an owned cluster/) + const justified = lines.filter((_, i) => i !== 2) + justified[2] += ` ${ALLOW} local PostgreSQL unavailable` + writeFileSync(file, justified.join('\n')) + git('commit', '-qam', 'justify skip') + const listed = run() + assert.equal(listed.status, 0, listed.stdout) + assert.match(listed.stdout, /Skips added[\s\S]*local PostgreSQL unavailable/) +}) + +test('a deleted test file lists its removed tests and assertions', () => { + const file = 'tests/release-votes-db.test.mjs' + const r = analyzeTestDiff( + [ + `--- a/${file}`, + '+++ /dev/null', + '@@ -1,2 +0,0 @@', + "-test('rejects cross-origin votes', () => {", + '- assert.equal(status, 403)', + ].join('\n'), + ) + assert.deepEqual(r.removedTests, [{ file, title: 'rejects cross-origin votes' }]) + assert.match(r.assertionSignals.join(), /1 assertion\(s\) removed net/) +}) + +test('net assertion removal and weak assertions are surfaced, not blocking', () => { + const r = analyzeTestDiff( + diff( + SPEC, + '- assert.equal(result.status, 403)', + '- assert.match(body.error, /cross-origin/)', + `+ ${WEAK}`, + ), + ) + assert.deepEqual(r.errors, []) + assert.match(r.assertionSignals.join('\n'), /1 assertion\(s\) removed net \(2 removed, 1 added\)/) + assert.match(r.assertionSignals.join('\n'), /weak assertion added: assert\.ok\(true\)/) + assert.deepEqual( + analyzeTestDiff(diff(SPEC, '- assert.equal(a, 1)', '+ assert.equal(a, 2)')).assertionSignals, + [], + ) +}) + +test('skips outside test files are ignored', () => { + assert.deepEqual(analyzeTestDiff(diff('src/lib/release-votes.ts', `+${SKIP}('x')`)).errors, []) +}) + +test('removed test cases are listed, renamed-in-place ones are not', () => { + const r = analyzeTestDiff( + diff( + SPEC, + "-test('rejects cross-origin votes', () => {})", + "-test('kept title', () => {})", + "+test('kept title', () => { /* edited */ })", + ), + ) + assert.deepEqual(r.removedTests, [{ file: SPEC, title: 'rejects cross-origin votes' }]) +}) diff --git a/tests/engineering-workflow-state.test.mjs b/tests/engineering-workflow-state.test.mjs new file mode 100644 index 0000000..4701320 --- /dev/null +++ b/tests/engineering-workflow-state.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const slug = 'fixture-task' +const task = `.tmp/agent-workflows/${slug}` + +function fixture(t) { + // Long-form path: the helper compares its location with git's toplevel (8.3 names differ on Windows). + const directory = realpathSync.native(mkdtempSync(join(tmpdir(), 'stackpulse-workflow-state-'))) + t.after(() => rmSync(directory, { recursive: true, force: true })) + const git = (...args) => { + const r = spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { + cwd: directory, + encoding: 'utf8', + }) + assert.equal(r.status, 0, r.stderr) + } + git('init', '-q') + mkdirSync(join(directory, 'scripts')) + cpSync( + join(root, 'scripts/engineering-workflow-state.mjs'), + join(directory, 'scripts/engineering-workflow-state.mjs'), + ) + writeFileSync(join(directory, '.gitignore'), '.tmp/\n') + writeFileSync(join(directory, 'app.txt'), 'v1\n') + git('add', '-A') + git('commit', '-qm', 'init') + const write = (name, data) => { + mkdirSync(join(directory, task), { recursive: true }) + writeFileSync(join(directory, task, name), data) + } + const run = (...args) => + spawnSync(process.execPath, ['scripts/engineering-workflow-state.mjs', ...args], { + cwd: directory, + encoding: 'utf8', + windowsHide: true, + timeout: 20000, + }) + const ok = (...args) => { + const r = run(...args) + assert.equal(r.status, 0, `${args.join(' ')}\n${r.stdout}\n${r.stderr}`) + return r + } + return { directory, write, run, ok } +} + +test('re-verification after a correction may record refreshed verification evidence', (t) => { + const f = fixture(t) + f.ok('init', slug, 'MEDIUM') + f.ok('advance', slug, 'READY') + f.write('plan.md', 'plan') + f.ok('advance', slug, 'READY', '--artifact', `${task}/plan.md`) + f.write('plan-review-1.md', 'approved') + f.ok( + 'advance', + slug, + 'PLAN_APPROVED', + '--artifact', + `${task}/plan-review-1.md`, + '--reviewer-id', + 'r1', + ) + writeFileSync(join(f.directory, 'app.txt'), 'v2\n') + f.ok('advance', slug, 'READY') + f.write('verification.json', '{"run":1}') + f.ok('advance', slug, 'VERIFIED', '--artifact', `${task}/verification.json`) + f.write('code-review-1.md', 'changes requested') + f.ok( + 'advance', + slug, + 'REVIEW_REQUEST_CHANGES', + '--artifact', + `${task}/code-review-1.md`, + '--reviewer-id', + 'r2', + ) + writeFileSync(join(f.directory, 'app.txt'), 'v3\n') + f.ok('advance', slug, 'READY') + // VERIFY again: the prescribed verification.json is rewritten with the new run's evidence. + f.write('verification.json', '{"run":2}') + f.ok('advance', slug, 'VERIFIED', '--artifact', `${task}/verification.json`) + // Reviewer reports stay immutable. + f.write('code-review-1.md', 'edited after the fact') + const resumed = JSON.parse(f.ok('resume', slug).stdout) + assert.equal(resumed.result, 'BLOCKED_EVIDENCE_CHANGED') + assert.deepEqual(resumed.artifactProblems, [`Modified: ${task}/code-review-1.md`]) +}) diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..6d481ca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", ".tmp", ".claude/worktrees"] }