diff --git a/.cursor/rules/interface-design.mdc b/.agents/skills/interface-design/SKILL.md similarity index 97% rename from .cursor/rules/interface-design.mdc rename to .agents/skills/interface-design/SKILL.md index 1bba746349f5..61e3ebad4c2c 100644 --- a/.cursor/rules/interface-design.mdc +++ b/.agents/skills/interface-design/SKILL.md @@ -1,6 +1,6 @@ --- -description: Interface design principles for package APIs — where configuration, behavior, and state belong across schema, endpoint, and hook layers -alwaysApply: false +name: interface-design +description: Interface design principles for package APIs — where configuration, behavior, and state belong across schema, endpoint, and hook layers. Use when adding or changing options, behavior, or state in packages/*. --- # Interface Design diff --git a/.claude/hooks/worktree-setup.js b/.claude/hooks/worktree-setup.js new file mode 100644 index 000000000000..2708385f17cd --- /dev/null +++ b/.claude/hooks/worktree-setup.js @@ -0,0 +1,37 @@ +/* global require */ +// Claude Code twin of Cursor's `.cursor/worktrees.json`: when a session starts +// in a fresh git worktree (`claude --worktree`), runs its `setup-worktree` +// commands. Anywhere else this is one stat per session start. +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); +// written only once every command succeeds, so a failed setup reruns +const done = path.join(projectDir, 'node_modules/.worktree-setup-done'); +// a linked worktree has a `.git` file; the main checkout has a directory +const isWorktree = fs + .statSync(path.join(projectDir, '.git'), { + throwIfNoEntry: false, + }) + ?.isFile(); +if (!isWorktree || fs.existsSync(done)) process.exit(0); + +const commands = + JSON.parse( + fs.readFileSync(path.join(projectDir, '.cursor/worktrees.json'), 'utf8'), + )['setup-worktree'] ?? []; +for (const command of commands) { + try { + execSync(command, { cwd: projectDir, stdio: ['ignore', 'ignore', 'pipe'] }); + } catch (err) { + // stdout becomes session context, so the agent knows setup is incomplete + console.log( + `Worktree setup failed at \`${command}\`:\n${String(err.stderr).slice(-2000)}`, + ); + process.exit(0); + } +} +fs.mkdirSync(path.dirname(done), { recursive: true }); +fs.writeFileSync(done, ''); +console.log(`Worktree set up: ${commands.join(' && ')}`); diff --git a/.claude/rules/agents-md-authoring.md b/.claude/rules/agents-md-authoring.md new file mode 100644 index 000000000000..9edc36a7ded5 --- /dev/null +++ b/.claude/rules/agents-md-authoring.md @@ -0,0 +1,40 @@ +--- +paths: + - "**/AGENTS.md" +--- + + + +# Authoring AGENTS.md + +AGENTS.md is read into context every time an agent works in the package/directory. Every line costs tokens and competes with the conversation. Treat it like a SKILL.md. + +## Default assumption + +The agent is smart and will read the source. Only include what it cannot derive in seconds from `ls`, `Read`, or running tests. + +## Include + +- **Hard correctness constraints** consumers depend on (referential equality, identity-keyed caches, storage-shape-as-API). One line of statement + one line of consequence. +- **Non-obvious gotchas** with concrete numbers (e.g. "adding an optional field to `EntityPath` regressed `getSmallResponse` 5–10%"). +- **APIs the agent must call** that aren't discoverable from types alone — pointer to function + one-line contract. +- **Workflow specifics** the agent will get wrong without help (test methodology, benchmark thermal-noise rules, build-artifact ordering). + +## Exclude + +- **Architecture overviews / file maps** — `Glob` and `Read` cover this. +- **Anything in a parent AGENTS.md** — root `AGENTS.md` is always loaded; do not restate Jest project names, build commands, file naming conventions, monorepo structure, etc. Reference the parent only when adding a *consequence* not in the parent. +- **Verbose motivation paragraphs** — state the constraint, not why constraints exist in general. +- **Code blocks that the agent can write itself** from a one-line rule. +- **Time-sensitive notes** ("as of 2024…", "the new design will…"). + +## Format + +- Telegraphic bullets and short sentences. No preamble. +- Group by concern (Correctness / Performance / Workflow), not by file. +- Backtick file paths and API names. +- Target ≤100 lines for most packages; larger only with strong justification. + +## Verification + +Before saving, scan the parent (and root) `AGENTS.md` and remove any duplicate facts. If a section reads like a tutorial or general explanation, cut it. diff --git a/.claude/rules/benchmarking.md b/.claude/rules/benchmarking.md new file mode 100644 index 000000000000..cc067a742142 --- /dev/null +++ b/.claude/rules/benchmarking.md @@ -0,0 +1,318 @@ +--- +paths: + - "examples/benchmark/**" + - "examples/benchmark-react/**" + - ".github/workflows/benchmark.yml" + - ".github/workflows/benchmark-react.yml" + - "packages/normalizr/src/**" + - "packages/core/src/**" + - "packages/endpoint/src/schemas/**" + - "packages/react/src/**" +--- + + + +# Benchmarking + +## Node benchmark (`@examples/benchmark`) + +When working on performance investigations or changes that might impact **core, normalizr, or endpoint** (no browser, no React), use **`@examples/benchmark`** as the canonical harness. + +## React benchmark (`@examples/benchmark-react`) + +When working on **`packages/react`** or comparing data-client to other React data libraries (TanStack Query, SWR), use **`@examples/benchmark-react`**. + +- **Where it lives**: `examples/benchmark-react/` +- **How to run**: From repo root: `yarn build:benchmark-react`, then `yarn workspace example-benchmark-react preview &` and in another terminal `cd examples/benchmark-react && yarn bench` +- **What it measures**: Browser-based init/update duration, ref-stability counts, sorted-view (Query memoization), optional memory (heap delta), startup metrics (FCP/TBT), and React Profiler commit times. Compares data-client, TanStack Query, and SWR. +- **CI**: `.github/workflows/benchmark-react.yml` runs on changes to `packages/react/src/**`, `packages/core/src/**`, `packages/endpoint/src/schemas/**`, `packages/normalizr/src/**`, or `examples/benchmark-react/**` and reports via `rhysd/github-action-benchmark` (customBiggerIsBetter). CI runs **data-client only** (hot-path scenarios) to track regressions; competitor libraries (TanStack Query, SWR) are for local comparison only. +- **Report viewer**: Open `examples/benchmark-react/bench/report-viewer.html` in a browser and paste `react-bench-output.json` to view a comparison table and charts. Toggle "React commit" and "Trace" filters. Use "Load history" for time-series. + +See `@examples/benchmark-react/README.md` for methodology, adding a new library, and interpreting results. + +### Scenarios and what they exercise + +Use this mapping when deciding which React benchmark scenarios are relevant to a change: + +- **Get list scenarios** (`getlist-100`, `getlist-500`) + - Exercises: full fetch + normalization + render pipeline (ListView auto-fetches from list endpoint) + - Relevant for: `@data-client/react` hooks, `@data-client/core` store initialization + - All libraries + +- **Update propagation** (`update-entity`, `update-user`, `update-user-10000`) + - Exercises: store update → React rerender → DOM mutation + - Relevant for: `@data-client/core` dispatch/reducer, `@data-client/react` subscription/selector + - All libraries (normalization advantage shows with shared user at scale) + +- **Ref-stability** (`ref-stability-issue-changed`, `ref-stability-user-changed`) + - Exercises: referential equality preservation through normalization + - Relevant for: `@data-client/normalizr` denormalize memoization, Entity identity + - All libraries (data-client should show fewest changed refs) + +- **Multi-view entity update** (`update-entity-multi-view`) + - Exercises: cross-query entity propagation — one update to a shared entity reflected in list, detail panel, and pinned cards + - Relevant for: `@data-client/normalizr` normalized cache, `@data-client/core` subscription fan-out + - All libraries (normalization advantage: one store write vs. multiple query invalidations + refetches) + +- **Sorted/derived view** (`getlist-500-sorted`, `update-entity-sorted`) + - Exercises: `Query` schema memoization via `useQuery` (data-client) vs `useMemo` sort (competitors) + - Relevant for: `@data-client/endpoint` Query, `@data-client/normalizr` MemoCache, `@data-client/react` useQuery + - All libraries + +- **Optimistic update** (`optimistic-update`) — data-client only + - Exercises: `getOptimisticResponse` + `controller.fetch` pipeline + - Relevant for: `@data-client/core` optimistic dispatch + +- **Invalidation** (`invalidate-and-resolve`) — data-client only + - Exercises: `controller.invalidate` → Suspense fallback → `controller.setResponse` re-resolve + - Relevant for: `@data-client/core` invalidation, `@data-client/react` Suspense integration + +### Expected variance + +| Category | Scenarios | Typical run-to-run spread | +|---|---|---| +| **Stable** | `getlist-*`, `update-entity`, `ref-stability-*` | <2% | +| **Moderate** | `update-user-*`, `update-entity-sorted`, `update-entity-multi-view`, `move-item` | 2–4% | +| **Volatile** | `memory-mount-unmount-cycle`, `startup-*`, `(react commit)` suffixes | 5–15% | + +CI convergence targets: 2% (small scenarios), 3% (large scenarios). Reported margins should not exceed 5%. Regressions >5% on stable scenarios or >10% on moderate scenarios are worth investigating. + +### Profiling / tracing (opt + deopt investigation) + +The React benchmark supports the same V8 opt/deopt investigation as the Node benchmark, via Chromium's `--js-flags`: + +- **`bench:trace`** (`BENCH_V8_TRACE=true`): launches Chromium with `--trace-opt --trace-deopt`; browser process output piped to `v8-trace.log`. Equivalent to `examples/benchmark`'s `start:trace`. +- **`bench:deopt`** (`BENCH_V8_DEOPT=true`): launches Chromium with `--prof`; V8 writes per-process logs to `v8-logs/v8-.log`. Process the renderer log (largest file) with `node --prof-process`. + +Both default to `--lib data-client --size small` for focused runs. Override with additional flags: + +```bash +yarn workspace example-benchmark-react bench:trace +yarn workspace example-benchmark-react bench:deopt +BENCH_V8_TRACE=true yarn workspace example-benchmark-react bench --scenario update-entity +``` + +### When to use Node vs React benchmark + +- **Core/normalizr/endpoint changes only** (no rendering impact): Run `examples/benchmark` (Node). Faster iteration, no browser needed. +- **React hook or Provider changes**: Run `examples/benchmark-react`. Captures real rendering cost. +- **Schema changes** (Entity, Query, All): Run both — Node benchmark for raw throughput, React benchmark for rendering impact. +- **Performance investigation**: Start with Node benchmark to isolate the JS layer, then validate with React benchmark for end-to-end confirmation. + +--- + +# Node benchmark details (`@examples/benchmark`) + +## Optimization workflow + +Before implementing any performance optimization: + +1. **Create an isolated microbenchmark** in `@examples/benchmark/micro.js` that targets the specific operation being optimized +2. **Run the microbenchmark** to establish a baseline measurement +3. **Implement the optimization** +4. **Re-run the microbenchmark** to validate the improvement in isolation +5. **Run relevant suite benchmarks** (`normalizr`, `core`, etc.) to confirm no regressions +6. **Analyze bundlesize impact** (see below) + +This workflow ensures optimizations are validated in isolation before measuring their effect on the broader system. + +### Microbenchmarks (`micro` suite) + +Use `@examples/benchmark/micro.js` to add isolated benchmarks that: +- Test a single function or code path with minimal setup +- Compare different implementation approaches side-by-side +- Measure specific optimizations before integrating into larger suites + +```bash +yarn workspace example-benchmark start micro [filter] +``` + +Microbenchmarks should be small and focused. Once an optimization is validated, consider whether it warrants a permanent benchmark in one of the main suites. + +### Bundlesize impact analysis + +Every optimization must include a bundlesize impact assessment. Run from repo root: + +```bash +yarn ci:build:bundlesize +``` + +This builds all packages and generates size comparison data. Document the bundlesize delta in your PR: +- **Acceptable**: Size-neutral or size-reducing optimizations +- **Requires justification**: Any size increase must be justified by measurable performance gains +- **Rule of thumb**: A 1KB increase should yield at least 5-10% improvement on relevant benchmarks + +## Where to look + +- **Entry point / suite selection**: `@examples/benchmark/index.js` +- **Suite implementations**: + - `@examples/benchmark/micro.js` (isolated microbenchmarks) + - `@examples/benchmark/entity.js` + - `@examples/benchmark/normalizr.js` + - `@examples/benchmark/core.js` + - `@examples/benchmark/spread.js` (degenerate-case store-size-scaling writes; scenarios in `@examples/benchmark/spread-scenarios.js`) + - `@examples/benchmark/old-normalizr/normalizr.js` +- **Memory pressure script**: `@examples/benchmark/memory.js` (allocation rate, GC churn, retained heap for the spread scenarios) +- **Schemas/data used by multiple suites**: + - `@examples/benchmark/schemas.js` + - `@examples/benchmark/data.json` + - `@examples/benchmark/user.json` +- **CI benchmark runners**: `@.github/workflows/benchmark.yml` (default suites), `@.github/workflows/benchmark-spread.yml` (single spread case, store-write triggers only) + +## How to run + +From repo root: + +```bash +yarn build:benchmark +yarn workspace example-benchmark start [suite-name] [filter] +``` + +From `examples/benchmark/`: + +```bash +yarn start [suite-name] [filter] +``` + +Both arguments are optional: +- **No arguments**: runs `normalizr` + `core` suites with all benchmarks +- **Suite only**: `yarn start normalizr` runs all benchmarks in that suite +- **Suite + filter**: `yarn start normalizr denormalize` runs only benchmarks containing "denormalize" + +Filter syntax: +- `text` → substring match (contains "text") +- `^text` → starts with "text" + +**When benchmarking specific changes, use filters to focus on relevant benchmarks** (see "Suites and what they exercise" below for recommended filters): + +Examples: +- `yarn start normalizr "^normalize"` → only "normalizeLong" (not denormalize*) +- `yarn start normalizr "^denormalize"` → all denormalize* benchmarks +- `yarn start core "^set"` → all setResponse* benchmarks (setLong, setSmallResponse, etc.) +- `yarn start core "^get"` → all getResponse/get benchmarks (getResponse, getSmallResponse, get Collection, etc.) +- `yarn start normalizr withCache` → benchmarks containing "withCache" + +## Profiling / tracing (opt + deopt investigation) + +When you need to go beyond “is it faster/slower?” and understand **why** (V8 optimization decisions, unexpected deopts, hot path shapes), use the `@examples/benchmark/package.json` profiling scripts: + +- **`start:trace`** (`@examples/benchmark/package.json:16`): use when you want **console trace output** for V8 optimizations/deoptimizations (adds `--trace_opt --trace_deopt` to the normal benchmark run). +- **`start:deopt`** (`@examples/benchmark/package.json:17`): use when you want **dexnode/V8 log artifacts** to inspect deopt reasons and code traces (writes `v8.log` and redirects code traces to `/tmp/codetrace`). + +Notes: +- Passing no suite name runs **`normalizr` + `core`** (see `@examples/benchmark/index.js`). +- The harness forces `--expose_gc` and calls `gc()` between cycles for more consistent results. + +## Suites and what they exercise + +Use this mapping when deciding which suite(s) to run for a change: + +- **`micro`** (`@examples/benchmark/micro.js`) + - **Primary focus**: isolated microbenchmarks for validating specific optimizations + - **Packages exercised**: depends on what's being tested + - **Recommended filters**: target specific benchmark names you've added + +- **`entity`** (`@examples/benchmark/entity.js`) + - **Primary focus**: entity instance operations like `pk()` and `fromJS()`, plus `EntityMixin`. + - **Packages exercised**: + - **`@data-client/endpoint`**: `Entity`, `EntityMixin` (re-exported via `@examples/benchmark/dist/index.js`) + - **Recommended filters**: Run all benchmarks (suite is small and focused) + +- **`normalizr`** (`@examples/benchmark/normalizr.js`) + - **Primary focus**: `normalize()` / `denormalize()` throughput, memoization, and query/key building. + - **Packages exercised**: + - **`@data-client/normalizr`**: `normalize`, `denormalize`, `MemoCache`, `WeakDependencyMap` + - **`@data-client/endpoint`**: schema helpers used in `@examples/benchmark/schemas.js` (`schema.All`, `schema.Query`, `schema.Collection`) and entity definitions + - **Recommended filters**: + - Changes to normalization: `^normalize` + - Changes to denormalization: `^denormalize` + - Changes to query/key building: `query` or `buildQueryKey` + +- **`core`** (`@examples/benchmark/core.js`) + - **Primary focus**: end-to-end store/controller costs (`Controller.setResponse()`, `Controller.getResponse()`, reducer updates). + - **Packages exercised**: + - **`@data-client/core`**: `Controller`, `createReducer`, `initialState` + - **`@data-client/endpoint`**: `Endpoint`, `Entity`, schema definitions used by endpoints + - **`@data-client/normalizr`**: normalization work triggered by `setResponse()` and `getResponse()` paths + - **Recommended filters**: + - Changes to `setResponse()` or reducer updates: `^set` + - Changes to `getResponse()` or cache retrieval: `^get` + - Changes to `Controller.set()` or batch writes: `setMany` (one `set()` per row vs one `set([Entity], rows)`, into a 500-entity store) + +- **`spread`** (`@examples/benchmark/spread.js`, scenarios shared with `memory.js` via `@examples/benchmark/spread-scenarios.js`) + - **Primary focus**: degenerate cases where spread-operation cost scales with **store size** rather than payload size — single-entity `setResponse` into 1k/10k/100k entity stores (per-type entity map clone in `NormalizeDelegate`), writes with 10k cached endpoint keys (`endpoints`/`meta` spreads in `setResponseReducer`), collection push onto 10k items (`pushMerge`), and `invalidateAll`/`expireAll` over 10k endpoints. + - **Packages exercised**: + - **`@data-client/core`**: `Controller`, `createReducer` write paths + - **`@data-client/normalizr`**: `normalize` store-copy behavior + - **`@data-client/endpoint`**: `Entity.merge`, `Collection` push + - **Recommended filters**: `setOneEntity` (store-size sweep + control), `collection push`, `invalidateAll` + - **CI**: only `setOneEntity in 10k entity store` is tracked over time, via its own workflow `@.github/workflows/benchmark-spread.yml` (separate `spread-bench` history dir on `gh-pages-bench`). It triggers on **store-write paths only**: `packages/core/src/state/**`, `packages/normalizr/src/normalize/**`, `packages/endpoint/src/schemas/EntityMixin.ts`, and the spread suite files. All other spread benchmarks are manual-only. The `setOneEntity` sweep should scale near-linearly with store size while the `control` benchmark stays flat. + - Scenario fixtures are built lazily per matching filter (`buildScenarios(filter)` in `spread-scenarios.js`), so filtered runs skip the expensive 100k-store construction. + - **Memory pressure**: run `yarn workspace example-benchmark start:memory [filter]` to measure allocation/op, GC counts and pause time, and retained heap for the same scenarios. Copies are transient, so expect high allocation + GC churn with ~0 retained. + +- **`old-normalizr`** (`@examples/benchmark/old-normalizr/normalizr.js`) + - **Primary focus**: baseline comparison against the legacy `normalizr` npm package. + - **Packages exercised**: + - **`normalizr` (npm)**: `normalize`, `denormalize`, `schema.Entity` + - **`@data-client/core`**: `initialState` only (used to shape a "store-like" state for merge behavior) + - **Recommended filters**: Run all benchmarks (baseline comparison) + +## Adding or changing benchmarks + +- **Keep suite names stable**: output is tracked over time in CI; renaming benchmarks makes history harder to interpret. +- **Update both code and docs**: + - Add/update suite module (e.g. `core.js`) + - Wire it in `@examples/benchmark/index.js` (argv dispatch) + - Update `@examples/benchmark/README.md` “Suites” section if the suite list changes +- **Avoid measuring unrelated work**: + - Don’t log inside benchmark bodies (except suite cycle output via `Benchmark.js`). + - Keep fixtures/data constant unless the benchmark’s goal is to measure data-shape changes. + +## CI behavior + +CI runs: + +```bash +yarn build:benchmark +yarn workspace example-benchmark start | tee output.txt +``` + +The output is parsed as **benchmark.js** format and reported by `rhysd/github-action-benchmark` (see `@.github/workflows/benchmark.yml`). + +A second workflow, `@.github/workflows/benchmark-spread.yml`, runs only: + +```bash +yarn workspace example-benchmark start spread "setOneEntity in 10k entity store" +``` + +with narrower path triggers (store-write code: `packages/core/src/state/**`, `packages/normalizr/src/normalize/**`, `packages/endpoint/src/schemas/EntityMixin.ts`, spread suite files) and reports to a separate `spread-bench` history dir on the same `gh-pages-bench` branch. + +## Expected variance + +Benchmark results have two types of variance to consider: + +### Within-run variance (reported as ±X%) + +The `±X%` shown after each result is the **margin of error** for samples within that run. Most benchmarks show: +- **Low variance (±0.1–0.3%)**: Cache-hit benchmarks with stable hot paths (`buildQueryKey`, `setSmallResponse`, `denormalizeShort donotcache`) +- **Moderate variance (±0.5–1.5%)**: Most normalize/denormalize operations, entity operations +- **Higher variance (±1.5–2.5%)**: Complex operations with GC pressure (`getResponse`, `getResponse Collection`) + +### Run-to-run variance + +When comparing results across separate benchmark runs, expect additional variance: + +| Category | Examples | Typical run-to-run spread | +|----------|----------|---------------------------| +| **Very stable** | `denormalizeShort donotcache 500x`, `no-defaults pk()` | <1% | +| **Stable** | `normalizeLong`, `setLong`, `setLongWithMerge`, `mixin pk()` | 1–3% | +| **Moderate** | `getSmallResponse`, `fromJS()`, `get Collection` | 3–7% | +| **Volatile** | `query All withCache`, `denormalizeLong withCache`, `denormalizeLong All withCache` | 10–20% | + +### Interpreting results + +- **Performance regressions**: Require >5% degradation on stable benchmarks, or >15% on volatile ones, to be considered significant. +- **Cache-path benchmarks** (those with `withCache` suffix) show higher variance because cache state and GC timing affect results more. +- **Run multiple times**: For performance investigations, run benchmarks 3+ times and compare the median or best result, not single runs. +- **The `gc()` calls** between cycles help but don't eliminate variance from JIT warmup and memory pressure. + diff --git a/.claude/rules/breaking-changes.md b/.claude/rules/breaking-changes.md new file mode 100644 index 000000000000..d9ed53b1f1e3 --- /dev/null +++ b/.claude/rules/breaking-changes.md @@ -0,0 +1,28 @@ +--- +paths: + - "packages/**" + - ".changeset/**" + - "plans/next-breaking-release.md" +--- + + + +# Breaking Change Strategy + +Get users the fix now with a clean upgrade path; batch the actual break into a later release. + +## Before shipping a change that could break + +Check both: +- **User code**: subclasses, overrides, explicit annotations, and classes implementing exported interfaces. +- **Mixed versions**: `@data-client/rest`/`endpoint`/`graphql` aren't dependencies of `react`/`vue`/`core`, so users can pair a newer client with an older endpoint (and vice versa). Type checks in core against endpoint types must still accept older endpoint versions. + +Requiring a matching version for a feature that is new in the same release is not breaking. + +## When it would break + +1. **Ship a compatible version**: a shim that keeps old code compiling and working (e.g. method-syntax declarations for parameter bivariance, a loose structural type instead of the strict interface, a deprecated alias). +2. **Prepare users**: in the changeset and the release's draft blog post, recommend the future-proof form now with a code example, so the later break is a no-op for them. +3. **Track the cleanup**: add an entry to [plans/next-breaking-release.md](../../plans/next-breaking-release.md) naming the shim to remove, what it breaks, and the migration. + +Mark a change `BREAKING` (minor bump while under 1.0) only when no compatible version is reasonable. When a release is already breaking, work through `plans/next-breaking-release.md`, move each finished item into that release's blog migration guide, and delete it from the plan. diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md new file mode 100644 index 000000000000..5e034b5d9e2c --- /dev/null +++ b/.claude/rules/ci-config.md @@ -0,0 +1,51 @@ +--- +paths: + - ".circleci/**" + - ".github/workflows/*.yml" +--- + + + +# CI configuration + +## CircleCI (`.circleci/config.yml`) + +- Jest `--maxWorkers` is pinned per job to the `resource_class` vCPU count (large = 4, medium = 2) because docker containers report the host's CPUs via `os.cpus()`. Exception: the ReactNative `unit_tests` run is deliberately uncapped — its suites are fake-timer-wait dominated and capping workers flakes 5s test timeouts. +- Jobs halt via the `halt-unless-relevant-change` command based on flags computed once in `setup` (`.ci-esmodule-relevant`, `.ci-tests-relevant`) and transported via `save_cache`/`restore_cache` (keyed on `CIRCLE_SHA1`) so jobs can halt before paying `attach_workspace`. Missing/unreadable flags fail open (jobs run). On the default branch both flags are always true (a push may carry several commits). The diff uses `--no-renames` so moving a file out of a relevant dir still counts. + - `esmodule` (validate-esmodule-browser-build, esmodule-types*): a denylist, so new paths fail open. False only when every changed path is provably outside the esmodule jobs' inputs: the shared `DOCS_ONLY` paths (also the `tests` denylist) plus `.vscode/`, `plans/`, root `__tests__/` (excluded by every `tsconfig.compile.json`), `eslint.config.mjs`, `jest.config.js`, `examples/*.md`, and examples the jobs never build (`benchmark`, `benchmark-react`, `coin-app`, `nextjs`, `normalizr-github`, `normalizr-redux`, `test-bundlesize`, `vue-todo-app`). Only add a path if no esmodule job (or the `setup` builds feeding them) reads it. + - `tests` (lint, typecheck, unit_tests, node_matrix): false only when every changed path is docs/website/tooling (`website/`, `docs/`, `.changeset/`, `.cursor/`, `.agents/`, `.claude/`, `.github/`, root `*.md`), except `website/src/components/Playground/` (has unit tests). When both flags are false, `setup` halts before install. +- Legacy TS types (`ci:build:legacy-types`, consumed by `esmodule-types`): + - Built inside `setup` (`ci:build:setup:esmodule`) only when the esmodule flag is set; there is no separate job, to keep a job hop off the critical path. + - CI builds the endpoint, normalizr and rest legacy outputs, all for TS >= 4.0 (the minimum supported TS, and the oldest in the `esmodule-types` matrix). `use-enhanced-reducer` still ships a `ts3.4` build in release builds (`build:types`). + - `scripts/build-legacy-types.sh` builds each TS version concurrently; each `ts/` gets the downleveled `lib` (with `abstract new` rewritten to `new` below 4.2, which `downlevel-dts` misses), then every newer version's `src-*-types` overlay, then its own. Keep overlays to small single-purpose modules (like `NoInfer.ts`, `tupleTypes.ts`) so whole-file copies can't go stale. + - `esmodule-types` also runs `examples/todo-app/tsconfig.typetest-libcheck.json` (`skipLibCheck: false`, `types: []`) so errors inside the legacy outputs fail CI, and `esmodule-types-latest` runs it with `--moduleResolution bundler` (TS 7 removed `node`) to cover `lib/`; the other typetests use `skipLibCheck: true`. + - Any change to legacy types building must leave `ts*/` output byte-identical to master (diff it) unless it intentionally changes published types (then add a changeset). +- `typecheck` also runs `yarn check:typeperf` ([scripts/typeperf](../../scripts/typeperf/README.md)), which reads the `ci:build:types` output from `setup`'s workspace. +- Never `git fetch --depth` the base branch in the relevance check: a shallow fetch severs the merge base and the three-dot diff fails. +- Changing root `package.json` `workspaces` requires updating the `setup` job's workspace trimming step. +- `setup`'s workspace leaves out the yarn cache (`.yarn/cache`) to keep its upload short. Jobs that re-resolve dependencies (`yarn up`/`add`) run `restore-yarn-cache`, keyed on `.ci-deps-key`, a hash of the manifests as committed (taken before trimming and `yarn up` rewrite them). + +## GitHub Actions (`.github/workflows/`) + +- Workflows install only needed workspaces via `./scripts/ci-install.sh [extra-workspace ...]`. +- `skills.yml` `paths` must cover every input of `website/framework-docs/skillReferences.mjs` (docs, skill manifests, the generator and its deps). +- `agent-rules.yml` runs `scripts/agent-rules.mjs --check` and the agent hook tests (`node --test '.cursor/hooks/*.test.js'`; a bare directory runs nothing on Node 22) with no install (node and git only). Its `paths` must cover every input and output of that script, and `.cursor/hooks/`. +- `editor-types.yml` reruns `yarn copy:websitetypes` and fails if `website/src/components/Playground/editor-types` changes. It needs the `website` workspace (for deps like `bignumber.js`), which CircleCI's `setup` drops. Its `paths` must cover every input of `scripts/copywebsitetypes.sh`. +- `site-preview.yml` runs one `build` job (one install for the typecheck and the build). It builds the site directly (no Vercel CLI, only the packages it imports via `ci:build:website`, `VERCEL_ENV=preview` to include drafts), restores Docusaurus' webpack cache (only master pushes save it, so PRs share one entry), and fails on any `[WARNING]`/`[ERROR]` line. Broken links are `warn` in `docusaurus.config.ts` so this check catches them without failing Vercel deploys. +- Production docs deploys come from Vercel's Git integration only (gated by `vercel-ignore.sh`); there is no Actions deploy workflow. Vercel clones ~10 commits deep, so `website/scripts/deepenGitHistory.cjs` (called from `docusaurus.config.ts`) fetches 800 more for the "Last updated" dates. +- `site-preview.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. +- `benchmark-react.yml` caches Playwright browsers keyed on the resolved `playwright` version from `examples/benchmark-react`; bumping playwright invalidates the cache automatically. +- Benchmark workflows (`benchmark.yml`, `benchmark-react.yml`) tune the host (CPU governor, swapoff) and pin CPUs with `taskset` — they must run directly on the runner, not in a `container:`. +- Never cancel a run on master: a cancelled check marks the commit red, which hurts npm search scoring. PR runs cancel superseded ones (`group: -${{ github.head_ref || github.run_id }}`, `cancel-in-progress: true`); push runs get a unique group. Runs that must not overlap (`release.yml`, `beta-release.yml`) use a shared group with `queue: max`, which keeps up to 100 pending runs in order instead of cancelling all but one (GitHub rejects it alongside `cancel-in-progress: true`; actionlint 1.7.12 doesn't know the key yet). The benchmark push groups still use the default single pending slot. +- Report-style workflows (`benchmark*.yml`, `bundle_size.yml`, `codeql-analysis.yml`) skip draft PRs with a job-level `if: ${{ !github.event.pull_request.draft }}` and list `ready_for_review` in `pull_request.types`, so they run once a PR is marked ready. Correctness checks (`editor-types`, `skills`, `website`) still run on drafts. +- `paths` leave out what a workflow never reads, so test- or docs-only edits under `packages/` don't fan out: `__tests__/`, `typescript-tests/`, `src-*-types/` (legacy types) and `*.md`. `bundle_size.yml` also skips packages `examples/test-bundlesize` doesn't bundle (graphql, test, vue). +- CodeQL triggers only on shipped source (`packages/*/src/**`, `packages/*/node.mjs`). `bundle_size.yml` is PR-only: on push the action measures but has nowhere to report. +- Bundle Size and the benchmarks list `yarn.lock` in `paths` but gate their main job on the reusable `dependency-gate.yml` (`scripts/ci-deps-relevant.mjs`). It runs the job when a non-manifest file in the workflow's paths changed, a manifest of the measured workspaces (or a workspace package they ship with) changed, or the yarn.lock resolutions reachable from their dependencies or the babel/browserslist/core-js build tooling changed. Bumps of test, lint, React Native or website tooling skip it; anything it can't classify runs. The gate reads the caller's `on..paths` itself (via `github.workflow_ref` and `yq`), so there's one list. Root `devDependencies` aren't walked: add a new root build dependency to `BUILD_TOOLS`. +- Renovate (`.github/renovate.json`) uses `rebaseWhen: conflicted`: master's branch protection requires up-to-date branches, which makes the default rebase every Renovate PR (and rerun all of its CI) on each master push. Update the branch before merging a Renovate PR; if that update isn't merged, Renovate treats the branch as edited and stops rebasing it until its PR's rebase checkbox is ticked. + +## Vercel docs site (`website/scripts/vercel-ignore.sh`) + +- Vercel's ignore step: exit 0 skips, anything else builds, so fail open. Never diff only `HEAD^`: a master merge or multi-commit push makes it wrong. Run `vercel-ignore.test.sh` after changes. +- `git.deploymentEnabled: false` doesn't stick (dashboard overrides it); skipped pushes still show as canceled deployments. +- Vercel's clone can have a `master`/`origin/master` ref at the commit being built, so always fetch master and never use a ref equal to `HEAD` as the base (it makes every preview skip). +- Previews on `renovate/*` skip when the only site changes are website `package.json` or lockfiles; production is unchanged. diff --git a/.claude/rules/library-goals.md b/.claude/rules/library-goals.md new file mode 100644 index 000000000000..21227be32d59 --- /dev/null +++ b/.claude/rules/library-goals.md @@ -0,0 +1,14 @@ +--- +paths: + - "packages/**" +--- + + + +# Library Goals Alignment + +When editing library code in `packages/*`, read [GOALS.md](../../GOALS.md) and weigh changes against it. It is the source of truth for project priorities — do not rely on a summary of it. + +- Evaluate design decisions (new APIs, abstractions, dependencies) against the goals before implementing. +- When goals conflict for a given change (e.g. bundle size vs. performance), resolve the trade-off using the priorities expressed in GOALS.md, and note the reasoning. +- If a requested change works against the goals, say so and propose an alternative that stays aligned. diff --git a/.claude/rules/markdown-formatting.md b/.claude/rules/markdown-formatting.md new file mode 100644 index 000000000000..66ffd07a36fa --- /dev/null +++ b/.claude/rules/markdown-formatting.md @@ -0,0 +1,28 @@ +--- +paths: + - "**/*.md" + - "**/*.mdc" + - "**/*.mdx" +--- + + + +# Markdown Formatting + +## Documentation Links + +Link API concepts that have corresponding doc pages: + +- **External doc links**: `[Union](https://dataclient.io/rest/api/Union)` +- **Internal doc links**: Use site paths like `[Controller](/docs/api/Controller)` or `[Entity](/rest/api/Entity)` +- **Package links**: `[@data-client/rest](www.npmjs.com/package/@data-client/rest)` + +## Repository Links + +- **PR links**: `[#1234](https://github.com/reactive/data-client/pull/1234)` +- **Commit links**: ``[`abc123`](https://github.com/reactive/data-client/commit/abc123)`` + +## Code References + +- Use backticks for inline code: function names, class names, file paths, package names +- Use fenced code blocks with language tags for multi-line code examples diff --git a/.claude/rules/skills-sync.md b/.claude/rules/skills-sync.md new file mode 100644 index 000000000000..86dfac203850 --- /dev/null +++ b/.claude/rules/skills-sync.md @@ -0,0 +1,20 @@ +--- +paths: + - "docs/**" + - ".agents/skills/**" + - "packages/*/src/index.ts" +--- + + + +# Skills sync + +Skill `references/*.md` files listed in a skill's `references.json` are generated from `docs/` by `yarn build:skills` (an agent hook runs it before `git push`; the `skills` CI check fails on drift and on dead `references/` links in `SKILL.md`). Everything else in a skill (`SKILL.md`, references without the generated header) is hand-written and only changes when you change it. + +- **Editing a doc**: never edit the generated reference. Edit the doc; references regenerate. +- **Adding a doc**: if a skill covers that API or topic (match by skill `description`), add the page to its `references.json` and link it from the skill's reference list in `SKILL.md`. New partials (`_foo.mdx`) need nothing; they're inlined. +- **Renaming, moving or deleting a doc**: update every `references.json` entry and `SKILL.md` link to it (`grep -rn '' .agents/skills`). The generator fails on a missing source. +- **Changing a public API** (rename, signature, new option, deprecation): grep `.agents/skills` for the old name and update `SKILL.md` examples and hand-written references in the same PR. Generated references only follow the docs. +- **Adding a hand-written reference**: `.gitattributes` marks `references/**` as `linguist-generated` (collapsed in GitHub diffs); add a `-linguist-generated` line for it. `yarn build:skills` fails until you do. +- **Framework-specific pages**: a `frameworks: [react]` page is skipped for Vue; a skill whose `frameworks` lists `vue` must not rely on it. Skills covering both frameworks link `name.md`; `name.vue.md` exists only where Vue differs. +- App-level examples in skills import from `@data-client/react` or `@data-client/vue` (and `/test` subpaths), never `@data-client/core`. diff --git a/.claude/settings.json b/.claude/settings.json index ab7a022c5145..fecd4d379464 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,30 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/.cursor/hooks/build-skills.js\"" + "if": "Bash(git *)", + "command": "node \"$CLAUDE_PROJECT_DIR/.cursor/hooks/pre-push.js\"" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-setup.js\"", + "timeout": 1800 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.cursor/hooks/eslint-fix.js\"" } ] } diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 000000000000..2b7a412b8fa0 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.cursor/hooks.json b/.cursor/hooks.json index bc046cb18538..037f3abe714f 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -1,14 +1,14 @@ { "version": 1, "hooks": { - "afterFileEdit": [ + "beforeShellExecution": [ { - "command": "node .cursor/hooks/eslint-fix.js" + "command": "node .cursor/hooks/pre-push.js" } ], - "beforeShellExecution": [ + "stop": [ { - "command": "node .cursor/hooks/build-skills.js" + "command": "node .cursor/hooks/eslint-fix.js" } ] } diff --git a/.cursor/hooks/build-skills.js b/.cursor/hooks/build-skills.js deleted file mode 100644 index 4f343f1dcf69..000000000000 --- a/.cursor/hooks/build-skills.js +++ /dev/null @@ -1,132 +0,0 @@ -/* global require */ -// Before an agent runs `git push` (Cursor `beforeShellExecution`, Claude Code -// `PreToolUse` on Bash), regenerates agent skill references when the branch -// touches their inputs, and holds the push until the result is committed. -// Runs once per push instead of per edit or turn, so any number of local -// commits can come first; CI's `skills` check is the backstop. -const { execFileSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -let payload = {}; -try { - payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); -} catch { - process.exit(0); -} -const command = payload.command ?? payload.tool_input?.command ?? ''; -// a git subcommand as a command (`git push`, `git -C dir push`, `cd x && git -// push`); not `git stash push`, `git -c commit.gpgsign=false`, a branch named -// fix-commit or a commit message mentioning push -const gitCommand = sub => - new RegExp( - `(?:^|[;&|(]\\s*)git(?:\\s+-[cC]\\s+\\S+|\\s+--?[\\w-]+(?:=\\S+)?)*\\s+${sub}(?![\\w.-])`, - 'm', - ); -if (!gitCommand('push').test(command)) process.exit(0); - -const projectDir = - process.env.CURSOR_PROJECT_DIR || - process.env.CLAUDE_PROJECT_DIR || - process.cwd(); -const git = (...args) => - execFileSync('git', args, { - cwd: projectDir, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trimEnd(); - -/** Docs some skill renders; partials (`_foo.mdx`) may be inlined anywhere */ -let skillDocs; -function isSkillDoc(file) { - if (!/^docs\/.*\.mdx?$/.test(file)) return false; - if (path.basename(file).startsWith('_')) return true; - if (!skillDocs) { - const skills = path.join(projectDir, '.agents/skills'); - skillDocs = new Set( - fs.readdirSync(skills).flatMap(skill => { - const manifest = path.join(skills, skill, 'references.json'); - return fs.existsSync(manifest) ? - Object.values(JSON.parse(fs.readFileSync(manifest, 'utf8')).docs) - : []; - }), - ); - } - return skillDocs.has(file.replace(/\.(react|vue)(\.mdx?)$/, '$2')); -} -const isInput = file => - isSkillDoc(file) || - /^\.agents\/skills\/[^/]+\/(references\.json|SKILL\.md)$/.test(file) || - file.startsWith('website/framework-docs/'); - -// files the branch changes relative to master, plus uncommitted ones when -// the same command commits before pushing (`git commit -am x && git push`) -try { - // renames as delete + add, so the old path counts too - const dirty = git( - 'status', - '--porcelain', - '--no-renames', - '--untracked-files=all', - ) - .split('\n') - .map(line => line.slice(3)) - .some(isInput); - // the generator reads the working tree, so it can only vouch for what's - // pushed when that includes these edits; otherwise leave it to CI - if (dirty && !gitCommand('commit').test(command)) process.exit(0); - const committed = git( - 'diff', - '--name-only', - '--no-renames', - 'origin/master...HEAD', - ) - .split('\n') - .some(isInput); - if (!dirty && !committed) process.exit(0); -} catch { - process.exit(0); -} - -let problems = ''; -try { - execFileSync('node', ['website/framework-docs/skillReferences.mjs'], { - cwd: projectDir, - stdio: ['ignore', 'ignore', 'pipe'], - }); -} catch (err) { - // dead links, missing variant notes or bad manifests - problems = String(err.stderr ?? '').trim(); -} -// includes references regenerated earlier but never committed -const uncommitted = git( - 'status', - '--porcelain', - '--untracked-files=all', - '--', - '.agents/skills/*/references/*', -); -if (!uncommitted && !problems) process.exit(0); - -const message = [ - uncommitted && - `Skill references generated from this branch's docs changes aren't committed. Commit them, then push again:\n${uncommitted}`, - problems && - `\`yarn build:skills\` found problems the skills CI check will fail on. Fix them, commit, then push again:\n${problems}`, -] - .filter(Boolean) - .join('\n\n'); -console.log( - JSON.stringify( - payload.hook_event_name === 'PreToolUse' ? - { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: message, - }, - } - : { permission: 'deny', userMessage: message, agentMessage: message }, - ), -); -process.exit(0); diff --git a/.cursor/hooks/eslint-fix.js b/.cursor/hooks/eslint-fix.js index 237b389b27f2..20e1ec7c1b25 100644 --- a/.cursor/hooks/eslint-fix.js +++ b/.cursor/hooks/eslint-fix.js @@ -1,36 +1,185 @@ +/* global require, module */ +// `eslint --fix` for agent hooks. Run directly, it's the end-of-turn hook +// (Cursor `stop`, Claude Code `Stop`): fixes the uncommitted JS/TS files +// changed since this hook last ran, so edits from the agent and from someone +// editing alongside it are batched into one run per turn instead of one per +// edit, and a turn that changed none skips eslint. Errors eslint can't fix go +// back to the agent once per turn (not after a Cursor abort). `pre-push.js` also uses it +// for the files a push includes. +const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); -const { execFileSync } = require('child_process'); - -const input = fs.readFileSync(0, 'utf8').trim(); -if (!input) process.exit(0); -let payload; -try { - payload = JSON.parse(input); -} catch { - process.exit(0); -} - -const filePath = payload.file_path; -if (!filePath) process.exit(0); +const projectDir = + process.env.CURSOR_PROJECT_DIR || + process.env.CLAUDE_PROJECT_DIR || + process.cwd(); +const CACHE = path.join(projectDir, '.eslintcache'); +// written only by the end-of-turn run, unlike eslint's cache, which pre-push +// and other `eslint --cache` runs also touch +const LAST_RUN = path.join( + projectDir, + 'node_modules/.cache/eslint-fix-last-run', +); +const isLintable = file => /\.[cm]?[jt]sx?$/.test(file); +const mtime = file => + fs.statSync(path.join(projectDir, file), { throwIfNoEntry: false })?.mtimeMs; -const projectDir = process.env.CURSOR_PROJECT_DIR || process.cwd(); -const normalizedProject = path.resolve(projectDir) + path.sep; -const normalizedFile = path.resolve(filePath); +const git = (...args) => + execFileSync('git', args, { + cwd: projectDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trimEnd(); -if (!normalizedFile.startsWith(normalizedProject)) process.exit(0); +/** Uncommitted files; renames as delete + add, so the old path counts too */ +const dirtyFiles = () => + git('status', '--porcelain', '--no-renames', '--untracked-files=all') + .split('\n') + .filter(Boolean) + .map(line => line.slice(3)); -const ext = path.extname(normalizedFile); -const allowed = new Set(['.js', '.jsx', '.ts', '.tsx', '.cts', '.mts']); -if (!allowed.has(ext)) process.exit(0); +/** + * Fixes the JS/TS `files` that exist; returns whether eslint ran, the ones it + * changed, the mtime of each fix still on disk, and the errors it couldn't + * fix, one `file:line:col message (rule)` each + */ +function eslintFix(files) { + files = files.filter(file => isLintable(file) && mtime(file) !== undefined); + if (!files.length) + return { ok: true, fixed: [], fixedMtimes: {}, errors: [] }; + let ok = true; + let report = '[]'; + try { + report = execFileSync( + path.join(projectDir, 'node_modules/.bin/eslint'), + [ + '--fix', + '--cache', + '--cache-location', + CACHE, + '--no-warn-ignored', + '--format', + 'json', + '--', + ...files, + ], + { + cwd: projectDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + maxBuffer: 64 * 1024 * 1024, + }, + ); + } catch (err) { + // exit 1 means lint errors are left; a crash or missing install is left + // to CI + if (err.status === 1) report = err.stdout; + else ok = false; + } + let results = []; + try { + results = JSON.parse(report); + } catch { + // not eslint's report + } + if (!Array.isArray(results)) results = []; + // git paths use `/`, including on Windows where `path.relative` does not + const relative = filePath => + path.relative(projectDir, filePath).split(path.sep).join('/'); + const fixed = []; + // mtimes of fixes still on disk. A later edit is left out so it is not + // stored as already linted. + const fixedMtimes = {}; + for (const { filePath, output } of results) { + if (output === undefined) continue; + const file = relative(filePath); + fixed.push(file); + const modified = mtime(file); + if (modified === undefined) continue; + try { + if (fs.readFileSync(path.join(projectDir, file), 'utf8') === output) { + fixedMtimes[file] = modified; + } + } catch { + // removed while eslint ran + } + } + return { + // false when eslint crashed or isn't installed + ok, + // eslint reports `output` only for files its fixes changed + fixed, + fixedMtimes, + errors: results.flatMap(({ filePath, messages }) => + messages + .filter(({ severity }) => severity === 2) + .map( + ({ line, column, message, ruleId }) => + `${relative(filePath)}:${line}:${column} ${message}${ruleId ? ` (${ruleId})` : ''}`, + ), + ), + }; +} -try { - execFileSync('yarn', ['eslint', '--fix', '--', normalizedFile], { - stdio: 'ignore', - }); -} catch { - // Ignore lint failures to avoid blocking the agent loop. +if (require.main === module) { + // LAST_RUN's mtime is this run's start, so edits made while eslint runs + // count next turn. Its contents are mtimes already linted: the mtime seen + // when a file was chosen, or, when eslint rewrote it, the mtime it left. + // Statting every chosen file after the run would store an edit that landed + // during it as already linted. + const start = new Date(); + let payload = {}; + try { + payload = JSON.parse( + (!process.stdin.isTTY && fs.readFileSync(0, 'utf8')) || '{}', + ); + } catch { + // run by hand + } + try { + const lastRun = + fs.statSync(LAST_RUN, { throwIfNoEntry: false })?.mtimeMs ?? 0; + let linted = {}; + try { + linted = JSON.parse(fs.readFileSync(LAST_RUN, 'utf8')); + } catch { + // first run + } + const mtimes = {}; + const files = dirtyFiles().filter(file => { + const modified = mtime(file); + if (!(modified > lastRun && modified !== linted[file])) return false; + mtimes[file] = modified; + return true; + }); + const { ok, fixedMtimes, errors } = eslintFix(files); + // keep the old marker, so these files are tried again next turn + if (!ok) process.exit(0); + Object.assign(mtimes, fixedMtimes); + fs.mkdirSync(path.dirname(LAST_RUN), { recursive: true }); + fs.writeFileSync(LAST_RUN, JSON.stringify(mtimes)); + fs.utimesSync(LAST_RUN, start, start); + // hand errors eslint can't fix back to the agent once per turn, so it + // fixes them before finishing; the files it edits get linted again on + // its next stop, which reports nothing more + const followUp = + payload.stop_hook_active || + payload.loop_count > 0 || + (payload.status && payload.status !== 'completed'); + if (errors.length && !followUp) { + const message = `ESLint found errors it couldn't fix in uncommitted files. Fix the ones in files you edited, and leave the rest to whoever is editing them:\n${errors.slice(0, 50).join('\n')}${errors.length > 50 ? `\n…and ${errors.length - 50} more` : ''}`; + console.log( + JSON.stringify( + payload.hook_event_name === 'Stop' ? + { decision: 'block', reason: message } + : { followup_message: message }, + ), + ); + } + } catch { + // not a git checkout + } } -process.exit(0); +module.exports = { projectDir, git, dirtyFiles, eslintFix }; diff --git a/.cursor/hooks/eslint-fix.test.js b/.cursor/hooks/eslint-fix.test.js new file mode 100644 index 000000000000..2abcac526883 --- /dev/null +++ b/.cursor/hooks/eslint-fix.test.js @@ -0,0 +1,150 @@ +/* global require, __dirname */ +// `node --test '.cursor/hooks/*.test.js'` +const assert = require('assert/strict'); +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const { test } = require('node:test'); +const os = require('os'); +const path = require('path'); + +const hook = path.join(__dirname, 'eslint-fix.js'); +const future = new Date('2030-01-01T00:00:00Z'); + +const fakeEslint = `#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const sep = process.argv.indexOf('--'); +const targets = sep === -1 ? [] : process.argv.slice(sep + 1); +const log = path.join(process.cwd(), 'invocations.json'); +const prev = fs.existsSync(log) ? JSON.parse(fs.readFileSync(log, 'utf8')) : []; +prev.push(targets); +fs.writeFileSync(log, JSON.stringify(prev)); +const mode = JSON.parse(process.env.ESLINT_FAKE || '{}'); +if (mode.crash) process.exit(2); +const when = new Date('2030-01-01T00:00:00Z'); +const results = targets.map(file => { + const filePath = path.resolve(process.cwd(), file); + const action = mode[file]; + const result = { filePath, messages: [], errorCount: 0, warningCount: 0 }; + if (action === 'human' || action === 'fix' || action === 'fix-then-human') { + const original = fs.readFileSync(filePath, 'utf8'); + const fixed = original + '\\n// fix\\n'; + if (action === 'human') { + fs.writeFileSync(filePath, original + '// human\\n'); + } else if (action === 'fix') { + fs.writeFileSync(filePath, fixed); + result.output = fixed; + } else { + fs.writeFileSync(filePath, fixed + '// human\\n'); + result.output = fixed; + } + fs.utimesSync(filePath, when, when); + } + return result; +}); +process.stdout.write(JSON.stringify(results)); +`; + +function withRepo(fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'eslint-fix-')); + try { + execFileSync('git', ['init', '-b', 'main'], { cwd: root, stdio: 'ignore' }); + const bin = path.join(root, 'node_modules', '.bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, 'eslint'), fakeEslint, { mode: 0o755 }); + fn(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function write(root, file) { + const abs = path.join(root, file); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, 'const n = 1;\n'); +} + +const mtime = (root, file) => fs.statSync(path.join(root, file)).mtimeMs; +const marker = root => + path.join(root, 'node_modules/.cache/eslint-fix-last-run'); +const linted = root => JSON.parse(fs.readFileSync(marker(root), 'utf8')); +const invocations = root => + JSON.parse(fs.readFileSync(path.join(root, 'invocations.json'), 'utf8')); + +function run(root, fake = {}) { + execFileSync(process.execPath, [hook], { + cwd: root, + env: { + ...process.env, + CURSOR_PROJECT_DIR: root, + ESLINT_FAKE: JSON.stringify(fake), + }, + input: '{}', + encoding: 'utf8', + }); +} + +test('an edit that lands during eslint is linted next turn', () => { + withRepo(root => { + write(root, 'src/edited.js'); + write(root, 'src/fixed.js'); + const editedBefore = mtime(root, 'src/edited.js'); + run(root, { 'src/edited.js': 'human', 'src/fixed.js': 'fix' }); + const stored = linted(root); + // the human edit's mtime must not be the one recorded as already linted + assert.equal(stored['src/edited.js'], editedBefore); + assert.notEqual(mtime(root, 'src/edited.js'), editedBefore); + // eslint's own rewrite is remembered, so the next turn skips it + assert.equal(stored['src/fixed.js'], mtime(root, 'src/fixed.js')); + run(root, {}); + const calls = invocations(root); + assert.deepEqual(calls[0].slice().sort(), [ + 'src/edited.js', + 'src/fixed.js', + ]); + assert.deepEqual(calls[1], ['src/edited.js']); + }); +}); + +test('an edit after eslint rewrites a chosen file is linted next turn', () => { + withRepo(root => { + write(root, 'src/both.js'); + const before = mtime(root, 'src/both.js'); + run(root, { 'src/both.js': 'fix-then-human' }); + assert.equal(linted(root)['src/both.js'], before); + run(root, {}); + assert.deepEqual(invocations(root)[1], ['src/both.js']); + }); +}); + +test('a file linted with a mtime after the run start is not linted again', () => { + withRepo(root => { + write(root, 'src/fresh.js'); + fs.utimesSync(path.join(root, 'src/fresh.js'), future, future); + const stamped = mtime(root, 'src/fresh.js'); + run(root, {}); + assert.equal(linted(root)['src/fresh.js'], stamped); + run(root, {}); + assert.equal(invocations(root).length, 1); + }); +}); + +test('an edit after the run is linted on the next turn', () => { + withRepo(root => { + write(root, 'src/quiet.js'); + run(root, {}); + fs.utimesSync(path.join(root, 'src/quiet.js'), future, future); + run(root, {}); + assert.equal(invocations(root).length, 2); + }); +}); + +test('files eslint crashed on are tried again next turn', () => { + withRepo(root => { + write(root, 'src/retry.js'); + run(root, { crash: true }); + assert.equal(fs.existsSync(marker(root)), false); + run(root, {}); + assert.deepEqual(invocations(root), [['src/retry.js'], ['src/retry.js']]); + }); +}); diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js new file mode 100644 index 000000000000..cfcfc205423d --- /dev/null +++ b/.cursor/hooks/pre-push.js @@ -0,0 +1,230 @@ +/* global require */ +// Before an agent runs `git push` (Cursor `beforeShellExecution`, Claude Code +// `PreToolUse` on Bash), regenerates agent skill references and Claude Code's +// copy of the Cursor rules when the branch touches their inputs, runs +// `eslint --fix` on the JS/TS files it changes, and holds the push until the +// result is committed. +// Runs once per push instead of per edit or turn, so any number of local +// commits can come first; CI's `skills` and `agent-rules` checks are the +// backstop. +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const { projectDir, git, dirtyFiles, eslintFix } = require('./eslint-fix'); + +let payload = {}; +try { + payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); +} catch { + process.exit(0); +} +const command = payload.command ?? payload.tool_input?.command ?? ''; +// a git subcommand as a command (`git push`, `git -C dir push`, `cd x && git +// push`); not `git stash push`, `git -c commit.gpgsign=false`, a branch named +// fix-commit or a commit message mentioning push +const gitCommand = sub => + new RegExp( + `(?:^|[;&|(]\\s*)git(?:\\s+-[cC]\\s+\\S+|\\s+--?[\\w-]+(?:=\\S+)?)*\\s+${sub}(?![\\w.-])`, + 'm', + ); +if (!gitCommand('push').test(command)) process.exit(0); +const commits = gitCommand('commit').test(command); +// `git commit -a` / `-am` / `--all` +const commitsAll = + commits && + /(?:^|[;&|(]\s*)git\b[^;&|]*\scommit\b[^;&|]*\s(?:--all\b|-[A-Za-z]*a)/m.test( + // not a commit message mentioning `-a` or `--all` + command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, "''"), + ); + +/** Docs some skill renders; partials (`_foo.mdx`) may be inlined anywhere */ +let skillDocs; +function isSkillDoc(file) { + if (!/^docs\/.*\.mdx?$/.test(file)) return false; + if (path.basename(file).startsWith('_')) return true; + if (!skillDocs) { + const skills = path.join(projectDir, '.agents/skills'); + skillDocs = new Set( + fs.readdirSync(skills).flatMap(skill => { + const manifest = path.join(skills, skill, 'references.json'); + return fs.existsSync(manifest) ? + Object.values(JSON.parse(fs.readFileSync(manifest, 'utf8')).docs) + : []; + }), + ); + } + return skillDocs.has(file.replace(/\.(react|vue)(\.mdx?)$/, '$2')); +} +const isSkillInput = file => + isSkillDoc(file) || + /^\.agents\/skills\/[^/]+\/(references\.json|SKILL\.md)$/.test(file) || + file.startsWith('website/framework-docs/'); + +// files the branch changes relative to master and uncommitted ones; renames +// as delete + add, so the old path counts too +let dirty, committed; +try { + dirty = dirtyFiles(); + committed = git( + 'diff', + '--name-only', + '--no-renames', + 'origin/master...HEAD', + ).split('\n'); +} catch { + process.exit(0); +} + +/** + * Runs the generator `script` (`yarn build:`, CI check ``) when + * the branch changes a file `isInput` matches, then reports problems it + * printed and `outputs` (pathspecs) left uncommitted + */ +function regenerate({ what, from, script, check, isInput, outputs }) { + const isDirty = dirty.some(isInput); + // the generator reads the working tree, so it can only vouch for what's + // pushed when that includes these edits; otherwise leave it to CI + if (isDirty && !commits) return []; + if (!isDirty && !committed.some(isInput)) return []; + let problems = ''; + try { + execFileSync('node', [script], { + cwd: projectDir, + stdio: ['ignore', 'ignore', 'pipe'], + }); + } catch (err) { + problems = String(err.stderr ?? '').trim(); + } + // includes output generated earlier but never committed + const uncommitted = git( + 'status', + '--porcelain', + '--untracked-files=all', + '--', + ...outputs, + ); + return [ + uncommitted && + `${what} generated from this branch's ${from} changes aren't committed. Commit them, then push again:\n${uncommitted}`, + problems && + `\`yarn build:${check}\` found problems the ${check} CI check will fail on. Fix them, commit, then push again:\n${problems}`, + ]; +} + +const PENDING = path.join(projectDir, 'node_modules/.cache/pre-push-fixed'); + +/** `{ file: blob }` for those of `files` HEAD has */ +function headBlobs(files) { + if (!files.length) return {}; + return Object.fromEntries( + git('ls-tree', '-z', 'HEAD', '--', ...files) + .split('\0') + .filter(Boolean) + .map(line => { + const [meta, file] = line.split('\t'); + return [file, meta.split(' ')[2]]; + }), + ); +} + +/** `eslint --fix` the JS/TS files this push includes */ +function lintFix() { + // eslint reads the working tree, so lint an uncommitted file only when this + // command's commit takes all of it: staged with nothing unstaged on top, or + // any tracked edit with `-a`. Partial staging, untracked files and pathspecs + // can't be told from here, so those are left alone with the user's WIP + const committing = new Set( + commits ? + git('status', '--porcelain', '--no-renames', '--untracked-files=all') + .split('\n') + .filter( + line => + line && + !line.startsWith('?') && + (commitsAll || (line[0] !== ' ' && line[1] === ' ')), + ) + .map(line => line.slice(3)) + : [], + ); + const pushed = [ + ...committed.filter(file => !dirty.includes(file)), + ...committing, + ]; + const { fixed } = eslintFix(pushed); + // fixes an earlier push's run left uncommitted: those files are dirty now, + // so they aren't linted above, but the push would still go without them. + // Each is kept with its HEAD blob at the time; once a commit changes that, + // the fix went in with it + let pending = {}; + try { + pending = JSON.parse(fs.readFileSync(PENDING, 'utf8')); + } catch { + // none yet + } + const blobs = headBlobs([...fixed, ...Object.keys(pending)]); + const unpushed = Object.fromEntries([ + ...Object.entries(pending).filter( + ([file, blob]) => + blob === (blobs[file] ?? null) && + dirty.includes(file) && + !committing.has(file), + ), + ...fixed.map(file => [file, blobs[file] ?? null]), + ]); + try { + fs.mkdirSync(path.dirname(PENDING), { recursive: true }); + fs.writeFileSync(PENDING, JSON.stringify(unpushed)); + } catch { + // no install + } + // a `git add` earlier in this command can't be told from here, so these + // are held until a commit has them; the agent then pushes on its own + const held = Object.keys(unpushed); + return held.length ? + [ + `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${held.join('\n')}`, + ] + : []; +} + +const message = [ + ...lintFix(), + // dead links, missing variant notes or bad manifests + ...regenerate({ + what: 'Skill references', + from: 'docs', + script: 'website/framework-docs/skillReferences.mjs', + check: 'skills', + isInput: isSkillInput, + outputs: ['.agents/skills/*/references/*'], + }), + ...regenerate({ + what: 'Claude Code rules', + from: 'Cursor rules', + script: 'scripts/agent-rules.mjs', + check: 'agent-rules', + // sources only; a hand edit to the output is left to CI + isInput: file => + /(^|\/)\.cursor\/rules\//.test(file) || + file === 'scripts/agent-rules.mjs', + outputs: [':(glob)**/.claude/rules/**', '.claude/skills'], + }), +] + .filter(Boolean) + .join('\n\n'); +if (!message) process.exit(0); +console.log( + JSON.stringify( + payload.hook_event_name === 'PreToolUse' ? + { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: message, + }, + } + : { permission: 'deny', userMessage: message, agentMessage: message }, + ), +); +process.exit(0); diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index ee55396f3216..ccf049377d2d 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -27,6 +27,7 @@ alwaysApply: false - Workflows install only needed workspaces via `./scripts/ci-install.sh [extra-workspace ...]`. - `skills.yml` `paths` must cover every input of `website/framework-docs/skillReferences.mjs` (docs, skill manifests, the generator and its deps). +- `agent-rules.yml` runs `scripts/agent-rules.mjs --check` and the agent hook tests (`node --test '.cursor/hooks/*.test.js'`; a bare directory runs nothing on Node 22) with no install (node and git only). Its `paths` must cover every input and output of that script, and `.cursor/hooks/`. - `editor-types.yml` reruns `yarn copy:websitetypes` and fails if `website/src/components/Playground/editor-types` changes. It needs the `website` workspace (for deps like `bignumber.js`), which CircleCI's `setup` drops. Its `paths` must cover every input of `scripts/copywebsitetypes.sh`. - `site-preview.yml` runs one `build` job (one install for the typecheck and the build). It builds the site directly (no Vercel CLI, only the packages it imports via `ci:build:website`, `VERCEL_ENV=preview` to include drafts), restores Docusaurus' webpack cache (only master pushes save it, so PRs share one entry), and fails on any `[WARNING]`/`[ERROR]` line. Broken links are `warn` in `docusaurus.config.ts` so this check catches them without failing Vercel deploys. - Production docs deploys come from Vercel's Git integration only (gated by `vercel-ignore.sh`); there is no Actions deploy workflow. Vercel clones ~10 commits deep, so `website/scripts/deepenGitHistory.cjs` (called from `docusaurus.config.ts`) fetches 800 more for the "Last updated" dates. diff --git a/.github/workflows/agent-rules.yml b/.github/workflows/agent-rules.yml new file mode 100644 index 000000000000..b89125a0e923 --- /dev/null +++ b/.github/workflows/agent-rules.yml @@ -0,0 +1,45 @@ +name: agent-rules +on: + # master catches drift from PRs merged in sequence + push: + branches: + - master + # Inputs and outputs of scripts/agent-rules.mjs, and the hook tests + paths: + - '**/.cursor/rules/**' + - '**/.claude/rules/**' + - '.claude/skills' + - '.cursor/hooks/**' + - 'scripts/agent-rules.mjs' + - '.github/workflows/agent-rules.yml' + pull_request: + branches: + - master + paths: + - '**/.cursor/rules/**' + - '**/.claude/rules/**' + - '.claude/skills' + - '.cursor/hooks/**' + - 'scripts/agent-rules.mjs' + - '.github/workflows/agent-rules.yml' + +concurrency: + group: agent-rules-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 1 + - uses: actions/setup-node@v6 + with: + node-version: '26' + # no install: these only need node and git + - name: Check Claude Code rules match the Cursor rules + run: node scripts/agent-rules.mjs --check + - name: Test agent hooks + run: node --test '.cursor/hooks/*.test.js' diff --git a/.gitignore b/.gitignore index 00bfab817f2c..513a874c4095 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,7 @@ typings/ # generated Vue mirror of docs/core (website/framework-docs) docs/.core-* + +# Claude Code +.claude/worktrees/ +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index ffae6f190e08..661eea640c53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,8 +42,10 @@ Any user-facing change in `packages/*` requires a changeset. Core packages are v - **Documentation**: `docs/core/api`, `docs/rest`, `docs/core/guides` - **Tests**: `packages/*/src/**/__tests__` - **Benchmarks**: `examples/benchmark` (Node: core/normalizr/endpoint throughput), `examples/benchmark-react` (browser: React rendering and data-library comparison). See `.cursor/rules/benchmarking.mdc` and each example’s README. -- **Skills**: `.agents/skills/` (Cursor, Codex, and other agents) +- **Skills**: `.agents/skills/` (Cursor, Codex, and other agents; `.claude/skills` links to it for Claude Code) - `references/*.md` listed in a skill's `references.json` are generated from `docs/`; edit the doc, never the reference. `yarn build:skills` regenerates them (an agent pre-push hook makes sure they are committed) and the `skills` CI check fails on drift. +- **Agent rules**: `.cursor/rules/*.mdc` (and nested `/.cursor/rules`) are the source for both Cursor and Claude Code. `yarn build:agent-rules` generates `.claude/rules/*.md` from them (`globs` become `paths`); never edit those. Every rule needs `globs` or `alwaysApply: true`; guidance pulled in by description alone belongs in a skill. The pre-push hook and the `agent-rules` CI check catch drift. +- **Agent hooks**: `.cursor/hooks/` scripts are wired in both `.cursor/hooks.json` and `.claude/settings.json`: `eslint-fix.js` fixes uncommitted JS/TS once at the end of each turn and hands errors it can't fix back to the agent, and `pre-push.js` regenerates files and lint-fixes what a push includes. Keep hooks cheap: batch per turn or push, never per edit. ## Key Principles diff --git a/package.json b/package.json index fa47761f947d..d093d895c466 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "build:copy:ambient": "mkdirp ./packages/endpoint/lib && copyfiles --flat ./packages/endpoint/src/schema.d.ts ./packages/endpoint/lib/ && copyfiles --flat ./packages/endpoint/src/endpoint.d.ts ./packages/endpoint/lib/ && mkdirp ./packages/rest/lib && copyfiles --flat ./packages/rest/src/RestEndpoint.d.ts ./packages/rest/lib && copyfiles --flat ./packages/rest/src/next/RestEndpoint.d.ts ./packages/rest/lib/next && mkdirp ./packages/react/lib && copyfiles --flat ./packages/react/src/server/redux/redux.d.ts ./packages/react/lib/server/redux", "copy:websitetypes": "yarn ci:build:types && ./scripts/copywebsitetypes.sh", "build:skills": "yarn workspace rdc-website build:skills", + "build:agent-rules": "node scripts/agent-rules.mjs", "test": "NODE_ENV=test run jest", "test:ci": "ANANSI_JEST_TYPECHECK=false yarn test --ci", "test:coverage": "ANANSI_JEST_TYPECHECK=false yarn test --coverage --selectProjects ReactDOM Node --coverageThreshold='{\"global\":{\"statements\":98,\"branches\":96,\"functions\":90,\"lines\":98}}'", diff --git a/scripts/agent-rules.mjs b/scripts/agent-rules.mjs new file mode 100644 index 000000000000..2b4f465d2003 --- /dev/null +++ b/scripts/agent-rules.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// Mirrors the Cursor agent setup for Claude Code so both harnesses get the +// same steering from one source: +// - every `/.cursor/rules/.mdc` becomes `/.claude/rules/.md` +// with its `globs` as Claude's `paths`. Same depth, so relative links still +// resolve, and nested rules keep loading only for files in their folder. +// - `.claude/skills` links to `.agents/skills` +// AGENTS.md needs nothing: Claude Code reads it natively while the repo has +// no CLAUDE.md. +// +// Usage: node scripts/agent-rules.mjs [--check] +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const check = process.argv.includes('--check'); +const SKILLS_LINK = '.claude/skills'; +const SKILLS_TARGET = '../.agents/skills'; + +const lsFiles = glob => + execFileSync( + 'git', + [ + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '--', + `:(glob)${glob}`, + ], + { cwd: root, encoding: 'utf8' }, + ) + .split('\n') + .filter(file => file && fs.existsSync(path.join(root, file))); + +const problems = []; +/** @type {Map} output path -> content */ +const outputs = new Map(); + +for (const source of lsFiles('**/.cursor/rules/*.mdc')) { + const text = fs.readFileSync(path.join(root, source), 'utf8'); + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text); + if (!match) { + problems.push(`${source}: missing frontmatter`); + continue; + } + // Cursor's own frontmatter: single-line `key: value`, `globs` comma-separated + const meta = Object.fromEntries( + match[1] + .split(/\r?\n/) + .map(line => /^(\w+):\s*(.*)$/.exec(line)) + .filter(Boolean) + .map(([, key, value]) => [key, value.trim()]), + ); + // folder holding `.cursor` ('' at the root) + const base = source.replace(/(^|\/)\.cursor\/rules\/[^/]+$/, ''); + // Claude resolves a nested rule's `paths` from its folder; accept globs + // written from either the repo root or that folder + const globs = (meta.globs ?? '') + .split(',') + .map(glob => glob.trim()) + .filter(Boolean) + .map(glob => + base && glob.startsWith(`${base}/`) ? glob.slice(base.length + 1) : glob, + ); + const alwaysApply = meta.alwaysApply === 'true'; + if (!alwaysApply && !globs.length) { + problems.push( + `${source}: rules need \`globs\` or \`alwaysApply: true\`. Claude Code has no description-only rules; put guidance the agent pulls in by description in a skill (.agents/skills) instead`, + ); + continue; + } + const output = path.posix.join( + base, + '.claude/rules', + path.posix.basename(source, '.mdc') + '.md', + ); + const frontmatter = + alwaysApply ? '' : ( + `---\npaths:\n${globs.map(glob => ` - ${JSON.stringify(glob)}\n`).join('')}---\n` + ); + outputs.set( + output, + `${frontmatter}\n\n${text.slice(match[0].length)}`, + ); +} + +const changed = []; +for (const file of lsFiles('**/.claude/rules/**')) { + if (outputs.has(file)) continue; + changed.push(`${file} (no source rule)`); + if (!check) fs.rmSync(path.join(root, file)); +} +for (const [file, content] of outputs) { + const abs = path.join(root, file); + if (fs.existsSync(abs) && fs.readFileSync(abs, 'utf8') === content) continue; + changed.push(file); + if (!check) { + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } +} + +const link = path.join(root, SKILLS_LINK); +let linkTarget; +try { + linkTarget = fs.readlinkSync(link); +} catch { + // missing, or a checkout without symlinks +} +if (linkTarget !== SKILLS_TARGET) { + changed.push(`${SKILLS_LINK} (must be a symlink to ${SKILLS_TARGET})`); + if (!check) { + fs.rmSync(link, { recursive: true, force: true }); + fs.symlinkSync(SKILLS_TARGET, link, 'dir'); + } +} + +if (problems.length) { + console.error(problems.join('\n')); + process.exit(1); +} +if (check && changed.length) { + console.error( + `Claude Code agent config is out of date with the Cursor rules. Run \`yarn build:agent-rules\` and commit:\n${changed.join('\n')}`, + ); + process.exit(1); +} +console.log( + changed.length ? + `Updated:\n${changed.join('\n')}` + : 'Claude Code rules are up to date.', +); diff --git a/website/blog/.claude/rules/blog-posts.md b/website/blog/.claude/rules/blog-posts.md new file mode 100644 index 000000000000..c2771f6d5749 --- /dev/null +++ b/website/blog/.claude/rules/blog-posts.md @@ -0,0 +1,158 @@ +--- +paths: + - "**" +--- + + + +## Naming Convention + +Files: `YYYY-MM-DD-vX.Y-short-description.md` + +## Frontmatter + +- Title format: `vX.Y: Feature1, Feature2, Feature3` or `vX.Y Feature-focused Title` +- Include `releases` tag plus relevant feature tags from [tags.yml](./tags.yml) +- `draft: true` for unpublished/WIP posts + +## Package Layers + +Organize content by package layer. Both feature sections and migration guides follow this order: + +| Layer | Packages | Content | +|-------|----------|---------| +| **Platforms** | Integration | NextJS, Expo, React Native, Vue, React versions | +| **Client/Platform** | react, vue, core | Hooks, composables, Controller, Managers, Provider | +| **API Definition** | rest, endpoint, graphql | Resource, RestEndpoint, Entity, Collection, schemas | +| **Internal** | normalizr, core | State structure, normalization, typing | + +## Blog Structure + +**Summary (before `{/* truncate */}`):** +1. Platforms/major features (highest visibility) +2. Package consolidations (import path changes) +3. New APIs (hooks, controller methods, schemas) +4. Other Improvements (bulleted list) +5. Breaking Changes (with anchor links to migration sections) + +**Details (after truncate):** +1. Major features (with ``, `` demos) +2. New/changed APIs (with `` examples) +3. Performance (with `` if benchmarked) +4. Other improvements +5. Migration guide + +Within each section: explanation → code example → PR/commit links + +## Migration Guide + +Start with ``, end with Upgrade support blurb. + +**By Package** - Group changes by package when multiple packages have breaking changes: +- `### @data-client/react X.Y` - Client/Platform changes +- `### @data-client/rest X.Y` - API Definition changes + +**By Audience** - Within packages (or standalone), group by who's affected: +- `### For all users` / `#### For all users` +- `### For custom Manager authors {#custom-managers}` - Action/middleware changes +- `### For custom Schema authors {#custom-schemas}` - normalize/denormalize changes + +Add skip guidance for specialized sections: "Skip this section if you don't have custom [Managers](/docs/concepts/managers)." + +Use `` for before/after. End with: + +```md +### Upgrade support + +As usual, if you have any troubles or questions, feel free to join our [![Chat](https://img.shields.io/discord/768254430381735967.svg?style=flat-square&colorB=758ED3)](https://discord.gg/wXGV27xm6t) or [file a bug](https://github.com/reactive/data-client/issues/new/choose) +``` + +## Components + +| Component | Use For | +|-----------|---------| +| `` | Interactive demos with fixtures | +| `` | Embedded app demos (todo-app, coin-app) | +| `` | Type-safe code examples | +| `` | Before/After migration changes (with `caption` and `// callout:` annotations) | +| `` | Package installation | +| `` | Recorded clips (SSR/navigation, DevTools). Record with [scripts/videos](../../scripts/videos/README.md); pass `alt` and put the code it shows in a fenced block next to it | + +## DiffEditor Annotations + +Let the diff carry its reason instead of a paragraph of prose above it: + +- `caption="..."` - one line on why the change is needed, shown above the diff. Backticks render as code. +- `// callout: reason` - on its own line inside a fence; annotates the next code line with a numbered marker (①, ②, …) and lists the reason under the diff. Consecutive `// callout:` lines join into one callout. Use for the *why* of a specific line, instead of trailing code comments that would show up in the diff. + +````mdx + + +```ts title="After" +class LensSchema { + constructor({ lens }) { + // callout: The function reference is the cache key, so set it once here. + this.lensSelector = lens; + } +} +``` + + +```` + +Caption and callout text render as plain HTML outside the editor, so they are in the static page for search engines. Keep each to one sentence; anything longer belongs in the prose. + +## Performance Sections + +1. Explain what was optimized +2. Quantify with multipliers ("2x", "16x") +3. Link PR and docs +4. Visualize with ``, not mermaid `xychart-beta` (it has no legend, so overlaid bars are unlabeled) +5. Link to [benchmarks](https://reactive.github.io/data-client/dev/bench/) + +`` renders a bar chart of each row's speedup. The raw numbers appear on hover or tap, and stay in the +page text for crawlers and screen readers. Pass raw measurements, not percentages; the component computes the multiplier. + +```mdx +import PerfChart from '@site/src/components/PerfChart'; + + +``` + +`unit` defaults to `ms`, with lower being better; set `higherIsBetter` and `unit="ops/sec"` for throughput. +Bar lengths switch to a log scale on their own when speedups differ by more than 10x, and the chart labels it. +Each bar is shaded up to a dashed 1x line (no change), so the bright part is the gain. A row that got slower shows a red, +dashed gap between its bar and 1x; include regressions rather than dropping them. When charts sit next to each other, pass +the same `scaleMax` (the largest speedup among them) so their 1x lines line up. + +For several metrics per row (like time and memory), use `` instead of a markdown table. Each cell shows +paired before/after bars and a colored percent-change badge, with exact numbers on hover or tap; lower is better. + +```mdx +import PerfTable from '@site/src/components/PerfTable'; + + +``` + +## Conventions + +- Each change links to PR `[#1234](https://github.com/reactive/data-client/pull/1234)` and relevant docs +- Use `:::note` / `:::tip` / `:::warning` for callouts +- Use `// highlight-next-line` for code emphasis +- Imports go after frontmatter; place migration-only imports after truncate