From 33817814588f07be4eac8729d844a7336f5b8c97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:57:59 +0000 Subject: [PATCH 01/24] internal: Give Claude Code the same agent rules, skills and hooks as Cursor Cursor rules stay the single source: `yarn build:agent-rules` generates `.claude/rules` from every `**/.cursor/rules/*.mdc` (globs become paths), `.claude/skills` links to `.agents/skills`, and the pre-push hook plus a new `agent-rules` CI check catch drift. AGENTS.md already loads natively. - interface-design (description-only rule) becomes a skill, the shared mechanism both harnesses pull in by description - SessionStart hook runs `.cursor/worktrees.json` setup in fresh `claude --worktree` checkouts - build-skills hook renamed pre-push and also regenerates rules Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .../skills/interface-design/SKILL.md | 4 +- .claude/hooks/worktree-setup.js | 34 ++ .claude/rules/agents-md-authoring.md | 40 +++ .claude/rules/benchmarking.md | 318 ++++++++++++++++++ .claude/rules/breaking-changes.md | 28 ++ .claude/rules/ci-config.md | 42 +++ .claude/rules/library-goals.md | 14 + .claude/rules/markdown-formatting.md | 28 ++ .claude/rules/skills-sync.md | 19 ++ .claude/settings.json | 15 +- .claude/skills | 1 + .cursor/hooks.json | 2 +- .../hooks/{build-skills.js => pre-push.js} | 115 ++++--- .cursor/rules/ci-config.mdc | 1 + .github/workflows/agent-rules.yml | 41 +++ .gitignore | 4 + AGENTS.md | 4 +- package.json | 1 + scripts/agent-rules.mjs | 135 ++++++++ website/blog/.claude/rules/blog-posts.md | 158 +++++++++ 20 files changed, 955 insertions(+), 49 deletions(-) rename .cursor/rules/interface-design.mdc => .agents/skills/interface-design/SKILL.md (97%) create mode 100644 .claude/hooks/worktree-setup.js create mode 100644 .claude/rules/agents-md-authoring.md create mode 100644 .claude/rules/benchmarking.md create mode 100644 .claude/rules/breaking-changes.md create mode 100644 .claude/rules/ci-config.md create mode 100644 .claude/rules/library-goals.md create mode 100644 .claude/rules/markdown-formatting.md create mode 100644 .claude/rules/skills-sync.md create mode 120000 .claude/skills rename .cursor/hooks/{build-skills.js => pre-push.js} (55%) create mode 100644 .github/workflows/agent-rules.yml create mode 100644 scripts/agent-rules.mjs create mode 100644 website/blog/.claude/rules/blog-posts.md 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..7d9ea801f640 --- /dev/null +++ b/.claude/hooks/worktree-setup.js @@ -0,0 +1,34 @@ +/* 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(); +// 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(path.join(projectDir, 'node_modules'))) + 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); + } +} +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..9a6eccc392ff --- /dev/null +++ b/.claude/rules/ci-config.md @@ -0,0 +1,42 @@ +--- +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. + +## 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` with no install (node and git only). Its `paths` must cover every input and output of that script. +- `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`/`site-release.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:`. + +## 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..2a8123f21758 --- /dev/null +++ b/.claude/rules/skills-sync.md @@ -0,0 +1,19 @@ +--- +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. +- **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..8b7ec7313d9d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,20 @@ "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 } ] } 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..ea9e9baacd6b 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -8,7 +8,7 @@ ], "beforeShellExecution": [ { - "command": "node .cursor/hooks/build-skills.js" + "command": "node .cursor/hooks/pre-push.js" } ] } diff --git a/.cursor/hooks/build-skills.js b/.cursor/hooks/pre-push.js similarity index 55% rename from .cursor/hooks/build-skills.js rename to .cursor/hooks/pre-push.js index 4f343f1dcf69..33a374bcc077 100644 --- a/.cursor/hooks/build-skills.js +++ b/.cursor/hooks/pre-push.js @@ -1,9 +1,11 @@ /* 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. +// `PreToolUse` on Bash), regenerates agent skill references and Claude Code's +// copy of the Cursor rules 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. +// 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'); @@ -24,6 +26,7 @@ const gitCommand = sub => 'm', ); if (!gitCommand('push').test(command)) process.exit(0); +const commits = gitCommand('commit').test(command); const projectDir = process.env.CURSOR_PROJECT_DIR || @@ -54,68 +57,92 @@ function isSkillDoc(file) { } return skillDocs.has(file.replace(/\.(react|vue)(\.mdx?)$/, '$2')); } -const isInput = file => +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, plus uncommitted ones when -// the same command commits before pushing (`git commit -am x && git push`) +// the same command commits before pushing (`git commit -am x && git push`); +// renames as delete + add, so the old path counts too +let dirty, committed; try { - // renames as delete + add, so the old path counts too - const dirty = git( - 'status', - '--porcelain', - '--no-renames', - '--untracked-files=all', - ) + 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( + .map(line => line.slice(3)); + committed = git( 'diff', '--name-only', '--no-renames', 'origin/master...HEAD', - ) - .split('\n') - .some(isInput); - if (!dirty && !committed) process.exit(0); + ).split('\n'); } 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(); +/** + * Runs the generator `script` when the branch changes a file `isInput` + * matches, then reports problems it printed and `outputs` (pathspecs) left + * uncommitted + */ +function regenerate({ what, from, script, yarn, ci, 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 ${yarn}\` found problems the ${ci} CI check will fail on. Fix them, commit, then push again:\n${problems}`, + ]; } -// 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}`, + // dead links, missing variant notes or bad manifests + ...regenerate({ + what: 'Skill references', + from: 'docs', + script: 'website/framework-docs/skillReferences.mjs', + yarn: 'build:skills', + ci: 'skills', + isInput: isSkillInput, + outputs: ['.agents/skills/*/references/*'], + }), + ...regenerate({ + what: 'Claude Code rules', + from: 'Cursor rules', + script: 'scripts/agent-rules.mjs', + yarn: 'build:agent-rules', + ci: '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' ? diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index c3a1f356b2d0..4581c750d7af 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -26,6 +26,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` with no install (node and git only). Its `paths` must cover every input and output of that script. - `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`/`site-release.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. diff --git a/.github/workflows/agent-rules.yml b/.github/workflows/agent-rules.yml new file mode 100644 index 000000000000..c848ccb0e586 --- /dev/null +++ b/.github/workflows/agent-rules.yml @@ -0,0 +1,41 @@ +name: agent-rules +on: + # master catches drift from PRs merged in sequence + push: + branches: + - master + # Inputs and outputs of scripts/agent-rules.mjs + paths: + - '**/.cursor/rules/**' + - '**/.claude/rules/**' + - '.claude/skills' + - 'scripts/agent-rules.mjs' + - '.github/workflows/agent-rules.yml' + pull_request: + branches: + - master + paths: + - '**/.cursor/rules/**' + - '**/.claude/rules/**' + - '.claude/skills' + - 'scripts/agent-rules.mjs' + - '.github/workflows/agent-rules.yml' + +concurrency: + group: agent-rules-${{ github.head_ref || github.ref }} + 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: the script only needs node and git + - name: Check Claude Code rules match the Cursor rules + run: node scripts/agent-rules.mjs --check 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..9fbb79a13d1d 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**: scripts live in `.cursor/hooks/` (wired in `.cursor/hooks.json`; `.claude/settings.json` runs the same `pre-push.js`). Keep them cheap: run once before push, not per edit or turn. ## Key Principles diff --git a/package.json b/package.json index d361fc7aae16..464a9ae0d260 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,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 From 8db41973de43bce11868af537873c5fad0079dbe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:13:03 +0000 Subject: [PATCH 02/24] internal: Run eslint --fix before push instead of per edit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks.json | 5 ----- .cursor/hooks/eslint-fix.js | 36 ------------------------------------ .cursor/hooks/pre-push.js | 32 ++++++++++++++++++++++++++++++-- AGENTS.md | 2 +- 4 files changed, 31 insertions(+), 44 deletions(-) delete mode 100644 .cursor/hooks/eslint-fix.js diff --git a/.cursor/hooks.json b/.cursor/hooks.json index ea9e9baacd6b..30b3ffdeb4c5 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -1,11 +1,6 @@ { "version": 1, "hooks": { - "afterFileEdit": [ - { - "command": "node .cursor/hooks/eslint-fix.js" - } - ], "beforeShellExecution": [ { "command": "node .cursor/hooks/pre-push.js" diff --git a/.cursor/hooks/eslint-fix.js b/.cursor/hooks/eslint-fix.js deleted file mode 100644 index 237b389b27f2..000000000000 --- a/.cursor/hooks/eslint-fix.js +++ /dev/null @@ -1,36 +0,0 @@ -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.cwd(); -const normalizedProject = path.resolve(projectDir) + path.sep; -const normalizedFile = path.resolve(filePath); - -if (!normalizedFile.startsWith(normalizedProject)) process.exit(0); - -const ext = path.extname(normalizedFile); -const allowed = new Set(['.js', '.jsx', '.ts', '.tsx', '.cts', '.mts']); -if (!allowed.has(ext)) process.exit(0); - -try { - execFileSync('yarn', ['eslint', '--fix', '--', normalizedFile], { - stdio: 'ignore', - }); -} catch { - // Ignore lint failures to avoid blocking the agent loop. -} - -process.exit(0); diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index 33a374bcc077..da7328212bc7 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -1,8 +1,9 @@ /* 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, and holds the -// push until the result is committed. +// 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. @@ -116,7 +117,34 @@ function regenerate({ what, from, script, yarn, ci, isInput, outputs }) { ]; } +/** `eslint --fix` the branch's JS/TS files; reports the ones it changed */ +function lintFix() { + const files = [...new Set([...committed, ...(commits ? dirty : [])])].filter( + file => + /\.(c|m)?[jt]sx?$/.test(file) && + fs.existsSync(path.join(projectDir, file)), + ); + if (!files.length) return []; + const read = file => fs.readFileSync(path.join(projectDir, file), 'utf8'); + const before = files.map(read); + try { + execFileSync( + path.join(projectDir, 'node_modules/.bin/eslint'), + ['--fix', '--no-warn-ignored', '--', ...files], + { cwd: projectDir, stdio: 'ignore' }, + ); + } catch { + // unfixable lint errors are left to CI, like a missing install + } + const fixed = files.filter((file, i) => read(file) !== before[i]); + return [ + fixed.length && + `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${fixed.join('\n')}`, + ]; +} + const message = [ + ...lintFix(), // dead links, missing variant notes or bad manifests ...regenerate({ what: 'Skill references', diff --git a/AGENTS.md b/AGENTS.md index 9fbb79a13d1d..4f460adb25fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Any user-facing change in `packages/*` requires a changeset. Core packages are v - **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**: scripts live in `.cursor/hooks/` (wired in `.cursor/hooks.json`; `.claude/settings.json` runs the same `pre-push.js`). Keep them cheap: run once before push, not per edit or turn. +- **Agent hooks**: `.cursor/hooks/pre-push.js` (regenerated files, `eslint --fix`) is wired in both `.cursor/hooks.json` and `.claude/settings.json`. Keep hooks cheap: run once before push, not per edit or turn. ## Key Principles From b5e5ad2522243e47be0488ddc22166965f8faf54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:14:58 +0000 Subject: [PATCH 03/24] internal: Rerun worktree setup until it succeeds Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .claude/hooks/worktree-setup.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/worktree-setup.js b/.claude/hooks/worktree-setup.js index 7d9ea801f640..2708385f17cd 100644 --- a/.claude/hooks/worktree-setup.js +++ b/.claude/hooks/worktree-setup.js @@ -7,14 +7,15 @@ 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(path.join(projectDir, 'node_modules'))) - process.exit(0); +if (!isWorktree || fs.existsSync(done)) process.exit(0); const commands = JSON.parse( @@ -31,4 +32,6 @@ for (const command of commands) { process.exit(0); } } +fs.mkdirSync(path.dirname(done), { recursive: true }); +fs.writeFileSync(done, ''); console.log(`Worktree set up: ${commands.join(' && ')}`); From 38c4a6a4b5074e45fea3c577bbd9dcc164443028 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:16:26 +0000 Subject: [PATCH 04/24] internal: Skip lint-fixing files with uncommitted edits on push Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/pre-push.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index da7328212bc7..c96f548e139f 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -119,7 +119,12 @@ function regenerate({ what, from, script, yarn, ci, isInput, outputs }) { /** `eslint --fix` the branch's JS/TS files; reports the ones it changed */ function lintFix() { - const files = [...new Set([...committed, ...(commits ? dirty : [])])].filter( + // eslint reads the working tree, so like `regenerate()` it skips files with + // uncommitted edits unless this command commits them; CI lint covers those + const files = ( + commits ? + [...new Set([...committed, ...dirty])] + : committed.filter(file => !dirty.includes(file))).filter( file => /\.(c|m)?[jt]sx?$/.test(file) && fs.existsSync(path.join(projectDir, file)), From dca38d4ee60f7cea6cd7b87de353899363eb1935 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:20:31 +0000 Subject: [PATCH 05/24] internal: Simplify pre-push lint file selection and cache eslint Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/pre-push.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index c96f548e139f..a2939704c7ec 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -119,14 +119,15 @@ function regenerate({ what, from, script, yarn, ci, isInput, outputs }) { /** `eslint --fix` the branch's JS/TS files; reports the ones it changed */ function lintFix() { - // eslint reads the working tree, so like `regenerate()` it skips files with - // uncommitted edits unless this command commits them; CI lint covers those - const files = ( + // eslint reads the working tree, so skip files with uncommitted edits unless + // this command commits them (per file, since eslint reads only those) + const pushed = commits ? [...new Set([...committed, ...dirty])] - : committed.filter(file => !dirty.includes(file))).filter( + : committed.filter(file => !dirty.includes(file)); + const files = pushed.filter( file => - /\.(c|m)?[jt]sx?$/.test(file) && + /\.[cm]?[jt]sx?$/.test(file) && fs.existsSync(path.join(projectDir, file)), ); if (!files.length) return []; @@ -135,17 +136,18 @@ function lintFix() { try { execFileSync( path.join(projectDir, 'node_modules/.bin/eslint'), - ['--fix', '--no-warn-ignored', '--', ...files], + ['--fix', '--cache', '--no-warn-ignored', '--', ...files], { cwd: projectDir, stdio: 'ignore' }, ); } catch { // unfixable lint errors are left to CI, like a missing install } const fixed = files.filter((file, i) => read(file) !== before[i]); - return [ - fixed.length && - `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${fixed.join('\n')}`, - ]; + return fixed.length ? + [ + `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${fixed.join('\n')}`, + ] + : []; } const message = [ From c2682f4e8a38a08980f5f6b423097b75f0a6d0df Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:47:28 +0000 Subject: [PATCH 06/24] internal: Lint uncommitted JS/TS at the end of each agent turn Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .claude/settings.json | 10 +++++++ .cursor/hooks.json | 5 ++++ .cursor/hooks/eslint-fix.js | 56 +++++++++++++++++++++++++++++++++++++ .cursor/hooks/pre-push.js | 23 +++------------ 4 files changed, 75 insertions(+), 19 deletions(-) create mode 100644 .cursor/hooks/eslint-fix.js diff --git a/.claude/settings.json b/.claude/settings.json index 8b7ec7313d9d..fecd4d379464 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -23,6 +23,16 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.cursor/hooks/eslint-fix.js\"" + } + ] + } ] } } diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 30b3ffdeb4c5..037f3abe714f 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -5,6 +5,11 @@ { "command": "node .cursor/hooks/pre-push.js" } + ], + "stop": [ + { + "command": "node .cursor/hooks/eslint-fix.js" + } ] } } diff --git a/.cursor/hooks/eslint-fix.js b/.cursor/hooks/eslint-fix.js new file mode 100644 index 000000000000..8501740271e5 --- /dev/null +++ b/.cursor/hooks/eslint-fix.js @@ -0,0 +1,56 @@ +/* global require, module */ +// `eslint --fix` for agent hooks. Run directly, it's the end-of-turn hook +// (Cursor `stop`, Claude Code `Stop`): fixes every uncommitted JS/TS file at +// once, so edits from the agent and from someone editing alongside it are +// batched into one run per turn instead of one per edit. `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'); + +/** Fixes the JS/TS `files` that exist; returns the ones eslint changed */ +function eslintFix(projectDir, files) { + files = files.filter( + file => + /\.[cm]?[jt]sx?$/.test(file) && + fs.existsSync(path.join(projectDir, file)), + ); + if (!files.length) return []; + const read = file => fs.readFileSync(path.join(projectDir, file), 'utf8'); + const before = files.map(read); + try { + execFileSync( + path.join(projectDir, 'node_modules/.bin/eslint'), + ['--fix', '--cache', '--no-warn-ignored', '--', ...files], + { cwd: projectDir, stdio: 'ignore' }, + ); + } catch { + // unfixable lint errors are left to CI, like a missing install + } + return files.filter((file, i) => read(file) !== before[i]); +} +module.exports = { eslintFix }; + +if (require.main === module) { + const projectDir = + process.env.CURSOR_PROJECT_DIR || + process.env.CLAUDE_PROJECT_DIR || + process.cwd(); + try { + const dirty = execFileSync( + 'git', + ['status', '--porcelain', '--no-renames', '--untracked-files=all'], + { + cwd: projectDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }, + ) + .split('\n') + .filter(Boolean) + .map(line => line.slice(3)); + eslintFix(projectDir, dirty); + } catch { + // not a git checkout + } +} diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index a2939704c7ec..d16c761e2177 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -11,6 +11,8 @@ const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +const { eslintFix } = require('./eslint-fix'); + let payload = {}; try { payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); @@ -117,7 +119,7 @@ function regenerate({ what, from, script, yarn, ci, isInput, outputs }) { ]; } -/** `eslint --fix` the branch's JS/TS files; reports the ones it changed */ +/** `eslint --fix` the JS/TS files this push includes */ function lintFix() { // eslint reads the working tree, so skip files with uncommitted edits unless // this command commits them (per file, since eslint reads only those) @@ -125,24 +127,7 @@ function lintFix() { commits ? [...new Set([...committed, ...dirty])] : committed.filter(file => !dirty.includes(file)); - const files = pushed.filter( - file => - /\.[cm]?[jt]sx?$/.test(file) && - fs.existsSync(path.join(projectDir, file)), - ); - if (!files.length) return []; - const read = file => fs.readFileSync(path.join(projectDir, file), 'utf8'); - const before = files.map(read); - try { - execFileSync( - path.join(projectDir, 'node_modules/.bin/eslint'), - ['--fix', '--cache', '--no-warn-ignored', '--', ...files], - { cwd: projectDir, stdio: 'ignore' }, - ); - } catch { - // unfixable lint errors are left to CI, like a missing install - } - const fixed = files.filter((file, i) => read(file) !== before[i]); + const fixed = eslintFix(projectDir, pushed); return fixed.length ? [ `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${fixed.join('\n')}`, From aced1f1c6ae1b8f0fa2da7a0a51b7a51d95bdc2e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:47:52 +0000 Subject: [PATCH 07/24] docs: Describe end-of-turn lint hook in AGENTS.md Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4f460adb25fb..c30749dcd3e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Any user-facing change in `packages/*` requires a changeset. Core packages are v - **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/pre-push.js` (regenerated files, `eslint --fix`) is wired in both `.cursor/hooks.json` and `.claude/settings.json`. Keep hooks cheap: run once before push, not per edit or turn. +- **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 `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 From eed7844380371f917ca442c43dba4c08b8022179 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:55:47 +0000 Subject: [PATCH 08/24] internal: Skip end-of-turn eslint when no uncommitted JS/TS changed; share hook helpers Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/eslint-fix.js | 70 ++++++++++++++++++++++--------------- .cursor/hooks/pre-push.js | 12 ++----- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/.cursor/hooks/eslint-fix.js b/.cursor/hooks/eslint-fix.js index 8501740271e5..c7e85345108e 100644 --- a/.cursor/hooks/eslint-fix.js +++ b/.cursor/hooks/eslint-fix.js @@ -1,27 +1,52 @@ /* global require, module */ // `eslint --fix` for agent hooks. Run directly, it's the end-of-turn hook -// (Cursor `stop`, Claude Code `Stop`): fixes every uncommitted JS/TS file at -// once, so edits from the agent and from someone editing alongside it are -// batched into one run per turn instead of one per edit. `pre-push.js` also -// uses it for the files a push includes. +// (Cursor `stop`, Claude Code `Stop`): fixes the uncommitted JS/TS files +// changed since eslint 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. `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 projectDir = + process.env.CURSOR_PROJECT_DIR || + process.env.CLAUDE_PROJECT_DIR || + process.cwd(); +const CACHE = path.join(projectDir, '.eslintcache'); +const isLintable = file => /\.[cm]?[jt]sx?$/.test(file); +const mtime = file => + fs.statSync(path.join(projectDir, file), { throwIfNoEntry: false })?.mtimeMs; + +/** Uncommitted files; renames as delete + add, so the old path counts too */ +const dirtyFiles = () => + execFileSync( + 'git', + ['status', '--porcelain', '--no-renames', '--untracked-files=all'], + { cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ) + .split('\n') + .filter(Boolean) + .map(line => line.slice(3)); + /** Fixes the JS/TS `files` that exist; returns the ones eslint changed */ -function eslintFix(projectDir, files) { - files = files.filter( - file => - /\.[cm]?[jt]sx?$/.test(file) && - fs.existsSync(path.join(projectDir, file)), - ); +function eslintFix(files) { + files = files.filter(file => isLintable(file) && mtime(file) !== undefined); if (!files.length) return []; const read = file => fs.readFileSync(path.join(projectDir, file), 'utf8'); const before = files.map(read); try { execFileSync( path.join(projectDir, 'node_modules/.bin/eslint'), - ['--fix', '--cache', '--no-warn-ignored', '--', ...files], + [ + '--fix', + '--cache', + '--cache-location', + CACHE, + '--no-warn-ignored', + '--', + ...files, + ], { cwd: projectDir, stdio: 'ignore' }, ); } catch { @@ -29,28 +54,15 @@ function eslintFix(projectDir, files) { } return files.filter((file, i) => read(file) !== before[i]); } -module.exports = { eslintFix }; if (require.main === module) { - const projectDir = - process.env.CURSOR_PROJECT_DIR || - process.env.CLAUDE_PROJECT_DIR || - process.cwd(); try { - const dirty = execFileSync( - 'git', - ['status', '--porcelain', '--no-renames', '--untracked-files=all'], - { - cwd: projectDir, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }, - ) - .split('\n') - .filter(Boolean) - .map(line => line.slice(3)); - eslintFix(projectDir, dirty); + // eslint rewrites its cache on every run, so older files were already fixed + const lastRun = fs.statSync(CACHE, { throwIfNoEntry: false })?.mtimeMs ?? 0; + eslintFix(dirtyFiles().filter(file => mtime(file) > lastRun)); } catch { // not a git checkout } } + +module.exports = { projectDir, dirtyFiles, eslintFix }; diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index d16c761e2177..74af21a45a8a 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -11,7 +11,7 @@ const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); -const { eslintFix } = require('./eslint-fix'); +const { projectDir, dirtyFiles, eslintFix } = require('./eslint-fix'); let payload = {}; try { @@ -31,10 +31,6 @@ const gitCommand = sub => if (!gitCommand('push').test(command)) process.exit(0); const commits = gitCommand('commit').test(command); -const projectDir = - process.env.CURSOR_PROJECT_DIR || - process.env.CLAUDE_PROJECT_DIR || - process.cwd(); const git = (...args) => execFileSync('git', args, { cwd: projectDir, @@ -70,9 +66,7 @@ const isSkillInput = file => // renames as delete + add, so the old path counts too let dirty, committed; try { - dirty = git('status', '--porcelain', '--no-renames', '--untracked-files=all') - .split('\n') - .map(line => line.slice(3)); + dirty = dirtyFiles(); committed = git( 'diff', '--name-only', @@ -127,7 +121,7 @@ function lintFix() { commits ? [...new Set([...committed, ...dirty])] : committed.filter(file => !dirty.includes(file)); - const fixed = eslintFix(projectDir, pushed); + const fixed = eslintFix(pushed); return fixed.length ? [ `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${fixed.join('\n')}`, From 99b457fa465f4435cd1c5d2bc66c2c3b13a5bdd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:59:18 +0000 Subject: [PATCH 09/24] internal: Track end-of-turn lint with its own marker instead of .eslintcache Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/eslint-fix.js | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/.cursor/hooks/eslint-fix.js b/.cursor/hooks/eslint-fix.js index c7e85345108e..2dd37dd62a96 100644 --- a/.cursor/hooks/eslint-fix.js +++ b/.cursor/hooks/eslint-fix.js @@ -1,7 +1,7 @@ /* 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 eslint last ran, so edits from the agent and from someone +// 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. `pre-push.js` also uses it // for the files a push includes. @@ -14,6 +14,12 @@ const projectDir = 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; @@ -56,10 +62,30 @@ function eslintFix(files) { } if (require.main === module) { + // stamped with the start time, so edits made while eslint runs count next + // turn; holds the mtimes eslint left, so its own fixes don't + const start = new Date(); try { - // eslint rewrites its cache on every run, so older files were already fixed - const lastRun = fs.statSync(CACHE, { throwIfNoEntry: false })?.mtimeMs ?? 0; - eslintFix(dirtyFiles().filter(file => mtime(file) > lastRun)); + 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 files = dirtyFiles().filter( + file => mtime(file) > lastRun && mtime(file) !== linted[file], + ); + eslintFix(files); + fs.mkdirSync(path.dirname(LAST_RUN), { recursive: true }); + fs.writeFileSync( + LAST_RUN, + JSON.stringify( + Object.fromEntries(files.map(file => [file, mtime(file)])), + ), + ); + fs.utimesSync(LAST_RUN, start, start); } catch { // not a git checkout } From e8d9cce2150af2a4d5d8677af238cfbf1a39d7b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 21:05:12 +0000 Subject: [PATCH 10/24] internal: Hand unfixable end-of-turn lint errors back to the agent Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/eslint-fix.js | 69 ++++++++++++++++++++++++++++++++----- .cursor/hooks/pre-push.js | 2 +- AGENTS.md | 2 +- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/.cursor/hooks/eslint-fix.js b/.cursor/hooks/eslint-fix.js index 2dd37dd62a96..2b76e90f27ff 100644 --- a/.cursor/hooks/eslint-fix.js +++ b/.cursor/hooks/eslint-fix.js @@ -3,7 +3,8 @@ // (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. `pre-push.js` also uses it +// 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'); @@ -35,14 +36,18 @@ const dirtyFiles = () => .filter(Boolean) .map(line => line.slice(3)); -/** Fixes the JS/TS `files` that exist; returns the ones eslint changed */ +/** + * Fixes the JS/TS `files` that exist; returns the ones eslint changed 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 []; + if (!files.length) return { fixed: [], errors: [] }; const read = file => fs.readFileSync(path.join(projectDir, file), 'utf8'); const before = files.map(read); + let report = '[]'; try { - execFileSync( + report = execFileSync( path.join(projectDir, 'node_modules/.bin/eslint'), [ '--fix', @@ -50,21 +55,52 @@ function eslintFix(files) { '--cache-location', CACHE, '--no-warn-ignored', + '--format', + 'json', '--', ...files, ], - { cwd: projectDir, stdio: 'ignore' }, + { + 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; + } + let errors = []; + try { + errors = JSON.parse(report).flatMap(({ filePath, messages }) => + messages + .filter(({ severity }) => severity === 2) + .map( + ({ line, column, message, ruleId }) => + `${path.relative(projectDir, filePath)}:${line}:${column} ${message}${ruleId ? ` (${ruleId})` : ''}`, + ), ); } catch { - // unfixable lint errors are left to CI, like a missing install + // not eslint's report } - return files.filter((file, i) => read(file) !== before[i]); + return { + fixed: files.filter((file, i) => read(file) !== before[i]), + errors, + }; } if (require.main === module) { // stamped with the start time, so edits made while eslint runs count next // turn; holds the mtimes eslint left, so its own fixes don't const start = new Date(); + let payload = {}; + try { + payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); + } catch { + // run by hand + } try { const lastRun = fs.statSync(LAST_RUN, { throwIfNoEntry: false })?.mtimeMs ?? 0; @@ -77,7 +113,7 @@ if (require.main === module) { const files = dirtyFiles().filter( file => mtime(file) > lastRun && mtime(file) !== linted[file], ); - eslintFix(files); + const { errors } = eslintFix(files); fs.mkdirSync(path.dirname(LAST_RUN), { recursive: true }); fs.writeFileSync( LAST_RUN, @@ -86,6 +122,23 @@ if (require.main === module) { ), ); 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 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 } diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index 74af21a45a8a..881d536cbf35 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -121,7 +121,7 @@ function lintFix() { commits ? [...new Set([...committed, ...dirty])] : committed.filter(file => !dirty.includes(file)); - const fixed = eslintFix(pushed); + const { fixed } = eslintFix(pushed); return fixed.length ? [ `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${fixed.join('\n')}`, diff --git a/AGENTS.md b/AGENTS.md index c30749dcd3e6..661eea640c53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Any user-facing change in `packages/*` requires a changeset. Core packages are v - **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 `pre-push.js` regenerates files and lint-fixes what a push includes. Keep hooks cheap: batch per turn or push, never per edit. +- **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 From 0b4edcac2676d906bcf4107d619c797ed1356b31 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 21:08:42 +0000 Subject: [PATCH 11/24] internal: Scope lint follow-up to the agent's own files; don't wait on a TTY Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/eslint-fix.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.cursor/hooks/eslint-fix.js b/.cursor/hooks/eslint-fix.js index 2b76e90f27ff..b96f69e971c0 100644 --- a/.cursor/hooks/eslint-fix.js +++ b/.cursor/hooks/eslint-fix.js @@ -97,7 +97,9 @@ if (require.main === module) { const start = new Date(); let payload = {}; try { - payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); + payload = JSON.parse( + (!process.stdin.isTTY && fs.readFileSync(0, 'utf8')) || '{}', + ); } catch { // run by hand } @@ -130,7 +132,7 @@ if (require.main === module) { 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 them:\n${errors.slice(0, 50).join('\n')}${errors.length > 50 ? `\n…and ${errors.length - 50} more` : ''}`; + 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' ? From 0d9c4d05da05263eefd49f82436d5f3b3d62270a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 21:19:29 +0000 Subject: [PATCH 12/24] internal: Simplify agent hooks: share git helper, use eslint's fix report, derive build script from check Co-authored-by: Nathaniel Tucker --- .cursor/hooks/eslint-fix.js | 48 +++++++++++++++++++++---------------- .cursor/hooks/pre-push.js | 25 +++++++------------ 2 files changed, 35 insertions(+), 38 deletions(-) diff --git a/.cursor/hooks/eslint-fix.js b/.cursor/hooks/eslint-fix.js index b96f69e971c0..505ab712c640 100644 --- a/.cursor/hooks/eslint-fix.js +++ b/.cursor/hooks/eslint-fix.js @@ -25,13 +25,16 @@ const isLintable = file => /\.[cm]?[jt]sx?$/.test(file); const mtime = file => fs.statSync(path.join(projectDir, file), { throwIfNoEntry: false })?.mtimeMs; +const git = (...args) => + execFileSync('git', args, { + cwd: projectDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trimEnd(); + /** Uncommitted files; renames as delete + add, so the old path counts too */ const dirtyFiles = () => - execFileSync( - 'git', - ['status', '--porcelain', '--no-renames', '--untracked-files=all'], - { cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, - ) + git('status', '--porcelain', '--no-renames', '--untracked-files=all') .split('\n') .filter(Boolean) .map(line => line.slice(3)); @@ -43,8 +46,6 @@ const dirtyFiles = () => function eslintFix(files) { files = files.filter(file => isLintable(file) && mtime(file) !== undefined); if (!files.length) return { fixed: [], errors: [] }; - const read = file => fs.readFileSync(path.join(projectDir, file), 'utf8'); - const before = files.map(read); let report = '[]'; try { report = execFileSync( @@ -72,22 +73,26 @@ function eslintFix(files) { // to CI if (err.status === 1) report = err.stdout; } - let errors = []; + let results = []; try { - errors = JSON.parse(report).flatMap(({ filePath, messages }) => + results = JSON.parse(report); + } catch { + // not eslint's report + } + const relative = filePath => path.relative(projectDir, filePath); + return { + // eslint reports `output` only for files its fixes changed + fixed: results + .filter(({ output }) => output !== undefined) + .map(({ filePath }) => relative(filePath)), + errors: results.flatMap(({ filePath, messages }) => messages .filter(({ severity }) => severity === 2) .map( ({ line, column, message, ruleId }) => - `${path.relative(projectDir, filePath)}:${line}:${column} ${message}${ruleId ? ` (${ruleId})` : ''}`, + `${relative(filePath)}:${line}:${column} ${message}${ruleId ? ` (${ruleId})` : ''}`, ), - ); - } catch { - // not eslint's report - } - return { - fixed: files.filter((file, i) => read(file) !== before[i]), - errors, + ), }; } @@ -112,9 +117,10 @@ if (require.main === module) { } catch { // first run } - const files = dirtyFiles().filter( - file => mtime(file) > lastRun && mtime(file) !== linted[file], - ); + const files = dirtyFiles().filter(file => { + const modified = mtime(file); + return modified > lastRun && modified !== linted[file]; + }); const { errors } = eslintFix(files); fs.mkdirSync(path.dirname(LAST_RUN), { recursive: true }); fs.writeFileSync( @@ -146,4 +152,4 @@ if (require.main === module) { } } -module.exports = { projectDir, dirtyFiles, eslintFix }; +module.exports = { projectDir, git, dirtyFiles, eslintFix }; diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index 881d536cbf35..e992b09b4dce 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -11,7 +11,7 @@ const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); -const { projectDir, dirtyFiles, eslintFix } = require('./eslint-fix'); +const { projectDir, git, dirtyFiles, eslintFix } = require('./eslint-fix'); let payload = {}; try { @@ -31,13 +31,6 @@ const gitCommand = sub => if (!gitCommand('push').test(command)) process.exit(0); const commits = gitCommand('commit').test(command); -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) { @@ -78,11 +71,11 @@ try { } /** - * Runs the generator `script` when the branch changes a file `isInput` - * matches, then reports problems it printed and `outputs` (pathspecs) left - * uncommitted + * 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, yarn, ci, isInput, outputs }) { +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 @@ -109,7 +102,7 @@ function regenerate({ what, from, script, yarn, ci, isInput, outputs }) { uncommitted && `${what} generated from this branch's ${from} changes aren't committed. Commit them, then push again:\n${uncommitted}`, problems && - `\`yarn ${yarn}\` found problems the ${ci} CI check will fail on. Fix them, commit, then push again:\n${problems}`, + `\`yarn build:${check}\` found problems the ${check} CI check will fail on. Fix them, commit, then push again:\n${problems}`, ]; } @@ -136,8 +129,7 @@ const message = [ what: 'Skill references', from: 'docs', script: 'website/framework-docs/skillReferences.mjs', - yarn: 'build:skills', - ci: 'skills', + check: 'skills', isInput: isSkillInput, outputs: ['.agents/skills/*/references/*'], }), @@ -145,8 +137,7 @@ const message = [ what: 'Claude Code rules', from: 'Cursor rules', script: 'scripts/agent-rules.mjs', - yarn: 'build:agent-rules', - ci: 'agent-rules', + check: 'agent-rules', // sources only; a hand edit to the output is left to CI isInput: file => /(^|\/)\.cursor\/rules\//.test(file) || From 4a8204dc6a56f0ba5cbc2c4aee16d4c8bc2f0a05 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 21:36:44 +0000 Subject: [PATCH 13/24] internal: Regenerate Claude Code rules from master's Cursor rule changes Co-authored-by: Nathaniel Tucker --- .claude/rules/ci-config.md | 2 ++ .claude/rules/skills-sync.md | 1 + 2 files changed, 3 insertions(+) diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md index 9a6eccc392ff..ede0c29c1e22 100644 --- a/.claude/rules/ci-config.md +++ b/.claude/rules/ci-config.md @@ -23,6 +23,7 @@ paths: - `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/`) @@ -30,6 +31,7 @@ paths: - `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` with no install (node and git only). Its `paths` must cover every input and output of that script. - `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. - `site-preview.yml`/`site-release.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:`. diff --git a/.claude/rules/skills-sync.md b/.claude/rules/skills-sync.md index 2a8123f21758..86dfac203850 100644 --- a/.claude/rules/skills-sync.md +++ b/.claude/rules/skills-sync.md @@ -15,5 +15,6 @@ Skill `references/*.md` files listed in a skill's `references.json` are generate - **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`. From 43bac4a0ae2d679d5dd7e18c72f8891a141da6b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 22:01:46 +0000 Subject: [PATCH 14/24] internal: Don't record edits made during end-of-turn eslint as already linted The stop hook stamped LAST_RUN with the run's start time so edits during eslint are picked up next turn, but then stored each chosen file's mtime from after the run. A save that landed on one of those files matched linted[file], so the next turn skipped it until the file was touched again. Remember the mtime from when the file was chosen, and only replace it with the mtime eslint left when that fix is still the bytes on disk. Co-authored-by: Nathaniel Tucker --- .cursor/hooks/eslint-fix.js | 57 +++++++++---- .cursor/hooks/eslint-fix.test.js | 136 +++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 .cursor/hooks/eslint-fix.test.js diff --git a/.cursor/hooks/eslint-fix.js b/.cursor/hooks/eslint-fix.js index 505ab712c640..55ba2e3e9828 100644 --- a/.cursor/hooks/eslint-fix.js +++ b/.cursor/hooks/eslint-fix.js @@ -40,12 +40,13 @@ const dirtyFiles = () => .map(line => line.slice(3)); /** - * Fixes the JS/TS `files` that exist; returns the ones eslint changed and the - * errors it couldn't fix, one `file:line:col message (rule)` each + * Fixes the JS/TS `files` that exist; returns the ones eslint 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 { fixed: [], errors: [] }; + if (!files.length) return { fixed: [], fixedMtimes: {}, errors: [] }; let report = '[]'; try { report = execFileSync( @@ -79,12 +80,32 @@ function eslintFix(files) { } catch { // not eslint's report } - const relative = filePath => path.relative(projectDir, filePath); + 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 { // eslint reports `output` only for files its fixes changed - fixed: results - .filter(({ output }) => output !== undefined) - .map(({ filePath }) => relative(filePath)), + fixed, + fixedMtimes, errors: results.flatMap(({ filePath, messages }) => messages .filter(({ severity }) => severity === 2) @@ -97,8 +118,11 @@ function eslintFix(files) { } if (require.main === module) { - // stamped with the start time, so edits made while eslint runs count next - // turn; holds the mtimes eslint left, so its own fixes don't + // 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 { @@ -117,18 +141,17 @@ if (require.main === module) { } catch { // first run } + const mtimes = {}; const files = dirtyFiles().filter(file => { const modified = mtime(file); - return modified > lastRun && modified !== linted[file]; + if (!(modified > lastRun && modified !== linted[file])) return false; + mtimes[file] = modified; + return true; }); - const { errors } = eslintFix(files); + const { fixedMtimes, errors } = eslintFix(files); + Object.assign(mtimes, fixedMtimes); fs.mkdirSync(path.dirname(LAST_RUN), { recursive: true }); - fs.writeFileSync( - LAST_RUN, - JSON.stringify( - Object.fromEntries(files.map(file => [file, mtime(file)])), - ), - ); + 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 diff --git a/.cursor/hooks/eslint-fix.test.js b/.cursor/hooks/eslint-fix.test.js new file mode 100644 index 000000000000..03914b7c9c79 --- /dev/null +++ b/.cursor/hooks/eslint-fix.test.js @@ -0,0 +1,136 @@ +/* global require */ +// `node --test .cursor/hooks/eslint-fix.test.js` +const assert = require('assert/strict'); +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { test } = require('node:test'); + +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 || '{}'); +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); + }); +}); From 70345678fea7b10d6ec0dae0baffb96f8b92af22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 22:03:13 +0000 Subject: [PATCH 15/24] internal: Format the end-of-turn eslint hook test Co-authored-by: Nathaniel Tucker --- .cursor/hooks/eslint-fix.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.cursor/hooks/eslint-fix.test.js b/.cursor/hooks/eslint-fix.test.js index 03914b7c9c79..6fa078cc3df1 100644 --- a/.cursor/hooks/eslint-fix.test.js +++ b/.cursor/hooks/eslint-fix.test.js @@ -3,9 +3,9 @@ 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 { test } = require('node:test'); const hook = path.join(__dirname, 'eslint-fix.js'); const future = new Date('2030-01-01T00:00:00Z'); @@ -97,7 +97,10 @@ test('an edit that lands during eslint is linted next turn', () => { 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[0].slice().sort(), [ + 'src/edited.js', + 'src/fixed.js', + ]); assert.deepEqual(calls[1], ['src/edited.js']); }); }); From c39e937f17fa73e0249577d7a6d44cb1f6b3ec36 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 22:20:38 +0000 Subject: [PATCH 16/24] internal: Run the agent hook tests in the agent-rules CI check Co-authored-by: Nathaniel Tucker --- .claude/rules/ci-config.md | 2 +- .cursor/rules/ci-config.mdc | 2 +- .github/workflows/agent-rules.yml | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md index ede0c29c1e22..9eac36be9956 100644 --- a/.claude/rules/ci-config.md +++ b/.claude/rules/ci-config.md @@ -29,7 +29,7 @@ paths: - 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` with no install (node and git only). Its `paths` must cover every input and output of that script. +- `agent-rules.yml` runs `scripts/agent-rules.mjs --check` and the agent hook tests (`node --test .cursor/hooks/`) 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. - `site-preview.yml`/`site-release.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index 92d7046f8ef5..265b259b33cb 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -27,7 +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` with no install (node and git only). Its `paths` must cover every input and output of that script. +- `agent-rules.yml` runs `scripts/agent-rules.mjs --check` and the agent hook tests (`node --test .cursor/hooks/`) 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. - `site-preview.yml`/`site-release.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. diff --git a/.github/workflows/agent-rules.yml b/.github/workflows/agent-rules.yml index c848ccb0e586..aa7764b8a1d8 100644 --- a/.github/workflows/agent-rules.yml +++ b/.github/workflows/agent-rules.yml @@ -4,11 +4,12 @@ on: push: branches: - master - # Inputs and outputs of scripts/agent-rules.mjs + # 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: @@ -18,6 +19,7 @@ on: - '**/.cursor/rules/**' - '**/.claude/rules/**' - '.claude/skills' + - '.cursor/hooks/**' - 'scripts/agent-rules.mjs' - '.github/workflows/agent-rules.yml' @@ -36,6 +38,8 @@ jobs: - uses: actions/setup-node@v6 with: node-version: '26' - # no install: the script only needs node and git + # 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/ From 29874fd9db5ca19dc458efe2aaaea21affb1239a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:21:33 +0000 Subject: [PATCH 17/24] internal: Run agent hook tests by glob so they run on any Node version Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .claude/rules/ci-config.md | 2 +- .cursor/rules/ci-config.mdc | 2 +- .github/workflows/agent-rules.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md index 9eac36be9956..c79a878c0ba0 100644 --- a/.claude/rules/ci-config.md +++ b/.claude/rules/ci-config.md @@ -29,7 +29,7 @@ paths: - 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/`) with no install (node and git only). Its `paths` must cover every input and output of that script, and `.cursor/hooks/`. +- `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. - `site-preview.yml`/`site-release.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index 265b259b33cb..bcbb0ebe9e33 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -27,7 +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/`) with no install (node and git only). Its `paths` must cover every input and output of that script, and `.cursor/hooks/`. +- `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. - `site-preview.yml`/`site-release.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. diff --git a/.github/workflows/agent-rules.yml b/.github/workflows/agent-rules.yml index aa7764b8a1d8..3395d0d28bba 100644 --- a/.github/workflows/agent-rules.yml +++ b/.github/workflows/agent-rules.yml @@ -42,4 +42,4 @@ jobs: - 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/ + run: node --test '.cursor/hooks/*.test.js' From 87c24ab5c6109fc35970360d6fb7c714ba876659 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:25:59 +0000 Subject: [PATCH 18/24] internal: Pre-push lints only uncommitted files the same command's commit fully takes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/eslint-fix.test.js | 4 ++-- .cursor/hooks/pre-push.js | 36 +++++++++++++++++++++++++------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/.cursor/hooks/eslint-fix.test.js b/.cursor/hooks/eslint-fix.test.js index 6fa078cc3df1..d257a4874cb2 100644 --- a/.cursor/hooks/eslint-fix.test.js +++ b/.cursor/hooks/eslint-fix.test.js @@ -1,5 +1,5 @@ -/* global require */ -// `node --test .cursor/hooks/eslint-fix.test.js` +/* global require, __dirname */ +// `node --test '.cursor/hooks/*.test.js'` const assert = require('assert/strict'); const { execFileSync } = require('child_process'); const fs = require('fs'); diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index e992b09b4dce..10e4c52b2374 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -30,6 +30,12 @@ const gitCommand = sub => ); 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( + command, + ); /** Docs some skill renders; partials (`_foo.mdx`) may be inlined anywhere */ let skillDocs; @@ -54,9 +60,8 @@ const isSkillInput = 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`); -// renames as delete + add, so the old path counts too +// 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(); @@ -108,12 +113,27 @@ function regenerate({ what, from, script, check, isInput, outputs }) { /** `eslint --fix` the JS/TS files this push includes */ function lintFix() { - // eslint reads the working tree, so skip files with uncommitted edits unless - // this command commits them (per file, since eslint reads only those) - const pushed = + // 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 ? - [...new Set([...committed, ...dirty])] - : committed.filter(file => !dirty.includes(file)); + 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); return fixed.length ? [ From 01b99722b90c3f9b62708d8fe8abec280f8c15eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:27:24 +0000 Subject: [PATCH 19/24] internal: Regenerate Claude Code rules from master's ci-config rule Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .claude/rules/ci-config.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.claude/rules/ci-config.md b/.claude/rules/ci-config.md index c79a878c0ba0..5e034b5d9e2c 100644 --- a/.claude/rules/ci-config.md +++ b/.claude/rules/ci-config.md @@ -32,9 +32,16 @@ paths: - `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. -- `site-preview.yml`/`site-release.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. +- 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`) From d1cfe5e7f1e60fae12c773e31ffe720f3d27ed9d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:27:48 +0000 Subject: [PATCH 20/24] internal: Never cancel agent-rules runs on master Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .github/workflows/agent-rules.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agent-rules.yml b/.github/workflows/agent-rules.yml index 3395d0d28bba..b89125a0e923 100644 --- a/.github/workflows/agent-rules.yml +++ b/.github/workflows/agent-rules.yml @@ -24,7 +24,7 @@ on: - '.github/workflows/agent-rules.yml' concurrency: - group: agent-rules-${{ github.head_ref || github.ref }} + group: agent-rules-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: From 708464962462ff78db825b54f25085f469c6b1ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:37:48 +0000 Subject: [PATCH 21/24] internal: Hold pushes until hook lint fixes are committed; don't mistake message text for -a; retry after an eslint crash Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/eslint-fix.js | 17 ++++++++++++----- .cursor/hooks/eslint-fix.test.js | 11 +++++++++++ .cursor/hooks/pre-push.js | 29 ++++++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/.cursor/hooks/eslint-fix.js b/.cursor/hooks/eslint-fix.js index 55ba2e3e9828..20e1ec7c1b25 100644 --- a/.cursor/hooks/eslint-fix.js +++ b/.cursor/hooks/eslint-fix.js @@ -40,13 +40,15 @@ const dirtyFiles = () => .map(line => line.slice(3)); /** - * Fixes the JS/TS `files` that exist; returns the ones eslint changed, the - * mtime of each fix still on disk, and the errors it couldn't fix, one - * `file:line:col message (rule)` each + * 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 { fixed: [], fixedMtimes: {}, errors: [] }; + if (!files.length) + return { ok: true, fixed: [], fixedMtimes: {}, errors: [] }; + let ok = true; let report = '[]'; try { report = execFileSync( @@ -73,6 +75,7 @@ function eslintFix(files) { // 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 { @@ -103,6 +106,8 @@ function eslintFix(files) { } } return { + // false when eslint crashed or isn't installed + ok, // eslint reports `output` only for files its fixes changed fixed, fixedMtimes, @@ -148,7 +153,9 @@ if (require.main === module) { mtimes[file] = modified; return true; }); - const { fixedMtimes, errors } = eslintFix(files); + 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)); diff --git a/.cursor/hooks/eslint-fix.test.js b/.cursor/hooks/eslint-fix.test.js index d257a4874cb2..2abcac526883 100644 --- a/.cursor/hooks/eslint-fix.test.js +++ b/.cursor/hooks/eslint-fix.test.js @@ -20,6 +20,7 @@ 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); @@ -137,3 +138,13 @@ test('an edit after the run is linted on the next turn', () => { 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 index 10e4c52b2374..801597178012 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -34,7 +34,8 @@ const commits = gitCommand('commit').test(command); const commitsAll = commits && /(?:^|[;&|(]\s*)git\b[^;&|]*\scommit\b[^;&|]*\s(?:--all\b|-[A-Za-z]*a)/m.test( - command, + // not a commit message mentioning `-a` or `--all` + command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, "''"), ); /** Docs some skill renders; partials (`_foo.mdx`) may be inlined anywhere */ @@ -111,6 +112,8 @@ function regenerate({ what, from, script, check, isInput, outputs }) { ]; } +const PENDING = path.join(projectDir, 'node_modules/.cache/pre-push-fixed'); + /** `eslint --fix` the JS/TS files this push includes */ function lintFix() { // eslint reads the working tree, so lint an uncommitted file only when this @@ -135,9 +138,29 @@ function lintFix() { ...committing, ]; const { fixed } = eslintFix(pushed); - return fixed.length ? + // 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 + let pending = []; + try { + pending = JSON.parse(fs.readFileSync(PENDING, 'utf8')); + } catch { + // none yet + } + const unpushed = [ + ...new Set([ + ...fixed, + ...pending.filter(file => dirty.includes(file) && !committing.has(file)), + ]), + ]; + try { + fs.mkdirSync(path.dirname(PENDING), { recursive: true }); + fs.writeFileSync(PENDING, JSON.stringify(unpushed)); + } catch { + // no install + } + return unpushed.length ? [ - `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${fixed.join('\n')}`, + `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${unpushed.join('\n')}`, ] : []; } From 8812fc91492222d7d7fccb4df3e9183c0e115616 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:44:26 +0000 Subject: [PATCH 22/24] internal: Stop holding pushes once a commit has taken the hook's lint fix Pending fixes are kept with the file's HEAD blob, so a commit that changes the file clears them, and a push that runs `git add` in the same command isn't held. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/pre-push.js | 45 ++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index 801597178012..6b0cb913e78c 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -30,6 +30,7 @@ const gitCommand = sub => ); if (!gitCommand('push').test(command)) process.exit(0); const commits = gitCommand('commit').test(command); +const stages = gitCommand('add').test(command); // `git commit -a` / `-am` / `--all` const commitsAll = commits && @@ -114,6 +115,20 @@ function regenerate({ what, from, script, check, isInput, outputs }) { 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 @@ -139,28 +154,38 @@ function lintFix() { ]; 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 - let pending = []; + // 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 unpushed = [ - ...new Set([ - ...fixed, - ...pending.filter(file => dirty.includes(file) && !committing.has(file)), - ]), - ]; + 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 } - return unpushed.length ? + // a `git add` in this command may commit them; check again on the next push + const held = Object.keys(unpushed).filter( + file => fixed.includes(file) || !stages, + ); + return held.length ? [ - `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${unpushed.join('\n')}`, + `\`eslint --fix\` changed files this push would include. Commit them, then push again:\n${held.join('\n')}`, ] : []; } From f8e9542ae3d1f7b2c1948e3deee1db695ef4cdf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:57:10 +0000 Subject: [PATCH 23/24] internal: Hold earlier lint fixes unless this command's git add stages them Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/pre-push.js | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index 6b0cb913e78c..f6bf254b9155 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -30,7 +30,22 @@ const gitCommand = sub => ); if (!gitCommand('push').test(command)) process.exit(0); const commits = gitCommand('commit').test(command); -const stages = gitCommand('add').test(command); +// arguments to `git add`s in a command that also commits +const added = + commits && + [...command.matchAll(/(?:^|[;&|(]\s*)git\s+add\b([^;&|]*)/gm)] + .flatMap(([, args]) => args.trim().split(/\s+/)) + .filter(Boolean) + .map(arg => arg.replace(/^(['"])(.*)\1$/, '$2')); +const isAdded = file => + added && + added.some( + arg => + /^(-[A-Za-z]*[Au][A-Za-z]*|--all|--update|\.\/?)$/.test(arg) || + (!arg.startsWith('-') && + (file === path.normalize(arg) || + file.startsWith(path.normalize(arg).replace(/\/?$/, '/')))), + ); // `git commit -a` / `-am` / `--all` const commitsAll = commits && @@ -179,9 +194,9 @@ function lintFix() { } catch { // no install } - // a `git add` in this command may commit them; check again on the next push + // a `git add` this command commits with takes them; check again next push const held = Object.keys(unpushed).filter( - file => fixed.includes(file) || !stages, + file => fixed.includes(file) || !isAdded(file), ); return held.length ? [ From e6bcdba49b554499a1ee39901a627568335d3dfd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 23:05:25 +0000 Subject: [PATCH 24/24] internal: Always hold earlier lint fixes until a commit has them Guessing what a `git add` in the same command stages kept missing cases (pathspecs with -A, cd, Windows paths). Holding costs one extra push. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bz2B6nekuZ6XvJbMDRfpCU --- .cursor/hooks/pre-push.js | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/.cursor/hooks/pre-push.js b/.cursor/hooks/pre-push.js index f6bf254b9155..cfcfc205423d 100644 --- a/.cursor/hooks/pre-push.js +++ b/.cursor/hooks/pre-push.js @@ -30,22 +30,6 @@ const gitCommand = sub => ); if (!gitCommand('push').test(command)) process.exit(0); const commits = gitCommand('commit').test(command); -// arguments to `git add`s in a command that also commits -const added = - commits && - [...command.matchAll(/(?:^|[;&|(]\s*)git\s+add\b([^;&|]*)/gm)] - .flatMap(([, args]) => args.trim().split(/\s+/)) - .filter(Boolean) - .map(arg => arg.replace(/^(['"])(.*)\1$/, '$2')); -const isAdded = file => - added && - added.some( - arg => - /^(-[A-Za-z]*[Au][A-Za-z]*|--all|--update|\.\/?)$/.test(arg) || - (!arg.startsWith('-') && - (file === path.normalize(arg) || - file.startsWith(path.normalize(arg).replace(/\/?$/, '/')))), - ); // `git commit -a` / `-am` / `--all` const commitsAll = commits && @@ -194,10 +178,9 @@ function lintFix() { } catch { // no install } - // a `git add` this command commits with takes them; check again next push - const held = Object.keys(unpushed).filter( - file => fixed.includes(file) || !isAdded(file), - ); + // 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')}`,