diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 12dca64..204ca7a 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -11,6 +11,10 @@ on: description: Also query Context7 (anonymous tier) type: boolean default: true + variants: + description: 'Extra configurations, space-separated name:KEY=VALUE,KEY=VALUE (e.g. head0:LOCKDOCS_HEAD_WEIGHT=0)' + type: string + default: '' pull_request: paths: ['bench/**', '.github/workflows/bench.yml'] @@ -44,10 +48,13 @@ jobs: - name: Run (package files, then `lockdocs fetch` + upstream docs) env: C7: ${{ (github.event_name != 'workflow_dispatch' || inputs.context7) && '--context7' || '' }} + VARIANTS: ${{ inputs.variants }} GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail - python3 bench/run.py target/release/lockdocs bench/projects bench.json --fetch $C7 --context7-cache docs/public/bench-results.json | tee -a "$GITHUB_STEP_SUMMARY" + extra=() + for v in $VARIANTS; do extra+=(--variant "$v"); done + python3 bench/run.py target/release/lockdocs bench/projects bench.json --fetch $C7 --context7-cache docs/public/bench-results.json "${extra[@]}" | tee -a "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v7 with: name: bench diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c2f691..dcbb116 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,12 +50,13 @@ jobs: components: clippy, rustfmt - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - run: bun install --frozen-lockfile - - name: Manifests agree on one version and one tagline + - name: Manifests agree on one version and one tagline; capability paths exist env: GH_TOKEN: ${{ github.token }} run: | bun scripts/check-version.ts bun scripts/check-tagline.ts "$(gh api "repos/$GITHUB_REPOSITORY" --jq .description)" + bun scripts/check-capabilities.ts - name: Format and lints run: cargo fmt --all --check && cargo clippy --workspace --locked -- -D warnings - name: Build release binary diff --git a/AGENTS.md b/AGENTS.md index c6edf62..da308a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,10 @@ Read [PROJECT.md](PROJECT.md) for the layout and the release flow. run it in CI (`bench.yml`), not on a shared machine. - Keep the MCP surface at three tools: `resolve`, `docs`, `api`. - Bump `index::FORMAT` whenever extraction output or `Entry` changes, so old - caches are rebuilt. + caches are rebuilt; bump `upstream::FORMAT` when `fetch` downloads more, so + `lockdocs fetch` refreshes old copies. +- Ranking changes are judged on the whole benchmark in CI, never on one + question; questions marked `held-out` are not used for tuning. - A new lockfile format: add a pure parser to `lockfile.rs` with a unit test, and register it in `LOCKFILES` or `parse`. - One version everywhere: `bun scripts/set-version.ts` (CI runs diff --git a/CHANGELOG.md b/CHANGELOG.md index ef750e6..1dc703d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.3.0 + +- **Docs sites follow your major.** For packages whose docs live in a separate website repository, `lockdocs fetch` takes the docs for the pinned major: the default branch when your major is the latest, a `vN` / `N.x` branch when the site keeps one (Tailwind CSS v3, Prisma v6), or the last commit before the next major was released. Pages about a later major are skipped. React keeps the latest-only rule (react.dev documents APIs before they ship). tokio's website (tutorial and topics) is added, and docs sites now work for crates and PyPI packages too. +- **More of the docs are read.** Django's docs (reStructuredText in `.txt` files) were downloaded but not indexed; they are now. Docs pages written as React components (Tailwind's installation guides) are indexed. HTML headings in MDX split sections, and `export const title` names the page. +- **Ranking.** A second BM25 over just the heading (or name) and first sentence keeps a long body from burying what an entry says it is. Question words that name a documented top-level API of the package ("run code *after* the response" in Next.js, which exports `after`) count as identifiers. Generic headings (Parameters, Returns, Examples) take their topic from the heading above; MDX heading ids (`{/*usage*/}`) are dropped; capitalized words in headings stay whole (TypeScript no longer matches "type"). Upgrade guides to an older major than yours and pages titled "(Deprecated)" rank lower unless the question is about changes. Code-only sections are embedded with their code, not their title alone. The stemmer pairs -ation/-ate and -ability/-able, and the synonym table adds parameter/param, JavaScript/JS, TypeScript/TS and database/DB. +- **Answers.** The top result quotes the first paragraph of its page and parent section, with the short list or code block that follows, when they add something (React's "In React 19, forwardRef is no longer necessary" at the top of the page; the `@custom-variant` setup a Tailwind subsection builds on). +- **Fetch.** GitHub API redirects (renamed repositories) keep the token, docs-site errors appear in the fetch note, and `lockdocs fetch` refreshes copies made by older versions. +- **Benchmark:** 105 questions (was 70). 17 questions written as held-out were used to diagnose misses after their first run and joined the main set; 18 new held-out questions, not used for tuning, have their own column. Context7 answers are reused only when the question and its grading are unchanged, and a Context7 library that answers HTTP 404 falls back to the next search result. The pydantic 1 graders reject the v2 idiom `model_config = ConfigDict` instead of `ConfigDict`, which pydantic 1.10 also ships. `bench.yml` takes a `variants` input for weight sweeps. + ## 0.2.1 - **MCP server** now runs on [mcp-kit](https://github.com/SylphxAI/mcp-kit), which uses rmcp, the official Rust MCP SDK, instead of lockdocs' own JSON-RPC loop. Tools and answers are unchanged. The server now also handles protocol negotiation across every spec version, cancellation, progress and pagination. diff --git a/Cargo.lock b/Cargo.lock index f2a39a8..bc24ad8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -589,7 +589,7 @@ dependencies = [ [[package]] name = "lockdocs" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "lockdocs-core", @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "lockdocs-core" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "dirs 7.0.0", diff --git a/Cargo.toml b/Cargo.toml index b1be1cd..c8d7505 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/lockdocs-core", "crates/lockdocs"] resolver = "2" [workspace.package] -version = "0.2.1" +version = "0.3.0" edition = "2021" license = "MIT" repository = "https://github.com/SylphxAI/lockdocs" diff --git a/README.md b/README.md index b7d68e9..93b2531 100644 --- a/README.md +++ b/README.md @@ -121,18 +121,18 @@ Out of the box lockdocs reads only your disk (plus the one-time embedding model ## Benchmarks -70 questions whose correct answer depends on the version, over 15 libraries (zod, Next.js, React Router, pydantic, axum, tokio, Tailwind CSS, ESLint, Prisma, React, Vite, Express, SQLAlchemy, Django, FastAPI), each asked in a real project with that version installed. An answer passes when it contains the version-correct API and none of the other version's. Same questions and grader against Context7's anonymous API, on a GitHub-hosted runner ([run](https://github.com/SylphxAI/lockdocs/actions/runs/36125903106)): +105 questions whose correct answer depends on the version, over 15 libraries (zod, Next.js, React Router, pydantic, axum, tokio, Tailwind CSS, ESLint, Prisma, React, Vite, Express, SQLAlchemy, Django, FastAPI), each asked in a real project with that version installed. An answer passes when it contains the version-correct API and none of the other version's. Same questions and grader against Context7's anonymous API, on a GitHub-hosted runner ([run](https://github.com/SylphxAI/lockdocs/actions/runs/36204404336)): -| | correct | older majors | newer majors | tokio | median tokens | median latency | -|---|---|---|---|---|---|---| -| lockdocs + `lockdocs fetch` | 55/70 | 24/33 | 29/34 | 2/3 | 875 | 87 ms | -| lockdocs, package files only | 46/70 | 22/33 | 22/34 | 2/3 | 915 | 49 ms | -| Context7 (anonymous) | 49/70 | 12/33 | 34/34 | 3/3 | 908 | 2,011 ms | +| | correct | older majors | newer majors | tokio | held-out | median tokens | median latency | +|---|---|---|---|---|---|---|---| +| lockdocs + `lockdocs fetch` | 96/105 | 38/45 | 53/55 | 5/5 | 16/18 | 866 | 97 ms | +| lockdocs, package files only | 60/105 | 27/45 | 29/55 | 4/5 | 6/18 | 903 | 52 ms | +| Context7 (anonymous) | 77/105 | 19/45 | 53/55 | 5/5 | 15/18 | 908 | 2,583 ms | -- **Where versions matter most, lockdocs wins by 2x.** On older majors Context7 often answers with the newest API (all five pydantic 1 questions got pydantic 2 answers). -- **Context7 is ahead on the newest majors (34/34 vs 29/34) and on tokio (3/3 vs 2/3)**, and we are working to close that. Its index covers docs websites that no package or tag ships (Prisma's docs now describe a later major), and lockdocs has a few ranking misses. The benchmark page lists every question and answer. -- **~23x faster, no quota.** lockdocs latency is a fresh CLI process per question; `lockdocs fetch` is a one-time 0.6-5 s per project (median 2.4 s). +- **Where versions matter most, lockdocs wins by 2x** (38/45 vs 19/45 on older majors). Context7 often answers with the newest API (pydantic 1 questions get pydantic 2 answers). +- **Tied on the newest majors (53/55 each) and on tokio (5/5 each); ahead on held-out questions (16/18 vs 15/18)**, which were written before the ranking changes they measure and not used for tuning. +- **~27x faster, fewer tokens, no quota.** lockdocs latency is a fresh CLI process per question; `lockdocs fetch` is a one-time download per project. Method, questions, per-question results and scripts: [benchmark page](https://sylphxai.github.io/lockdocs/benchmarks) and [`bench/`](bench/). diff --git a/bench/questions.json b/bench/questions.json index faf4132..e6f9566 100644 --- a/bench/questions.json +++ b/bench/questions.json @@ -426,9 +426,9 @@ ] ], "reject": [ - "ConfigDict" + "model_config = ConfigDict" ], - "why": "v1: class Config: extra = Extra.allow", + "why": "v1: class Config: extra = Extra.allow. Rejects the v2 idiom model_config = ConfigDict (0 matches in pydantic 1.10.18 source and docs). Until 0.3 it rejected ConfigDict, which pydantic 1.10 also ships (config.py)", "line": "older" }, { @@ -1081,6 +1081,577 @@ "reject": [], "why": "16: middleware renamed to proxy (next 16.3.6 dist/server/web/types.d.ts: '@deprecated Use NextProxy instead. Middleware has been renamed to Proxy.')", "line": "newer" + }, + { + "id": "next14-params", + "project": "next14", + "package": "next", + "question": "How do I read the dynamic route parameters in a page component?", + "expect": [ + [ + "params" + ] + ], + "reject": [ + "await params", + "Promise<{" + ], + "why": "14: params is a plain object prop (no 'await params' in v14.2.35 docs/02-app/02-api-reference/02-file-conventions/page.mdx)", + "line": "older", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "next15-params", + "project": "next15", + "package": "next", + "question": "How do I read the dynamic route parameters in a page component?", + "expect": [ + [ + "await params", + "Promise<{" + ] + ], + "reject": [], + "why": "15: params is a Promise (v15.1.0 page.mdx, 18 matches)", + "line": "newer", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "tw3-dark", + "project": "tailwind3", + "package": "tailwindcss", + "question": "How do I switch dark mode to a class instead of the operating system setting?", + "expect": [ + [ + "darkMode" + ] + ], + "reject": [ + "@custom-variant" + ], + "why": "v3: darkMode: 'selector' / 'class' in tailwind.config.js (v3 site dark-mode.mdx)", + "line": "older", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "tw4-dark", + "project": "tailwind4", + "package": "tailwindcss", + "question": "How do I switch dark mode to a class instead of the operating system setting?", + "expect": [ + [ + "@custom-variant" + ] + ], + "reject": [], + "why": "v4: @custom-variant dark (&:where(.dark, .dark *)) in CSS (v4 site dark-mode.mdx)", + "line": "newer", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "react18-ref", + "project": "react18", + "package": "react", + "question": "How do I pass a ref through to a child component?", + "expect": [ + [ + "forwardRef" + ] + ], + "reject": [], + "why": "18: forwardRef (@types/react 18.3.31)", + "line": "older", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "react19-ref", + "project": "react19", + "package": "react", + "question": "How do I pass a ref through to a child component?", + "expect": [ + [ + "as a prop", + "no longer necessary" + ] + ], + "reject": [], + "why": "19: ref is a regular prop; forwardRef is no longer necessary (react.dev forwardRef.md)", + "line": "newer", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "rr6-types", + "project": "rr6", + "package": "react-router", + "question": "How do I get typed loader data in my route component?", + "expect": [ + [ + "useLoaderData" + ] + ], + "reject": [ + "Route.ComponentProps", + "+types" + ], + "why": "v6: useLoaderData() (docs/hooks/use-loader-data.md); generated route types arrived in v7", + "line": "older", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "rr7-types", + "project": "rr7", + "package": "react-router", + "question": "How do I get typed loader data in my route component?", + "expect": [ + [ + "Route.ComponentProps", + "+types" + ] + ], + "reject": [], + "why": "v7: generated ./+types/ and Route.ComponentProps (react-router@7.1.1 docs/start/framework/data-loading.md)", + "line": "newer", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "eslint8-globals", + "project": "eslint8", + "package": "eslint", + "question": "How do I tell ESLint about browser global variables?", + "expect": [ + [ + "\"browser\": true", + "browser: true" + ] + ], + "reject": [], + "why": "8: env: { browser: true } in .eslintrc (v8.57.1 language-options.md)", + "line": "older", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "eslint9-globals", + "project": "eslint9", + "package": "eslint", + "question": "How do I tell ESLint about browser global variables?", + "expect": [ + [ + "globals.browser" + ] + ], + "reject": [], + "why": "9: languageOptions: { globals: globals.browser } (v9.39.5 language-options.md)", + "line": "newer", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "dj51-generated", + "project": "django51", + "package": "django", + "question": "How do I add a model field whose value the database computes from other columns?", + "expect": [ + [ + "GeneratedField" + ] + ], + "reject": [], + "why": "5.0+: models.GeneratedField (5.1.15 docs/ref/models/fields.txt)", + "line": "newer", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "fa0115-querymodel", + "project": "fastapi0115", + "package": "fastapi", + "question": "How do I declare a group of query parameters with a Pydantic model?", + "expect": [ + [ + "Query Parameter Models", + "FilterParams", + "query parameter model" + ] + ], + "reject": [], + "why": "0.115.0+: query parameter models (docs/en/docs/tutorial/query-param-models.md)", + "line": "newer", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "tokio-channel", + "project": "axum08", + "package": "tokio", + "question": "How do I send messages from many tasks to a single consumer task?", + "expect": [ + [ + "mpsc" + ] + ], + "reject": [], + "why": "tokio::sync::mpsc", + "line": "single", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "zod3-datetime", + "project": "zod3", + "package": "zod", + "question": "How do I validate an ISO 8601 datetime string?", + "expect": [ + [ + ".datetime(" + ] + ], + "reject": [ + "z.iso." + ], + "why": "3.23: z.string().datetime(); z.iso arrived in zod 4 (0 matches in v3.23.8 README)", + "line": "older", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "zod4-datetime", + "project": "zod4", + "package": "zod", + "question": "How do I validate an ISO 8601 datetime string?", + "expect": [ + [ + "z.iso.datetime", + "iso.datetime(" + ] + ], + "reject": [], + "why": "4: z.iso.datetime() (v4.1.5 packages/docs/content/api.mdx)", + "line": "newer", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "pyd1-frozen", + "project": "pydantic1", + "package": "pydantic", + "question": "How do I make model instances immutable?", + "expect": [ + [ + "allow_mutation" + ] + ], + "reject": [ + "model_config = ConfigDict" + ], + "why": "v1: class Config: allow_mutation = False / frozen = True (v1.10.18 docs/usage/model_config.md). Rejects the v2 idiom model_config = ConfigDict", + "line": "older", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "pyd2-frozen", + "project": "pydantic2", + "package": "pydantic", + "question": "How do I make model instances immutable?", + "expect": [ + [ + "frozen=True" + ] + ], + "reject": [], + "why": "v2: model_config = ConfigDict(frozen=True) (v2.9.2 docs/concepts/models.md)", + "line": "newer", + "set": "tuning", + "history": "held-out in the first 0.3 run (lockdocs 11/17, Context7 12/17), then used to diagnose ranking misses" + }, + { + "id": "next15-form", + "project": "next15", + "package": "next", + "question": "How do I build a search form that navigates to a results page with client-side navigation?", + "expect": [ + [ + "next/form" + ] + ], + "reject": [], + "why": "15.0+:
from next/form (v15.1.0 docs/01-app/03-api-reference/02-components/form.mdx)", + "line": "newer", + "set": "held-out" + }, + { + "id": "next16-cache", + "project": "next16", + "package": "next", + "question": "How do I cache the result of an expensive function across requests?", + "expect": [ + [ + "use cache" + ] + ], + "reject": [], + "why": "16: the 'use cache' directive (v16.3.6 docs/01-app/03-api-reference/01-directives/use-cache.mdx)", + "line": "newer", + "set": "held-out" + }, + { + "id": "zod3-meta", + "project": "zod3", + "package": "zod", + "question": "How do I attach a description to a schema?", + "expect": [ + [ + ".describe(" + ] + ], + "reject": [ + ".meta(" + ], + "why": "3.23: .describe(); .meta() arrived in zod 4 (0 matches in v3.23.8 README)", + "line": "older", + "set": "held-out" + }, + { + "id": "zod4-meta", + "project": "zod4", + "package": "zod", + "question": "How do I attach a description to a schema?", + "expect": [ + [ + ".meta(" + ] + ], + "reject": [], + "why": "4: .meta() with the global registry (v4.1.5 packages/docs/content/metadata.mdx)", + "line": "newer", + "set": "held-out" + }, + { + "id": "pyd2-computed", + "project": "pydantic2", + "package": "pydantic", + "question": "How do I include a computed property when serializing a model?", + "expect": [ + [ + "computed_field" + ] + ], + "reject": [], + "why": "2: @computed_field (v2.9.2 docs/concepts/fields.md)", + "line": "newer", + "set": "held-out" + }, + { + "id": "pyd1-json", + "project": "pydantic1", + "package": "pydantic", + "question": "How do I parse and validate a model from a JSON string?", + "expect": [ + [ + "parse_raw" + ] + ], + "reject": [ + "model_validate_json" + ], + "why": "1: Model.parse_raw() (v1.10.18 docs/usage/models.md)", + "line": "older", + "set": "held-out" + }, + { + "id": "pyd2-json", + "project": "pydantic2", + "package": "pydantic", + "question": "How do I parse and validate a model from a JSON string?", + "expect": [ + [ + "model_validate_json" + ] + ], + "reject": [], + "why": "2: Model.model_validate_json() (v2.9.2 docs/concepts/models.md)", + "line": "newer", + "set": "held-out" + }, + { + "id": "sa20-dataclass", + "project": "sqlalchemy20", + "package": "sqlalchemy", + "question": "How do I declare ORM models that are also Python dataclasses?", + "expect": [ + [ + "MappedAsDataclass" + ] + ], + "reject": [], + "why": "2.0: MappedAsDataclass (rel_2_0_54 doc/build/orm/dataclasses.rst)", + "line": "newer", + "set": "held-out" + }, + { + "id": "eslint8-plugins", + "project": "eslint8", + "package": "eslint", + "question": "How do I add a plugin to my ESLint configuration?", + "expect": [ + [ + "\"plugins\": [", + "plugins: [" + ] + ], + "reject": [], + "why": "8: \"plugins\": [...] in .eslintrc (v8.57.1 docs/src/use/configure/plugins.md)", + "line": "older", + "set": "held-out" + }, + { + "id": "eslint9-plugins", + "project": "eslint9", + "package": "eslint", + "question": "How do I add a plugin to my ESLint configuration?", + "expect": [ + [ + "plugins: {" + ] + ], + "reject": [], + "why": "9: plugins: { name: plugin } in eslint.config.js (v9.39.5 docs/src/use/configure/plugins.md)", + "line": "newer", + "set": "held-out" + }, + { + "id": "vite6-envapi", + "project": "vite6", + "package": "vite", + "question": "How does a plugin know which environment it is running in?", + "expect": [ + [ + "this.environment" + ] + ], + "reject": [], + "why": "6: this.environment in plugin hooks (v6.4.3 docs/guide/api-environment-plugins.md)", + "line": "newer", + "set": "held-out" + }, + { + "id": "react18-context", + "project": "react18", + "package": "react", + "question": "How do I provide a context value to child components?", + "expect": [ + [ + ".Provider" + ] + ], + "reject": [ + "Starting in React 19" + ], + "why": "18: (@types/react 18.3.31)", + "line": "older", + "set": "held-out" + }, + { + "id": "react19-context", + "project": "react19", + "package": "react", + "question": "How do I provide a context value to child components?", + "expect": [ + [ + "Starting in React 19", + "is a legacy way" + ] + ], + "reject": [], + "why": "19: render as the provider; .Provider is legacy (react.dev createContext.md)", + "line": "newer", + "set": "held-out" + }, + { + "id": "rr7-routesfile", + "project": "rr7", + "package": "react-router", + "question": "Where do I configure my app's routes?", + "expect": [ + [ + "routes.ts" + ] + ], + "reject": [], + "why": "7 framework mode: app/routes.ts (react-router@7.1.1 docs/start/framework/routing.md)", + "line": "newer", + "set": "held-out" + }, + { + "id": "dj51-facets", + "project": "django51", + "package": "django", + "question": "How do I show counts next to the admin list filters?", + "expect": [ + [ + "show_facets" + ] + ], + "reject": [], + "why": "5.0+: ModelAdmin.show_facets (5.1.15 docs/ref/contrib/admin/index.txt)", + "line": "newer", + "set": "held-out" + }, + { + "id": "tw3-source", + "project": "tailwind3", + "package": "tailwindcss", + "question": "How do I tell Tailwind which files to scan for class names?", + "expect": [ + [ + "content:" + ] + ], + "reject": [ + "@source" + ], + "why": "v3: content: [...] in tailwind.config.js (v3 site content-configuration.mdx)", + "line": "older", + "set": "held-out" + }, + { + "id": "tw4-source", + "project": "tailwind4", + "package": "tailwindcss", + "question": "How do I tell Tailwind which files to scan for class names?", + "expect": [ + [ + "@source" + ] + ], + "reject": [], + "why": "v4: automatic detection plus @source in CSS (v4 site detecting-classes-in-source-files.mdx)", + "line": "newer", + "set": "held-out" + }, + { + "id": "tokio-interval", + "project": "axum08", + "package": "tokio", + "question": "How do I run a task every few seconds?", + "expect": [ + [ + "interval(" + ] + ], + "reject": [], + "why": "tokio::time::interval", + "line": "single", + "set": "held-out" } ] } diff --git a/bench/render.py b/bench/render.py index f648ea0..c157d95 100755 --- a/bench/render.py +++ b/bench/render.py @@ -34,7 +34,7 @@ def sub(key, line): - rs = [r for r in rows if r.get("line") == line] + rs = [r for r in rows if (r.get("set") == "held-out" if line == "held-out" else r.get("line") == line)] got = sum(1 for r in rs if (r.get("context7", {}) if key == "context7" else r["variants"].get(key, {})).get("pass")) return f"{got}/{len(rs)}" @@ -48,13 +48,13 @@ def fmt_ms(v): "An answer passes when it contains the version-correct API and none of the other version's. Same questions and grader against Context7's anonymous API, " f"on a GitHub-hosted runner" + (f" ([run]({url}))" if url else "") + ":", "", - f"| | correct | older majors | newer majors | {', '.join(single) or 'single version'} | median tokens | median latency |", - "|---|---|---|---|---|---|---|", + f"| | correct | older majors | newer majors | {', '.join(single) or 'single version'} | held-out | median tokens | median latency |", + "|---|---|---|---|---|---|---|---|", ] for key, label in [("fetched", "lockdocs + `lockdocs fetch`"), ("hybrid", "lockdocs, package files only"), ("context7", "Context7 (anonymous)")]: a = res["summary"].get(key) if a: - table.append(f"| {label} | {a['passed']}/{a['total']} | {sub(key, 'older')} | {sub(key, 'newer')} | {sub(key, 'single')} | {a['median_tokens']:,} | {fmt_ms(a['median_ms'])} |") + table.append(f"| {label} | {a['passed']}/{a['total']} | {sub(key, 'older')} | {sub(key, 'newer')} | {sub(key, 'single')} | {sub(key, 'held-out')} | {a['median_tokens']:,} | {fmt_ms(a['median_ms'])} |") rp = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "README.md") readme = open(rp).read() readme = re.sub(r".*?", "\n" + "\n".join(table) + "\n", readme, flags=re.S) diff --git a/bench/run.py b/bench/run.py index bd545b5..1a9cfad 100755 --- a/bench/run.py +++ b/bench/run.py @@ -2,13 +2,22 @@ """lockdocs benchmark: version-specific questions against real installs. Usage: python3 bench/run.py [--context7] [--only id,id] + [--variant name:KEY=VALUE,KEY=VALUE ...] + +`--variant` adds a configuration run after `lockdocs fetch` with extra +environment (for weight sweeps on CI, e.g. `head25:LOCKDOCS_HEAD_WEIGHT=0.25`). +Questions marked `"set": "held-out"` are not used for tuning: they are written +before the ranking changes they measure and reported in their own column. +Once held-out questions are used to diagnose a miss, they join the tuning set +(`history` says when) and new held-out questions replace them. Each question names a project (with dependencies installed at pinned versions) and a package. An answer passes when it contains at least one string from every `expect` group and none of the `reject` strings (case-insensitive). Tokens are counted with tiktoken o200k_base when available, else chars/4. Context7 is queried the way its MCP server does (library search, then -context for the question) on the anonymous tier; 429s are recorded, not retried. +context for the question) on the anonymous tier, trying the next search result +when a library answers HTTP 404; 429s are recorded, not retried. """ import json, os, subprocess, sys, time, urllib.parse, urllib.request @@ -77,8 +86,9 @@ def c7_get(url): def c7_library(pkg, version, question): - """Pick the library like the resolve step would: top search result, - then the listed version with the same major (exact when present).""" + """Pick libraries like the resolve step would: search results in order, + each at the listed version with the same major (exact when present). + Returns up to three candidates; the first one that answers is used.""" key = (pkg, version) if key in _c7_ids: return _c7_ids[key], 0.0 @@ -89,28 +99,39 @@ def c7_library(pkg, version, question): if not results: _c7_ids[key] = None return None, ms - pick = results[0] - lib = pick["id"] - versions = pick.get("versions") or [] major = version.lstrip("v").split(".")[0] - exact = [v for v in versions if v.lstrip("v") == version.lstrip("v")] - same = [v for v in versions if v.lstrip("v").split(".")[0] == major] - chosen = (exact or same or [None])[-1] - ident = f"{lib}/{chosen}" if chosen else lib - _c7_ids[key] = {"id": ident, "versions": versions, "exact": bool(exact), "same_major": bool(same)} - return _c7_ids[key], ms + cands = [] + for pick in results[:3]: + lib = pick["id"] + versions = pick.get("versions") or [] + exact = [v for v in versions if v.lstrip("v") == version.lstrip("v")] + same = [v for v in versions if v.lstrip("v").split(".")[0] == major] + chosen = (exact or same or [None])[-1] + cands.append({"id": f"{lib}/{chosen}" if chosen else lib, "base": lib, "exact": bool(exact), "same_major": bool(same)}) + _c7_ids[key] = cands + return cands, ms def run_context7(q, version): - lib, search_ms = c7_library(q["package"], version, q["question"]) - if not lib: + cands, search_ms = c7_library(q["package"], version, q["question"]) + if not cands: return {"error": "no library" if not c7_state["stopped"] else "rate limited", "ms": search_ms} - body, ms, err = c7_get(f"{C7}/context?" + urllib.parse.urlencode({"libraryId": lib["id"], "query": q["question"], "type": "txt"})) + total = search_ms + err, lib = None, cands[0] + # An agent whose first pick fails (HTTP 404) tries the next one; so do we. + for lib in cands: + for ident in dict.fromkeys([lib["id"], lib["base"]]): + body, ms, err = c7_get(f"{C7}/context?" + urllib.parse.urlencode({"libraryId": ident, "query": q["question"], "type": "txt"})) + total += ms + if body is not None or err != "HTTP 404": + break + if body is not None or err != "HTTP 404": + break if body is None: - return {"error": err, "ms": search_ms + ms, "library": lib["id"]} + return {"error": err, "ms": total, "library": lib["id"]} ok, missing, rej = grade(body, q) - return {"pass": ok, "missing": missing, "rejected": rej, "tokens": count(body), "ms": round(search_ms + ms), "library": lib["id"], - "version_match": "exact" if lib["exact"] else ("same major" if lib["same_major"] else "unversioned")} + return {"pass": ok, "missing": missing, "rejected": rej, "tokens": count(body), "ms": round(total), "library": ident, + "version_match": "exact" if lib["exact"] and ident == lib["id"] else ("same major" if lib["same_major"] and ident == lib["id"] else "unversioned")} VARIANTS = [ @@ -133,8 +154,18 @@ def run_lockdocs_env(binary, proj, q, env): os.environ[k] = v +def grader_key(q): + """Question identity for reusing a Context7 answer: text, package and grading.""" + return (q["id"], q["question"], q["package"], json.dumps([q["expect"], q.get("reject", [])])) + + def main(): binary, projects, out = sys.argv[1], sys.argv[2], sys.argv[3] + for i, a in enumerate(sys.argv): + if a == "--variant": + name, _, envs = sys.argv[i + 1].partition(":") + env = dict(kv.split("=", 1) for kv in envs.split(",") if kv) + VARIANTS.append((name, f"lockdocs, fetched, {envs}", env)) with_c7 = "--context7" in sys.argv do_fetch = "--fetch" in sys.argv # Reuse Context7 answers from a previous run for identical questions on the @@ -144,8 +175,9 @@ def main(): if "--context7-cache" in sys.argv: prev = json.load(open(sys.argv[sys.argv.index("--context7-cache") + 1])) for r in prev.get("rows", []): - if "pass" in r.get("context7", {}): - c7_cache[(r["id"], r["question"], r["package"], r["version"])] = r["context7"] + # Rows from before `grading` was recorded are keyed without it and never match. + if "pass" in r.get("context7", {}) and "grading" in r: + c7_cache[(r["id"], r["question"], r["package"], r["grading"], r["version"])] = r["context7"] reused = 0 only = None if "--only" in sys.argv: @@ -161,11 +193,11 @@ def main(): t = time.perf_counter() r = subprocess.run([binary, "index", "-C", proj, "--json"], capture_output=True, text=True, env={**os.environ, "LOCKDOCS_NO_UPSTREAM": "1"}) index[p] = {"ms": round((time.perf_counter() - t) * 1000), "report": json.loads(r.stdout) if r.returncode == 0 else r.stderr} - variants = [v for v in VARIANTS if do_fetch or v[0] != "fetched"] + variants = [v for v in VARIANTS if do_fetch or v[0] in ("keyword", "hybrid")] results = {} fetch = {} for name, _, env in variants: - if name == "fetched": + if name == "fetched" and not fetch: for p in projs: t = time.perf_counter() r = subprocess.run([binary, "fetch", "-C", os.path.join(projects, p), "--json"], capture_output=True, text=True) @@ -177,14 +209,14 @@ def main(): first = text.splitlines()[0] if text else "" version = first.split(" · ")[0].rsplit("@", 1)[-1] if "@" in first else "" results[(name, q["id"])] = ({"pass": ok and code == 0, "missing": missing, "rejected": rej, "tokens": count(text), "ms": round(ms, 1)}, version) - main_variant = variants[-1][0] + main_variant = "fetched" if do_fetch else variants[-1][0] rows = [] for q in qs: main_res, version = results[(main_variant, q["id"])] row = {"id": q["id"], "project": q["project"], "package": q["package"], "version": version, "question": q["question"], "why": q["why"], "line": q.get("line", ""), - "lockdocs": main_res, "variants": {n: results[(n, q["id"])][0] for n, _, _ in variants}} + "set": q.get("set", "tuning"), "grading": grader_key(q)[3], "lockdocs": main_res, "variants": {n: results[(n, q["id"])][0] for n, _, _ in variants}} if with_c7: - hit = c7_cache.get((q["id"], q["question"], q["package"], version)) + hit = c7_cache.get(grader_key(q) + (version,)) if hit is not None: row["context7"] = dict(hit, reused=True) reused += 1 @@ -221,8 +253,9 @@ def markdown(res, with_c7): if with_c7: cols.append(("context7", "Context7 (anonymous API)")) out = [f"Tokenizer: {res['tokenizer']}. Runner: {res['runner']['os']} {res['runner']['machine']}. {len(res['rows'])} questions.", ""] - groups = [("older", "older major"), ("newer", "newer major"), ("single", "single version")] - present = [g for g in groups if any(r.get("line") == g[0] for r in res["rows"])] + groups = [("older", "older major"), ("newer", "newer major"), ("single", "single version"), ("held-out", "held-out")] + in_group = lambda r, g: r.get("set") == "held-out" if g == "held-out" else r.get("line") == g + present = [g for g in groups if any(in_group(r, g[0]) for r in res["rows"])] out.append("| | correct | " + " | ".join(t for _, t in present) + " | median tokens | median latency | p95 latency |") out.append("|---|---|" + "---|" * len(present) + "---|---|---|") for key, label in cols: @@ -231,13 +264,13 @@ def markdown(res, with_c7): continue subs = [] for g, _ in present: - rs = [r for r in res["rows"] if r.get("line") == g] + rs = [r for r in res["rows"] if in_group(r, g)] subs.append(f"{sum(1 for r in rs if result_of(r, key).get('pass'))}/{len(rs)}") out.append(f"| {label} | {a['passed']}/{a['total']} | " + " | ".join(subs) + f" | {a['median_tokens']} | {a['median_ms']:.0f} ms | {a['p95_ms']:.0f} ms |") if with_c7 and res["context7_calls"]: c = res["context7_calls"] out += ["", f"Context7 (anonymous): {c['calls']} HTTP calls, {c['rate_limited']} rate-limited (429), {c['errors']} other errors; ratelimit-limit header {c['limit']}, remaining {c['remaining']}." - + (f" {c['reused']} answers reused from the previous run's identical question and version (see bench/run.py --context7-cache)." if c.get("reused") else "")] + + (f" {c['reused']} answers reused from the previous run's identical question, grading and version (see bench/run.py --context7-cache)." if c.get("reused") else "")] head = "| question | version | " + " | ".join(n for n, _ in cols) + " | tokens (last lockdocs) | ms |" + (" Context7 library |" if with_c7 else "") out += ["", head, "|---|---|" + "---|" * len(cols) + "---|---|" + ("---|" if with_c7 else "")] for r in res["rows"]: @@ -245,7 +278,7 @@ def markdown(res, with_c7): for key, _ in cols: x = result_of(r, key) marks.append("✅" if x.get("pass") else ("❌" if "pass" in x else f"— ({x.get('error')})")) - line = f"| {r['id']} | {r['package']}@{r['version']} | " + " | ".join(marks) + f" | {r['lockdocs']['tokens']} | {r['lockdocs']['ms']:.0f} |" + line = f"| {r['id']}{' (held-out)' if r.get('set') == 'held-out' else ''} | {r['package']}@{r['version']} | " + " | ".join(marks) + f" | {r['lockdocs']['tokens']} | {r['lockdocs']['ms']:.0f} |" if with_c7: c = r.get("context7", {}) line += f" {c.get('library', '')} ({c.get('version_match', '')}) |" diff --git a/crates/lockdocs-core/src/bm25.rs b/crates/lockdocs-core/src/bm25.rs index d0b2579..94049fa 100644 --- a/crates/lockdocs-core/src/bm25.rs +++ b/crates/lockdocs-core/src/bm25.rs @@ -12,9 +12,22 @@ const STOP: &[&str] = &[ "their", "into", "vs", "versus", "about", "example", "show", ]; -/// Conservative suffix stripping: plural, -ing, -ed, final -e. +/// A stopword (lowercase)? +pub fn is_stop(w: &str) -> bool { + STOP.contains(&w) +} + +/// Conservative suffix stripping: plural, -ing, -ed, final -e, and the +/// noun forms -ation / -ability that pair with -ate / -able +/// (validation ~ validate, immutability ~ immutable). pub fn stem(t: &str) -> String { let mut s = stem0(t); + let n = s.len(); + if s.is_ascii() && n > 8 && (s.ends_with("ability") || s.ends_with("ibility")) { + s.replace_range(n - 5.., "l"); + } else if s.is_ascii() && n > 7 && s.ends_with("ation") { + s.truncate(n - 3); + } if s.len() > 4 && s.ends_with('e') && s.is_ascii() { s.pop(); } @@ -92,6 +105,16 @@ const SYNONYMS: &[(&str, &[&str])] = &[ ("middleware", &["layer"]), ("layer", &["middleware"]), ("state", &["extension"]), + ("parameter", &["param", "arg", "argument"]), + ("param", &["parameter"]), + ("argument", &["arg", "param"]), + ("arg", &["argument"]), + ("javascript", &["js"]), + ("js", &["javascript"]), + ("typescript", &["ts"]), + ("ts", &["typescript"]), + ("databas", &["db"]), + ("db", &["databas"]), ("unknown", &["extra", "strict"]), ("extra", &["unknown"]), ]; @@ -237,6 +260,9 @@ mod tests { assert_eq!(stem("validate"), "validat"); assert_eq!(stem("parses"), stem("parse")); assert_eq!(stem("class"), "class"); + assert_eq!(stem("immutability"), stem("immutable")); + assert_eq!(stem("validation"), stem("validate")); + assert_eq!(stem("validations"), stem("validated")); let a = tf(&[("object schema strict", 1)]); let b = tf(&[("string schema", 1)]); let idx = Bm25::build([(a.0.as_slice(), a.1), (b.0.as_slice(), b.1)].into_iter()); diff --git a/crates/lockdocs-core/src/extract.rs b/crates/lockdocs-core/src/extract.rs index 2a556ce..4382643 100644 --- a/crates/lockdocs-core/src/extract.rs +++ b/crates/lockdocs-core/src/extract.rs @@ -215,6 +215,8 @@ struct Job { #[derive(Clone, Copy, PartialEq)] enum How { Prose, + /// A docs page written as a JS/TSX component. + Page, Example, Metadata, Ts, @@ -293,12 +295,27 @@ fn plan(eco: Eco, src: &Source, extra_types: Option<&Path>, upstream: Option<&Pa } // Upstream docs fetched from the repository at this version's tag. if let Some(up) = upstream { + let pages: HashSet = std::fs::read_to_string(up.join(".lockdocs-upstream.json")) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .map(|m| m.pages.into_iter().collect()) + .unwrap_or_default(); let mut rels = Vec::new(); walk_all(up, Path::new(""), 0, &mut rels); for r in rels { let e = ext_of(&r); - let rel = format!("upstream:{}", r.to_string_lossy().replace('\\', "/")); - if doc_ext(&e) || e == "mdoc" { + let plain = r.to_string_lossy().replace('\\', "/"); + let rel = format!("upstream:{plain}"); + if pages.contains(&plain) { + jobs.push(Job { + abs: up.join(&r), + rel, + how: How::Page, + }); + continue; + } + // Django and others write reStructuredText in `.txt` files under docs/. + if doc_ext(&e) || e == "mdoc" || e == "txt" { jobs.push(Job { abs: up.join(&r), rel, @@ -378,6 +395,12 @@ pub fn extract(eco: Eco, pkg_name: &str, src: &Source, extra_types: Option<&Path } } } + How::Page => { + if let Some((md, title)) = page_markdown(&text) { + let title = title.unwrap_or_else(|| page_title(&job.rel)); + prose_titled(&md, &job.rel, 0, Some(title), &mut out); + } + } How::Example => example(&text, &job.rel, &mut out), How::Metadata => { let (body, line) = markdown::metadata_body(&text); @@ -505,10 +528,249 @@ fn example(text: &str, rel: &str, out: &mut Vec) { }); } +/// Title for a page file without one: its route (`docs/installation/using-vite`). +fn page_title(rel: &str) -> String { + let segs: Vec<&str> = rel.split('/').filter(|s| !(s.starts_with('(') && s.ends_with(')'))).collect(); + let mut segs: Vec<&str> = segs.iter().rev().take(3).rev().copied().collect(); + if let Some(last) = segs.last() { + if last.starts_with("page.") || last.starts_with("index.") { + segs.pop(); + } + } + segs.join("/") + .trim_end_matches(".tsx") + .trim_end_matches(".jsx") + .trim_end_matches(".js") + .to_string() +} + +/// Markdown from a docs page written as a JS/TSX component: `title:` values +/// of listed steps become headings, `description:` and JSX text become +/// paragraphs, `code:` strings become fenced code (language from `lang:`), +/// and JSX `

`..`

` stay headings. Lines keep their source position +/// where they can. Returns (markdown, page title from `metadata.title`). +fn page_markdown(text: &str) -> Option<(String, Option)> { + if text.len() > 200_000 { + return None; + } + parse_with("tsx", text, |root, src| { + let mut p = PageOut::default(); + page_walk(root, src, &mut p, false); + p.flush(); + let mut md = String::new(); + let mut n = 0u32; + for (line, t) in &p.out { + while n + 1 < *line { + md.push('\n'); + n += 1; + } + md.push_str(t); + md.push('\n'); + n += 1 + t.matches('\n').count() as u32; + } + (md, p.title) + }) + .filter(|(md, _)| !md.trim().is_empty()) +} + +#[derive(Default)] +struct PageOut { + out: Vec<(u32, String)>, + para: String, + para_line: u32, + title: Option, +} + +impl PageOut { + fn text(&mut self, line: u32, t: &str) { + if self.para.trim().is_empty() { + self.para_line = line; + } + self.para.push_str(t); + } + fn flush(&mut self) { + let t = compact(&self.para, usize::MAX); + if !t.is_empty() { + self.out.push((self.para_line, t)); + } + self.para.clear(); + } + fn block(&mut self, line: u32, t: String) { + self.flush(); + self.out.push((line, t)); + } +} + +fn line_of(n: Node) -> u32 { + n.start_position().row as u32 + 1 +} + +/// The value of a string, template string or tagged template (`dedent`...``). +fn js_string(n: Node, src: &[u8]) -> Option { + match n.kind() { + "string" => { + let raw = txt(n, src); + let inner = raw.get(1..raw.len().saturating_sub(1)).unwrap_or(""); + Some(inner.replace("\\n", "\n").replace("\\'", "'").replace("\\\"", "\"").replace("\\\\", "\\")) + } + "template_string" => { + let raw = txt(n, src); + let inner = raw.get(1..raw.len().saturating_sub(1)).unwrap_or(""); + Some(dedent(&inner.trim_matches('\n').lines().collect::>())) + } + "call_expression" => n + .child_by_field_name("arguments") + .filter(|a| a.kind() == "template_string") + .and_then(|a| js_string(a, src)), + "parenthesized_expression" => n.named_child(0).and_then(|c| js_string(c, src)), + _ => None, + } +} + +fn jsx_name(n: Node, src: &[u8]) -> String { + let open = if n.kind() == "jsx_element" { + n.child_by_field_name("open_tag") + } else { + Some(n) + }; + open.and_then(|o| o.child_by_field_name("name")) + .map(|x| txt(x, src).to_string()) + .unwrap_or_default() +} + +/// The visible text inside a JSX element. +fn jsx_text(n: Node, src: &[u8], out: &mut String) { + match n.kind() { + "jsx_text" => out.push_str(txt(n, src)), + "jsx_expression" => { + if let Some(v) = n.named_child(0).and_then(|c| js_string(c, src)) { + out.push_str(&v); + } + } + "jsx_attribute" | "jsx_opening_element" | "jsx_closing_element" => {} + _ => { + let mut c = n.walk(); + for ch in n.named_children(&mut c) { + jsx_text(ch, src, out); + } + } + } +} + +fn page_walk(n: Node, src: &[u8], p: &mut PageOut, in_array: bool) { + match n.kind() { + "import_statement" | "comment" | "jsx_attribute" => {} + "pair" => { + let key = n + .child_by_field_name("key") + .map(|k| txt(k, src).trim_matches(['"', '\'']).to_string()) + .unwrap_or_default(); + let Some(value) = n.child_by_field_name("value") else { return }; + match key.as_str() { + "title" | "heading" => { + if let Some(t) = js_string(value, src) { + if !in_array && p.title.is_none() { + p.title = Some(t); + } else if in_array { + p.block(line_of(n), format!("## {}", compact(&t, 200))); + } + } + } + "description" => { + if let Some(t) = js_string(value, src) { + p.block(line_of(value), compact(&t, usize::MAX)); + } + } + "code" => match js_string(value, src) { + Some(code) => { + let lang = n + .parent() + .and_then(|obj| { + let mut c = obj.walk(); + let found = obj.named_children(&mut c).find(|x| { + x.kind() == "pair" && x.child_by_field_name("key").is_some_and(|k| txt(k, src).trim_matches(['"', '\'']) == "lang") + }); + found + }) + .and_then(|x| x.child_by_field_name("value")) + .and_then(|v| js_string(v, src)) + .unwrap_or_default(); + p.block(line_of(value), format!("```{lang}\n{}\n```", code.trim_end())); + } + None => page_walk(value, src, p, in_array), + }, + "name" | "lang" | "className" | "href" | "id" | "url" | "src" | "image" | "images" | "icon" | "openGraph" | "twitter" | "alternates" => {} + _ => page_walk(value, src, p, in_array), + } + } + "jsx_element" | "jsx_self_closing_element" => { + let name = jsx_name(n, src); + let b = name.as_bytes(); + if b.len() == 2 && b[0] == b'h' && (b'1'..=b'6').contains(&b[1]) { + let mut t = String::new(); + jsx_text(n, src, &mut t); + let t = compact(&t, 200); + if !t.is_empty() { + p.block(line_of(n), format!("{} {t}", "#".repeat((b[1] - b'0') as usize))); + } + } else if name == "code" { + let mut t = String::new(); + jsx_text(n, src, &mut t); + p.text(line_of(n), &format!("`{}`", t.trim())); + } else { + let block = matches!(name.as_str(), "p" | "li" | "div" | "section" | "ul" | "ol" | "pre" | "table" | "tr"); + if block { + p.flush(); + } + let mut c = n.walk(); + for ch in n.named_children(&mut c) { + page_walk(ch, src, p, in_array); + } + if block { + p.flush(); + } + } + } + "jsx_text" => p.text(line_of(n), txt(n, src)), + "jsx_expression" => match n.named_child(0) { + Some(c) if matches!(c.kind(), "string" | "template_string") => { + let v = js_string(c, src).unwrap_or_default(); + p.text(line_of(n), &v); + } + Some(c) => page_walk(c, src, p, in_array), + None => {} + }, + _ => { + let arr = in_array || n.kind() == "array"; + let mut c = n.walk(); + for ch in n.named_children(&mut c) { + page_walk(ch, src, p, arr); + } + } + } +} + fn prose(text: &str, rel: &str, line_off: u32, out: &mut Vec) { + prose_titled(text, rel, line_off, None, out) +} + +fn prose_titled(text: &str, rel: &str, line_off: u32, given: Option, out: &mut Vec) { let (text, fm_title) = markdown::clean_mdx(text, rel.ends_with(".mdx")); - let file_title = rel.rsplit('/').next().unwrap_or(rel); - let title = if rel.contains("package metadata") { + // A page without a title is named after its file: `model_config.md` -> `model config`. + let file_name = rel.rsplit('/').next().unwrap_or(rel); + let file_title = if rel.starts_with("upstream:") && !is_doc_name(file_name) { + file_name + .rsplit_once('.') + .map_or(file_name, |x| x.0) + .trim_start_matches(|c: char| c.is_ascii_digit() || c == '-') + .replace(['_', '-'], " ") + } else { + file_name.to_string() + }; + let file_title = file_title.as_str(); + let title = if let Some(t) = given { + t + } else if rel.contains("package metadata") { "README".to_string() } else if let Some(t) = fm_title { t @@ -569,7 +831,7 @@ fn txt<'a>(n: Node, src: &'a [u8]) -> &'a str { } fn compact(s: &str, cap: usize) -> String { - let mut out = String::with_capacity(s.len().min(cap + 8)); + let mut out = String::with_capacity(s.len().min(cap.saturating_add(8))); let mut space = false; for c in s.chars() { if c.is_whitespace() { @@ -1664,6 +1926,44 @@ export * as zz from "./external"; assert_eq!(get("pydantic.main.create_model").kind, Kind::Function); } + #[test] + fn docs_pages_written_as_components() { + let src = r#"import dedent from "dedent"; +export const metadata = { title: "Installing with Vite", description: "Use the Vite plugin.", openGraph: { title: "x" } }; +const steps = [ + { + title: "Import Tailwind CSS", + body: ( +

+ Add an @import to your CSS file. +

+ ), + code: { name: "CSS", lang: "css", code: dedent` + @import "tailwindcss"; + ` }, + }, + { title: 'Old way', body: () =>

Use directives{' '}here.

, code: { lang: 'css', code: '@tailwind base;\n@tailwind utilities;' } }, +]; +export default function Page() { return

Overview

Fast.

} +"#; + let (md, title) = page_markdown(src).unwrap(); + assert_eq!(title.as_deref(), Some("Installing with Vite")); + assert!(md.contains("## Import Tailwind CSS"), "{md}"); + assert!(md.contains("Add an `@import` to your CSS file."), "{md}"); + assert!(md.contains("```css\n@import \"tailwindcss\";\n```"), "{md}"); + assert!(md.contains("@tailwind base;\n@tailwind utilities;"), "{md}"); + assert!(md.contains("### Overview"), "{md}"); + assert!(!md.contains("dedent") && !md.contains("prose"), "{md}"); + let mut out = Vec::new(); + prose_titled(&md, "upstream:site/docs/installation/page.tsx", 0, title, &mut out); + let s = out.iter().find(|e| e.name == "Installing with Vite › Import Tailwind CSS").unwrap(); + assert_eq!(s.line, 5); + assert_eq!( + page_title("upstream:site/src/app/(docs)/docs/installation/(tabs)/using-vite/page.tsx"), + "installation/using-vite" + ); + } + #[test] fn rust_items_and_macro_bodies() { let src = "//! Crate docs.\n//!\n//! # Usage\n//! Call spawn.\n\ncfg_rt! {\n /// Spawns a new task.\n #[track_caller]\n pub fn spawn(future: F) -> JoinHandle\n where F: Future + Send + 'static {\n todo!()\n }\n}\n\n/// A router.\npub struct Router { inner: Inner }\n\nimpl Router {\n /// Add a route.\n pub fn route(self, path: &str, m: MethodRouter) -> Self { self }\n fn private(&self) {}\n}\n\n/// Hidden\n#[doc(hidden)]\npub fn hidden() {}\n\n/// Macro.\n#[macro_export]\nmacro_rules! select { () => {} }\n"; diff --git a/crates/lockdocs-core/src/index.rs b/crates/lockdocs-core/src/index.rs index 3f90525..46f363c 100644 --- a/crates/lockdocs-core/src/index.rs +++ b/crates/lockdocs-core/src/index.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use std::time::Instant; /// Bump when extraction or the on-disk format changes. -pub const FORMAT: u32 = 8; +pub const FORMAT: u32 = 9; #[derive(Serialize, Deserialize)] pub struct PackageIndex { @@ -26,6 +26,10 @@ pub struct PackageIndex { pub entries: Vec, pub terms: Vec>, pub lens: Vec, + /// Terms of the heading or name plus the first sentence only, ranked on + /// their own so a long body does not bury what the entry says it is. + pub head: Vec>, + pub head_lens: Vec, pub build_ms: u64, /// `github.com/o/r@tag (N files)` when upstream docs are included. pub upstream: Option, @@ -93,6 +97,17 @@ pub fn entry_tf(e: &Entry) -> (Vec<(String, u16)>, u32) { } } +/// Heading (or name) and first sentence: what an entry says it is about. +pub fn head_tf(e: &Entry) -> (Vec<(String, u16)>, u32) { + let (prose, _) = split_code(&e.doc); + let first = summary(&prose); + if e.kind == Kind::Prose { + bm25::tf(&[(crate::markdown::topic_heading(&e.name), 2), (first, 1)]) + } else { + bm25::tf(&[(&e.name, 2), (first, 1)]) + } +} + /// Files whose change means the package was modified in place. fn stamp(src: &Source) -> String { let probe: Vec = match &src.metadata { @@ -119,9 +134,14 @@ fn stamp(src: &Source) -> String { /// What an entry means, for the embedding: its name and the start of its /// prose (code examples and long signatures dilute a mean-pooled vector). pub fn embed_text(e: &Entry) -> String { - let (prose, _) = split_code(&e.doc); + let (prose, code) = split_code(&e.doc); let mut body: String = prose.chars().take(600).collect(); if e.kind == Kind::Prose { + // A code-only section embedded by its title alone matches short + // questions far too well; its code says what it is about. + if body.trim().is_empty() { + body = code.chars().take(600).collect(); + } let head: Vec<&str> = e.name.split(" › ").collect(); let tail = head[head.len().saturating_sub(2)..].join(" "); return format!("{tail}. {body}"); @@ -168,6 +188,7 @@ pub fn build(dep: &Dep, src: &Source, root: &Path, up: Option<&(PathBuf, Manifes let up_dir = up.filter(|(_, m)| m.files > 0).map(|(d, _)| d.as_path()); let entries = extract::extract(dep.eco, &dep.name, src, types.as_deref(), up_dir); let (terms, lens): (Vec<_>, Vec<_>) = entries.iter().map(entry_tf).unzip(); + let (head, head_lens): (Vec<_>, Vec<_>) = entries.iter().map(head_tf).unzip(); let model = embed::get(); let vecs: Vec = match &model { Some(m) => { @@ -186,6 +207,8 @@ pub fn build(dep: &Dep, src: &Source, root: &Path, up: Option<&(PathBuf, Manifes entries, terms, lens, + head, + head_lens, build_ms: t.elapsed().as_millis() as u64, upstream: up .filter(|(_, m)| m.files > 0) diff --git a/crates/lockdocs-core/src/markdown.rs b/crates/lockdocs-core/src/markdown.rs index 06c94b7..e512a92 100644 --- a/crates/lockdocs-core/src/markdown.rs +++ b/crates/lockdocs-core/src/markdown.rs @@ -126,7 +126,67 @@ pub fn split(text: &str, title: &str) -> Vec
{ out } +/// Headings that name a part of a page, not a topic (`Parameters`, +/// `Returns`, `Examples`): the topic is the heading above them. +pub fn generic_heading(h: &str) -> bool { + let h = h.trim().trim_end_matches(':').to_ascii_lowercase(); + [ + "parameters", + "parameter", + "params", + "arguments", + "args", + "returns", + "return value", + "return type", + "props", + "options", + "examples", + "example", + "usage", + "reference", + "overview", + "notes", + "caveats", + "good to know", + "type", + "types", + "description", + "syntax", + "signature", + "see also", + "troubleshooting", + "introduction", + "summary", + "details", + ] + .contains(&h.as_str()) +} + +/// The heading an entry is about: its last heading, or the one above it +/// when the last is generic (`generateMetadata › Parameters` -> `generateMetadata`). +pub fn topic_heading(name: &str) -> &str { + let mut it = name.rsplit(" › "); + let last = it.next().unwrap_or(""); + if generic_heading(last) { + it.next().unwrap_or(last) + } else { + last + } +} + fn clean_heading(h: &str) -> String { + // MDX heading ids: `Usage {/*usage*/}`, `Usage {#usage}`. + let mut h = h.to_string(); + for (open, close) in [("{/*", "*/}"), ("{#", "}")] { + while let Some(i) = h.find(open) { + match h[i..].find(close) { + Some(j) => h.replace_range(i..i + j + close.len(), ""), + None => break, + } + } + } + let h = h.as_str(); // `[text](url)` -> text, drop inline code ticks and HTML tags. let mut s = String::new(); let mut in_tag = false; @@ -199,7 +259,10 @@ fn push_sized(out: &mut Vec
, head: &str, line: u32, body: &str) { } /// Blank out front matter (keeping its `title`), and for MDX the `import` / -/// `export` lines and JSX-only lines. Line numbers are preserved. +/// `export` lines and JSX-only lines. `export const title = "..."` names the +/// page and `export const description` keeps its text. HTML headings +/// (`

@import

`, one or more lines) become +/// Markdown headings so sections split at them. Line numbers are preserved. pub fn clean_mdx(text: &str, mdx: bool) -> (String, Option) { let lines: Vec<&str> = text.lines().collect(); let mut out: Vec = Vec::with_capacity(lines.len()); @@ -225,21 +288,76 @@ pub fn clean_mdx(text: &str, mdx: bool) -> (String, Option) { } } let mut fence = false; - for l in &lines[i.min(lines.len())..] { + let mut j = i.min(lines.len()); + while j < lines.len() { + let l = lines[j]; let t = l.trim(); if t.starts_with("```") || t.starts_with("~~~") { fence = !fence; } + if !fence { + if let Some((level, text, used)) = html_heading(&lines[j..]) { + out.push(format!("{} {text}", "#".repeat(level))); + out.extend(std::iter::repeat_n(String::new(), used - 1)); + j += used; + continue; + } + } + if mdx && !fence { + if let Some(v) = export_const(t, "title") { + title = title.or(Some(v)); + out.push(String::new()); + j += 1; + continue; + } + if let Some(v) = export_const(t, "description") { + out.push(v); + j += 1; + continue; + } + } let drop = mdx && !fence && (t.starts_with("import ") || t.starts_with("export ") || (t.starts_with('<') && t.ends_with('>') && !t.starts_with(" -From [this run](https://github.com/SylphxAI/lockdocs/actions/runs/36125903106). +From [this run](https://github.com/SylphxAI/lockdocs/actions/runs/36204404336). -Tokenizer: tiktoken o200k_base. Runner: Linux x86_64. 70 questions. +Tokenizer: tiktoken o200k_base. Runner: Linux x86_64. 105 questions. -| | correct | older major | newer major | single version | median tokens | median latency | p95 latency | -|---|---|---|---|---|---|---|---| -| lockdocs, keyword only (BM25), package files | 41/70 | 20/33 | 19/34 | 2/3 | 898 | 22 ms | 397 ms | -| lockdocs, hybrid (BM25 + embeddings), package files | 46/70 | 22/33 | 22/34 | 2/3 | 915 | 49 ms | 94 ms | -| lockdocs, hybrid + upstream docs (after `lockdocs fetch`) | 55/70 | 24/33 | 29/34 | 2/3 | 875 | 87 ms | 491 ms | -| Context7 (anonymous API) | 49/70 | 12/33 | 34/34 | 3/3 | 908 | 2011 ms | 3327 ms | +| | correct | older major | newer major | single version | held-out | median tokens | median latency | p95 latency | +|---|---|---|---|---|---|---|---|---| +| lockdocs, keyword only (BM25), package files | 59/105 | 29/45 | 26/55 | 4/5 | 8/18 | 882 | 25 ms | 276 ms | +| lockdocs, hybrid (BM25 + embeddings), package files | 60/105 | 27/45 | 29/55 | 4/5 | 6/18 | 903 | 52 ms | 103 ms | +| lockdocs, hybrid + upstream docs (after `lockdocs fetch`) | 96/105 | 38/45 | 53/55 | 5/5 | 16/18 | 866 | 97 ms | 474 ms | +| Context7 (anonymous API) | 77/105 | 19/45 | 53/55 | 5/5 | 15/18 | 908 | 2583 ms | 3398 ms | -Context7 (anonymous): 4 HTTP calls, 0 rate-limited (429), 0 other errors; ratelimit-limit header 200, remaining 196. 67 answers reused from the previous run's identical question and version (see bench/run.py --context7-cache). +Context7 (anonymous): 8 HTTP calls, 0 rate-limited (429), 4 other errors; ratelimit-limit header 200, remaining 190. 103 answers reused from the previous run's identical question, grading and version (see bench/run.py --context7-cache). | question | version | keyword | hybrid | fetched | context7 | tokens (last lockdocs) | ms | Context7 library | |---|---|---|---|---|---|---|---|---| -| zod3-strict | zod@3.23.8 | ✅ | ✅ | ✅ | ✅ | 1019 | 84 | /colinhacks/zod/v3.24.2 (same major) | -| zod4-strict | zod@4.1.5 | ✅ | ❌ | ✅ | ✅ | 946 | 134 | /colinhacks/zod/v4.0.1 (same major) | -| zod3-email | zod@3.23.8 | ✅ | ✅ | ✅ | ✅ | 1167 | 40 | /colinhacks/zod/v3.24.2 (same major) | -| zod4-email | zod@4.1.5 | ✅ | ✅ | ✅ | ✅ | 965 | 59 | /colinhacks/zod/v4.0.1 (same major) | -| zod3-error | zod@3.23.8 | ✅ | ✅ | ✅ | ✅ | 985 | 46 | /colinhacks/zod/v3.24.2 (same major) | -| zod4-error | zod@4.1.5 | ✅ | ✅ | ✅ | ✅ | 989 | 52 | /colinhacks/zod/v4.0.1 (same major) | -| zod4-record | zod@4.1.5 | ❌ | ❌ | ✅ | ✅ | 1026 | 57 | /colinhacks/zod/v4.0.1 (same major) | -| next14-cookies | next@14.2.35 | ❌ | ✅ | ❌ | ❌ | 849 | 342 | /vercel/next.js/v14.3.0-canary.87 (same major) | -| next15-cookies | next@15.1.0 | ✅ | ✅ | ✅ | ✅ | 892 | 392 | /vercel/next.js/v15.1.11 (same major) | -| next14-headers | next@14.2.35 | ✅ | ✅ | ✅ | ✅ | 892 | 80 | /vercel/next.js/v14.3.0-canary.87 (same major) | -| next15-headers | next@15.1.0 | ✅ | ✅ | ✅ | ✅ | 872 | 84 | /vercel/next.js/v15.1.11 (same major) | -| next14-nostore | next@14.2.35 | ✅ | ✅ | ✅ | ✅ | 761 | 82 | /vercel/next.js/v14.3.0-canary.87 (same major) | -| next15-connection | next@15.1.0 | ✅ | ✅ | ✅ | ✅ | 798 | 93 | /vercel/next.js/v15.1.11 (same major) | -| next15-after | next@15.1.0 | ✅ | ✅ | ❌ | ✅ | 879 | 92 | /vercel/next.js/v15.1.11 (same major) | -| rr6-json | react-router@6.26.2 | ✅ | ✅ | ❌ | ❌ | 796 | 99 | /websites/reactrouter (unversioned) | -| rr7-data | react-router@7.1.1 | ❌ | ✅ | ✅ | ✅ | 892 | 122 | /websites/reactrouter (unversioned) | -| rr6-defer | react-router@6.26.2 | ✅ | ✅ | ✅ | ✅ | 895 | 47 | /websites/reactrouter (unversioned) | -| rr6-future | react-router@6.26.2 | ✅ | ✅ | ✅ | ✅ | 815 | 52 | /websites/reactrouter (unversioned) | -| rr7-router | react-router@7.1.1 | ✅ | ✅ | ✅ | ✅ | 838 | 51 | /websites/reactrouter (unversioned) | -| pyd1-dict | pydantic@1.10.18 | ✅ | ✅ | ✅ | ❌ | 948 | 151 | /pydantic/pydantic (unversioned) | -| pyd2-dict | pydantic@2.9.2 | ❌ | ❌ | ✅ | ✅ | 909 | 245 | /pydantic/pydantic (unversioned) | -| pyd1-parse | pydantic@1.10.18 | ❌ | ❌ | ✅ | ❌ | 884 | 63 | /pydantic/pydantic (unversioned) | -| pyd2-parse | pydantic@2.9.2 | ❌ | ❌ | ✅ | ✅ | 843 | 72 | /pydantic/pydantic (unversioned) | -| pyd1-validator | pydantic@1.10.18 | ❌ | ✅ | ✅ | ❌ | 965 | 62 | /pydantic/pydantic (unversioned) | -| pyd2-validator | pydantic@2.9.2 | ❌ | ✅ | ✅ | ✅ | 764 | 67 | /pydantic/pydantic (unversioned) | -| pyd1-schema | pydantic@1.10.18 | ✅ | ✅ | ✅ | ❌ | 878 | 62 | /pydantic/pydantic (unversioned) | -| pyd2-schema | pydantic@2.9.2 | ✅ | ✅ | ✅ | ✅ | 817 | 72 | /pydantic/pydantic (unversioned) | -| pyd1-config | pydantic@1.10.18 | ❌ | ❌ | ❌ | ❌ | 945 | 59 | /pydantic/pydantic (unversioned) | -| pyd2-config | pydantic@2.9.2 | ✅ | ✅ | ✅ | ✅ | 934 | 71 | /pydantic/pydantic (unversioned) | -| axum07-path | axum@0.7.9 | ✅ | ✅ | ✅ | ❌ | 960 | 88 | /websites/rs_axum (unversioned) | -| axum08-path | axum@0.8.1 | ✅ | ✅ | ✅ | ✅ | 925 | 94 | /websites/rs_axum (unversioned) | -| axum07-extractor | axum@0.7.9 | ✅ | ✅ | ✅ | ❌ | 926 | 46 | /websites/rs_axum (unversioned) | -| axum08-optional | axum@0.8.1 | ✅ | ✅ | ✅ | ✅ | 898 | 42 | /websites/rs_axum (unversioned) | -| tokio-blocking | tokio@1.43.0 | ✅ | ✅ | ✅ | ✅ | 820 | 316 | /websites/rs_tokio_1_49_0 (unversioned) | -| tokio-select | tokio@1.43.0 | ❌ | ❌ | ❌ | ✅ | 969 | 60 | /websites/rs_tokio_1_49_0 (unversioned) | -| tokio-timeout | tokio@1.43.0 | ✅ | ✅ | ✅ | ✅ | 920 | 55 | /websites/rs_tokio_1_49_0 (unversioned) | -| tw3-css | tailwindcss@3.4.19 | ❌ | ❌ | ❌ | ❌ | 959 | 62 | /rails/tailwindcss-rails (unversioned) | -| tw4-css | tailwindcss@4.1.18 | ❌ | ❌ | ❌ | ✅ | 849 | 146 | /rails/tailwindcss-rails (unversioned) | -| tw3-theme | tailwindcss@3.4.19 | ✅ | ✅ | ✅ | ✅ | 1035 | 41 | /rails/tailwindcss-rails (unversioned) | -| tw4-theme | tailwindcss@4.1.18 | ❌ | ❌ | ✅ | ✅ | 1147 | 54 | /rails/tailwindcss-rails (unversioned) | -| eslint8-config | eslint@8.57.1 | ✅ | ✅ | ✅ | ✅ | 813 | 407 | /eslint/eslint/v8.57.1 (exact) | -| eslint9-config | eslint@9.39.5 | ✅ | ✅ | ✅ | ✅ | 741 | 360 | /eslint/eslint/v9.39.3 (same major) | -| eslint8-ignore | eslint@8.57.1 | ❌ | ❌ | ✅ | ✅ | 854 | 84 | /eslint/eslint/v8.57.1 (exact) | -| eslint9-ignore | eslint@9.39.5 | ❌ | ❌ | ✅ | ✅ | 825 | 83 | /eslint/eslint/v9.39.3 (same major) | -| prisma5-bytes | @prisma/client@5.22.0 | ❌ | ❌ | ❌ | ❌ | 878 | 78 | /websites/prisma_io (unversioned) | -| prisma6-bytes | @prisma/client@6.19.3 | ✅ | ✅ | ❌ | ✅ | 878 | 95 | /prisma/web (unversioned) | -| prisma5-fts | prisma@5.22.0 | ❌ | ❌ | ❌ | ❌ | 915 | 70 | /prisma/web (unversioned) | -| prisma6-fts | prisma@6.19.3 | ❌ | ❌ | ❌ | ✅ | 850 | 90 | /prisma/web (unversioned) | -| react18-action | react@18.3.1 | ✅ | ✅ | ❌ | ❌ | 951 | 129 | /reactjs/react.dev (unversioned) | -| react19-action | react@19.2.8 | ❌ | ✅ | ✅ | ✅ | 869 | 417 | /reactjs/react.dev (unversioned) | -| react18-use | react@18.3.1 | ❌ | ❌ | ✅ | ❌ | 804 | 48 | /reactjs/react.dev (unversioned) | -| react19-use | react@19.2.8 | ❌ | ❌ | ✅ | ✅ | 859 | 94 | /reactjs/react.dev (unversioned) | -| vite5-env | vite@5.4.21 | ✅ | ✅ | ✅ | ❌ | 907 | 164 | /vitejs/vite/v5.4.21 (exact) | -| vite6-env | vite@6.4.3 | ✅ | ✅ | ✅ | ✅ | 803 | 142 | /vitejs/vite (unversioned) | -| express4-wildcard | express@4.21.2 | ❌ | ❌ | ✅ | ❌ | 730 | 86 | /expressjs/express (unversioned) | -| express5-wildcard | express@5.2.1 | ❌ | ❌ | ✅ | ✅ | 944 | 87 | /expressjs/express/v5.2.0 (same major) | -| sa14-column | sqlalchemy@1.4.54 | ✅ | ✅ | ✅ | ❌ | 869 | 855 | /websites/sqlalchemy_en_20 (unversioned) | -| sa20-column | sqlalchemy@2.0.54 | ❌ | ✅ | ✅ | ✅ | 841 | 1036 | /websites/sqlalchemy_en_20 (unversioned) | -| sa14-base | sqlalchemy@1.4.54 | ✅ | ✅ | ✅ | ❌ | 831 | 152 | /websites/sqlalchemy_en_20 (unversioned) | -| sa20-base | sqlalchemy@2.0.54 | ✅ | ✅ | ✅ | ✅ | 860 | 167 | /websites/sqlalchemy_en_20 (unversioned) | -| dj42-dbdefault | django@4.2.30 | ❌ | ❌ | ❌ | ✅ | 852 | 483 | /django/django/4.2.21 (same major) | -| dj51-dbdefault | django@5.1.15 | ❌ | ❌ | ❌ | ✅ | 858 | 491 | /django/django (unversioned) | -| dj42-login | django@4.2.30 | ✅ | ✅ | ✅ | ✅ | 816 | 87 | /django/django/4.2.21 (same major) | -| dj51-login | django@5.1.15 | ✅ | ✅ | ✅ | ✅ | 855 | 92 | /django/django (unversioned) | -| fa088-lifespan | fastapi@0.88.0 | ❌ | ❌ | ❌ | ❌ | 833 | 181 | /websites/fastapi_tiangolo (unversioned) | -| fa0115-lifespan | fastapi@0.115.14 | ❌ | ❌ | ✅ | ✅ | 867 | 231 | /websites/fastapi_tiangolo (unversioned) | -| fa088-annotated | fastapi@0.88.0 | ❌ | ❌ | ✅ | ❌ | 875 | 69 | /websites/fastapi_tiangolo (unversioned) | -| fa0115-annotated | fastapi@0.115.14 | ✅ | ✅ | ✅ | ✅ | 838 | 70 | /websites/fastapi_tiangolo (unversioned) | -| next15-proxy | next@15.1.0 | ✅ | ✅ | ✅ | ❌ | 831 | 90 | /vercel/next.js/v15.1.11 (same major) | -| next16-proxy | next@16.3.6 | ✅ | ✅ | ✅ | ✅ | 869 | 630 | /vercel/next.js/v16.2.9 (same major) | +| zod3-strict | zod@3.23.8 | ✅ | ✅ | ✅ | ✅ | 1075 | 87 | /colinhacks/zod/v3.24.2 (same major) | +| zod4-strict | zod@4.1.5 | ✅ | ❌ | ✅ | ✅ | 1015 | 142 | /colinhacks/zod/v4.0.1 (same major) | +| zod3-email | zod@3.23.8 | ✅ | ✅ | ✅ | ✅ | 1119 | 47 | /colinhacks/zod/v3.24.2 (same major) | +| zod4-email | zod@4.1.5 | ✅ | ✅ | ✅ | ✅ | 903 | 60 | /colinhacks/zod/v4.0.1 (same major) | +| zod3-error | zod@3.23.8 | ✅ | ✅ | ✅ | ✅ | 991 | 47 | /colinhacks/zod/v3.24.2 (same major) | +| zod4-error | zod@4.1.5 | ✅ | ✅ | ✅ | ✅ | 991 | 60 | /colinhacks/zod/v4.0.1 (same major) | +| zod4-record | zod@4.1.5 | ❌ | ❌ | ✅ | ✅ | 946 | 55 | /colinhacks/zod/v4.0.1 (same major) | +| next14-cookies | next@14.2.35 | ❌ | ✅ | ❌ | ❌ | 926 | 395 | /vercel/next.js/v14.3.0-canary.87 (same major) | +| next15-cookies | next@15.1.0 | ✅ | ✅ | ✅ | ✅ | 866 | 448 | /vercel/next.js/v15.1.11 (same major) | +| next14-headers | next@14.2.35 | ✅ | ✅ | ✅ | ✅ | 896 | 94 | /vercel/next.js/v14.3.0-canary.87 (same major) | +| next15-headers | next@15.1.0 | ✅ | ✅ | ✅ | ✅ | 870 | 106 | /vercel/next.js/v15.1.11 (same major) | +| next14-nostore | next@14.2.35 | ✅ | ✅ | ✅ | ✅ | 843 | 97 | /vercel/next.js/v14.3.0-canary.87 (same major) | +| next15-connection | next@15.1.0 | ✅ | ✅ | ✅ | ✅ | 754 | 108 | /vercel/next.js/v15.1.11 (same major) | +| next15-after | next@15.1.0 | ✅ | ✅ | ✅ | ✅ | 827 | 100 | /vercel/next.js/v15.1.11 (same major) | +| rr6-json | react-router@6.26.2 | ✅ | ✅ | ❌ | ❌ | 796 | 120 | /websites/reactrouter (unversioned) | +| rr7-data | react-router@7.1.1 | ✅ | ❌ | ✅ | ✅ | 876 | 144 | /websites/reactrouter (unversioned) | +| rr6-defer | react-router@6.26.2 | ✅ | ✅ | ✅ | ✅ | 872 | 50 | /websites/reactrouter (unversioned) | +| rr6-future | react-router@6.26.2 | ✅ | ✅ | ✅ | ✅ | 755 | 56 | /websites/reactrouter (unversioned) | +| rr7-router | react-router@7.1.1 | ✅ | ✅ | ✅ | ✅ | 807 | 58 | /websites/reactrouter (unversioned) | +| pyd1-dict | pydantic@1.10.18 | ✅ | ✅ | ✅ | ❌ | 846 | 157 | /pydantic/pydantic (unversioned) | +| pyd2-dict | pydantic@2.9.2 | ❌ | ✅ | ✅ | ✅ | 952 | 279 | /pydantic/pydantic (unversioned) | +| pyd1-parse | pydantic@1.10.18 | ❌ | ❌ | ✅ | ❌ | 845 | 64 | /pydantic/pydantic (unversioned) | +| pyd2-parse | pydantic@2.9.2 | ❌ | ✅ | ✅ | ✅ | 825 | 76 | /pydantic/pydantic (unversioned) | +| pyd1-validator | pydantic@1.10.18 | ✅ | ✅ | ✅ | ❌ | 961 | 62 | /pydantic/pydantic (unversioned) | +| pyd2-validator | pydantic@2.9.2 | ❌ | ✅ | ✅ | ✅ | 775 | 77 | /pydantic/pydantic (unversioned) | +| pyd1-schema | pydantic@1.10.18 | ✅ | ✅ | ✅ | ❌ | 818 | 68 | /pydantic/pydantic (unversioned) | +| pyd2-schema | pydantic@2.9.2 | ✅ | ✅ | ✅ | ✅ | 880 | 79 | /pydantic/pydantic (unversioned) | +| pyd1-config | pydantic@1.10.18 | ❌ | ❌ | ❌ | ❌ | 923 | 64 | /pydantic/pydantic (unversioned) | +| pyd2-config | pydantic@2.9.2 | ✅ | ✅ | ✅ | ✅ | 952 | 80 | /pydantic/pydantic (unversioned) | +| axum07-path | axum@0.7.9 | ✅ | ✅ | ✅ | ❌ | 1020 | 103 | /websites/rs_axum (unversioned) | +| axum08-path | axum@0.8.1 | ✅ | ✅ | ✅ | ✅ | 962 | 97 | /websites/rs_axum (unversioned) | +| axum07-extractor | axum@0.7.9 | ✅ | ✅ | ✅ | ❌ | 912 | 46 | /websites/rs_axum (unversioned) | +| axum08-optional | axum@0.8.1 | ✅ | ✅ | ✅ | ✅ | 891 | 41 | /websites/rs_axum (unversioned) | +| tokio-blocking | tokio@1.43.0 | ✅ | ✅ | ✅ | ✅ | 823 | 363 | /websites/rs_tokio_1_49_0 (unversioned) | +| tokio-select | tokio@1.43.0 | ❌ | ❌ | ✅ | ✅ | 962 | 69 | /websites/rs_tokio_1_49_0 (unversioned) | +| tokio-timeout | tokio@1.43.0 | ✅ | ✅ | ✅ | ✅ | 941 | 63 | /websites/rs_tokio_1_49_0 (unversioned) | +| tw3-css | tailwindcss@3.4.19 | ❌ | ❌ | ✅ | ❌ | 971 | 170 | /rails/tailwindcss-rails (unversioned) | +| tw4-css | tailwindcss@4.1.18 | ❌ | ❌ | ✅ | ✅ | 979 | 193 | /rails/tailwindcss-rails (unversioned) | +| tw3-theme | tailwindcss@3.4.19 | ✅ | ✅ | ✅ | ✅ | 1003 | 63 | /rails/tailwindcss-rails (unversioned) | +| tw4-theme | tailwindcss@4.1.18 | ❌ | ❌ | ✅ | ✅ | 1144 | 64 | /rails/tailwindcss-rails (unversioned) | +| eslint8-config | eslint@8.57.1 | ✅ | ✅ | ✅ | ✅ | 796 | 474 | /eslint/eslint/v8.57.1 (exact) | +| eslint9-config | eslint@9.39.5 | ✅ | ✅ | ✅ | ✅ | 720 | 417 | /eslint/eslint/v9.39.3 (same major) | +| eslint8-ignore | eslint@8.57.1 | ❌ | ❌ | ✅ | ✅ | 854 | 88 | /eslint/eslint/v8.57.1 (exact) | +| eslint9-ignore | eslint@9.39.5 | ❌ | ❌ | ✅ | ✅ | 849 | 99 | /eslint/eslint/v9.39.3 (same major) | +| prisma5-bytes | @prisma/client@5.22.0 | ❌ | ❌ | ❌ | ❌ | 846 | 385 | /websites/prisma_io (unversioned) | +| prisma6-bytes | @prisma/client@6.19.3 | ✅ | ✅ | ✅ | ✅ | 848 | 438 | /prisma/web (unversioned) | +| prisma5-fts | prisma@5.22.0 | ❌ | ❌ | ✅ | ❌ | 849 | 385 | /prisma/web (unversioned) | +| prisma6-fts | prisma@6.19.3 | ❌ | ❌ | ✅ | ✅ | 846 | 471 | /prisma/web (unversioned) | +| react18-action | react@18.3.1 | ✅ | ❌ | ❌ | ❌ | 832 | 409 | /reactjs/react.dev (unversioned) | +| react19-action | react@19.2.8 | ❌ | ✅ | ✅ | ✅ | 872 | 464 | /reactjs/react.dev (unversioned) | +| react18-use | react@18.3.1 | ❌ | ❌ | ✅ | ❌ | 823 | 99 | /reactjs/react.dev (unversioned) | +| react19-use | react@19.2.8 | ❌ | ❌ | ✅ | ✅ | 854 | 104 | /reactjs/react.dev (unversioned) | +| vite5-env | vite@5.4.21 | ✅ | ✅ | ✅ | ❌ | 897 | 186 | /vitejs/vite/v5.4.21 (exact) | +| vite6-env | vite@6.4.3 | ✅ | ✅ | ✅ | ✅ | 839 | 165 | /vitejs/vite (unversioned) | +| express4-wildcard | express@4.21.2 | ❌ | ❌ | ✅ | ❌ | 813 | 96 | /expressjs/express (unversioned) | +| express5-wildcard | express@5.2.1 | ❌ | ❌ | ✅ | ✅ | 932 | 97 | /expressjs/express/v5.2.0 (same major) | +| sa14-column | sqlalchemy@1.4.54 | ✅ | ✅ | ✅ | ❌ | 852 | 978 | /websites/sqlalchemy_en_20 (unversioned) | +| sa20-column | sqlalchemy@2.0.54 | ❌ | ✅ | ✅ | ✅ | 815 | 1167 | /websites/sqlalchemy_en_20 (unversioned) | +| sa14-base | sqlalchemy@1.4.54 | ✅ | ✅ | ✅ | ❌ | 828 | 166 | /websites/sqlalchemy_en_20 (unversioned) | +| sa20-base | sqlalchemy@2.0.54 | ✅ | ✅ | ✅ | ✅ | 863 | 190 | /websites/sqlalchemy_en_20 (unversioned) | +| dj42-dbdefault | django@4.2.30 | ❌ | ❌ | ✅ | ✅ | 812 | 1185 | /django/django/4.2.21 (same major) | +| dj51-dbdefault | django@5.1.15 | ❌ | ✅ | ✅ | ✅ | 802 | 1214 | /django/django (unversioned) | +| dj42-login | django@4.2.30 | ✅ | ✅ | ✅ | ✅ | 790 | 206 | /django/django/4.2.21 (same major) | +| dj51-login | django@5.1.15 | ✅ | ✅ | ✅ | ✅ | 736 | 207 | /django/django (unversioned) | +| fa088-lifespan | fastapi@0.88.0 | ❌ | ❌ | ❌ | ❌ | 873 | 203 | /websites/fastapi_tiangolo (unversioned) | +| fa0115-lifespan | fastapi@0.115.14 | ❌ | ❌ | ✅ | ✅ | 828 | 257 | /websites/fastapi_tiangolo (unversioned) | +| fa088-annotated | fastapi@0.88.0 | ❌ | ❌ | ✅ | ❌ | 865 | 76 | /websites/fastapi_tiangolo (unversioned) | +| fa0115-annotated | fastapi@0.115.14 | ✅ | ✅ | ✅ | ✅ | 766 | 82 | /websites/fastapi_tiangolo (unversioned) | +| next15-proxy | next@15.1.0 | ✅ | ✅ | ✅ | ❌ | 797 | 104 | /vercel/next.js/v15.1.11 (same major) | +| next16-proxy | next@16.3.6 | ✅ | ✅ | ✅ | ✅ | 816 | 718 | /vercel/next.js/v16.2.9 (same major) | +| next14-params | next@14.2.35 | ✅ | ✅ | ✅ | ✅ | 881 | 97 | /vercel/next.js/v14.3.0-canary.87 (same major) | +| next15-params | next@15.1.0 | ❌ | ❌ | ✅ | ✅ | 880 | 105 | /vercel/next.js/v15.1.11 (same major) | +| tw3-dark | tailwindcss@3.4.19 | ✅ | ✅ | ✅ | ✅ | 896 | 66 | /erimicel/select2-tailwindcss-theme (unversioned) | +| tw4-dark | tailwindcss@4.1.18 | ❌ | ❌ | ✅ | ❌ | 874 | 68 | /erimicel/select2-tailwindcss-theme (unversioned) | +| react18-ref | react@18.3.1 | ✅ | ✅ | ✅ | ✅ | 899 | 101 | /reactjs/react.dev (unversioned) | +| react19-ref | react@19.2.8 | ❌ | ❌ | ✅ | ✅ | 796 | 111 | /reactjs/react.dev (unversioned) | +| rr6-types | react-router@6.26.2 | ✅ | ✅ | ✅ | ❌ | 790 | 57 | /websites/reactrouter (unversioned) | +| rr7-types | react-router@7.1.1 | ❌ | ❌ | ✅ | ✅ | 851 | 58 | /websites/reactrouter (unversioned) | +| eslint8-globals | eslint@8.57.1 | ❌ | ❌ | ❌ | ✅ | 768 | 95 | /eslint/eslint/v8.57.1 (exact) | +| eslint9-globals | eslint@9.39.5 | ❌ | ❌ | ✅ | ✅ | 741 | 96 | /eslint/eslint/v9.39.3 (same major) | +| dj51-generated | django@5.1.15 | ❌ | ❌ | ✅ | ✅ | 750 | 218 | /django/django (unversioned) | +| fa0115-querymodel | fastapi@0.115.14 | ❌ | ❌ | ✅ | ✅ | 887 | 85 | /websites/fastapi_tiangolo (unversioned) | +| tokio-channel | tokio@1.43.0 | ✅ | ✅ | ✅ | ✅ | 881 | 71 | /websites/rs_tokio_tokio (unversioned) | +| zod3-datetime | zod@3.23.8 | ✅ | ✅ | ✅ | ❌ | 1162 | 44 | /colinhacks/zod/v3.24.2 (same major) | +| zod4-datetime | zod@4.1.5 | ✅ | ✅ | ✅ | ✅ | 1158 | 61 | /colinhacks/zod/v4.0.1 (same major) | +| pyd1-frozen | pydantic@1.10.18 | ✅ | ❌ | ✅ | ❌ | 930 | 69 | /pydantic/pydantic (unversioned) | +| pyd2-frozen | pydantic@2.9.2 | ❌ | ❌ | ✅ | ✅ | 884 | 82 | /pydantic/pydantic (unversioned) | +| next15-form (held-out) | next@15.1.0 | ❌ | ❌ | ✅ | ✅ | 898 | 103 | /vercel/next.js/v15.1.11 (same major) | +| next16-cache (held-out) | next@16.3.6 | ✅ | ✅ | ✅ | ✅ | 856 | 142 | /vercel/next.js/v16.2.9 (same major) | +| zod3-meta (held-out) | zod@3.23.8 | ✅ | ✅ | ✅ | ✅ | 975 | 45 | /colinhacks/zod/v3.24.2 (same major) | +| zod4-meta (held-out) | zod@4.1.5 | ❌ | ❌ | ✅ | ✅ | 888 | 55 | /colinhacks/zod/v4.0.1 (same major) | +| pyd2-computed (held-out) | pydantic@2.9.2 | ✅ | ✅ | ✅ | ✅ | 892 | 82 | /pydantic/pydantic (unversioned) | +| pyd1-json (held-out) | pydantic@1.10.18 | ❌ | ❌ | ✅ | ❌ | 938 | 70 | /pydantic/pydantic (unversioned) | +| pyd2-json (held-out) | pydantic@2.9.2 | ✅ | ✅ | ✅ | ✅ | 872 | 79 | /pydantic/pydantic (unversioned) | +| sa20-dataclass (held-out) | sqlalchemy@2.0.54 | ✅ | ✅ | ✅ | ✅ | 867 | 193 | /websites/sqlalchemy_en_20 (unversioned) | +| eslint8-plugins (held-out) | eslint@8.57.1 | ❌ | ❌ | ✅ | ✅ | 784 | 90 | /eslint/eslint/v8.57.1 (exact) | +| eslint9-plugins (held-out) | eslint@9.39.5 | ❌ | ❌ | ❌ | ✅ | 788 | 101 | /eslint/eslint/v9.39.3 (same major) | +| vite6-envapi (held-out) | vite@6.4.3 | ✅ | ❌ | ✅ | ✅ | 673 | 62 | /vitejs/vite (unversioned) | +| react18-context (held-out) | react@18.3.1 | ✅ | ❌ | ✅ | ❌ | 823 | 98 | /reactjs/react.dev (unversioned) | +| react19-context (held-out) | react@19.2.8 | ❌ | ❌ | ✅ | ✅ | 812 | 104 | /reactjs/react.dev (unversioned) | +| rr7-routesfile (held-out) | react-router@7.1.1 | ❌ | ❌ | ✅ | ✅ | 898 | 55 | /websites/reactrouter (unversioned) | +| dj51-facets (held-out) | django@5.1.15 | ❌ | ❌ | ✅ | ✅ | 769 | 209 | /django/django (unversioned) | +| tw3-source (held-out) | tailwindcss@3.4.19 | ❌ | ❌ | ✅ | ✅ | 860 | 62 | /rails/tailwindcss-rails (unversioned) | +| tw4-source (held-out) | tailwindcss@4.1.18 | ❌ | ❌ | ❌ | ❌ | 939 | 67 | /rails/tailwindcss-rails (unversioned) | +| tokio-interval (held-out) | tokio@1.43.0 | ✅ | ✅ | ✅ | ✅ | 890 | 65 | /websites/rs_tokio_tokio (unversioned) | Index build per project (cold cache, all direct deps, package files): -axum07 345 ms, axum08 401 ms, django42 473 ms, django51 480 ms, eslint8 182 ms, eslint9 80 ms, express4 72 ms, express5 48 ms, fastapi0115 83 ms, fastapi088 71 ms, next14 378 ms, next15 603 ms, next16 868 ms, prisma5 92 ms, prisma6 123 ms, pydantic1 98 ms, pydantic2 176 ms, react18 361 ms, react19 659 ms, rr6 279 ms, rr7 524 ms, sqlalchemy14 466 ms, sqlalchemy20 604 ms, tailwind3 50 ms, tailwind4 46 ms, vite5 91 ms, vite6 90 ms, zod3 65 ms, zod4 106 ms +axum07 391 ms, axum08 382 ms, django42 532 ms, django51 534 ms, eslint8 185 ms, eslint9 78 ms, express4 81 ms, express5 48 ms, fastapi0115 89 ms, fastapi088 69 ms, next14 391 ms, next15 576 ms, next16 1070 ms, prisma5 97 ms, prisma6 125 ms, pydantic1 106 ms, pydantic2 184 ms, react18 370 ms, react19 662 ms, rr6 290 ms, rr7 515 ms, sqlalchemy14 500 ms, sqlalchemy20 650 ms, tailwind3 46 ms, tailwind4 44 ms, vite5 93 ms, vite6 100 ms, zod3 73 ms, zod4 115 ms `lockdocs fetch` per project (one-time; upstream docs from GitHub at the version tag): -- axum07: 1636 ms (axum@0.7.9 2 files, tokio@1.41.1 1 files) -- axum08: 2101 ms (tokio@1.43.0 1 files) -- django42: 4897 ms (django@4.2.30 601 files) -- django51: 4876 ms (django@5.1.15 629 files) -- eslint8: 3197 ms (eslint@8.57.1 409 files) -- eslint9: 3391 ms (eslint@9.39.5 435 files) -- express4: 2984 ms (@types/express@4.17.25 6 files, express@4.21.2 10 files) -- express5: 3046 ms (@types/express@5.0.6 38 files, express@5.2.1 40 files) -- fastapi0115: 2482 ms (fastapi@0.115.14 180 files) -- fastapi088: 2335 ms (fastapi@0.88.0 113 files) -- next14: 4976 ms (next@14.2.35 318 files, react@18.3.1 3 files, react-dom@18.3.1 3 files) -- next15: 3429 ms (next@15.1.0 365 files, react@19.0.0 184 files, react-dom@19.0.0 184 files) -- next16: 4143 ms (next@16.3.6 458 files, react@19.2.8 183 files, react-dom@19.2.8 183 files) -- prisma5: 1697 ms (@prisma/client@5.22.0 2 files, prisma@5.22.0 2 files) -- prisma6: 1739 ms (@prisma/client@6.19.3 2 files, prisma@6.19.3 2 files) -- pydantic1: 1635 ms (pydantic@1.10.18 176 files) -- pydantic2: 1223 ms (pydantic@2.9.2 80 files) -- react18: 2396 ms (react@18.3.1 3 files, react-dom@18.3.1 3 files) -- react19: 3314 ms (@types/react@19.2.18 180 files, react@19.2.8 183 files, react-dom@19.2.8 183 files) -- rr6: 2184 ms (react@18.3.1 3 files, react-dom@18.3.1 3 files, react-router@6.26.2 118 files, react-router-dom@6.26.2 118 files) -- rr7: 2112 ms (react@19.0.0 184 files, react-dom@19.0.0 184 files, react-router@7.1.1 68 files) -- sqlalchemy14: 3270 ms (sqlalchemy@1.4.54 179 files) -- sqlalchemy20: 3201 ms (sqlalchemy@2.0.54 198 files) -- tailwind3: 725 ms (tailwindcss@3.4.19 2 files) -- tailwind4: 2472 ms (tailwindcss@4.1.18 200 files) -- vite5: 1379 ms (vite@5.4.21 37 files) -- vite6: 1511 ms (vite@6.4.3 47 files) -- zod3: 612 ms (zod@3.23.8 4 files) -- zod4: 1219 ms (zod@4.1.5 18 files) +- axum07: 2469 ms (axum@0.7.9 2 files, tokio@1.41.1 18 files) +- axum08: 2192 ms (tokio@1.43.0 18 files) +- django42: 4160 ms (django@4.2.30 601 files) +- django51: 4787 ms (django@5.1.15 629 files) +- eslint8: 3476 ms (eslint@8.57.1 409 files) +- eslint9: 3330 ms (eslint@9.39.5 435 files) +- express4: 3582 ms (@types/express@4.17.25 6 files, express@4.21.2 10 files) +- express5: 2913 ms (@types/express@5.0.6 38 files, express@5.2.1 40 files) +- fastapi0115: 2264 ms (fastapi@0.115.14 180 files) +- fastapi088: 2594 ms (fastapi@0.88.0 113 files) +- next14: 3699 ms (next@14.2.35 318 files, react@18.3.1 140 files, react-dom@18.3.1 140 files) +- next15: 4137 ms (next@15.1.0 365 files, react@19.0.0 184 files, react-dom@19.0.0 184 files) +- next16: 3730 ms (next@16.3.6 458 files, react@19.2.8 183 files, react-dom@19.2.8 183 files) +- prisma5: 6014 ms (@prisma/client@5.22.0 236 files, prisma@5.22.0 236 files) +- prisma6: 3632 ms (@prisma/client@6.19.3 247 files, prisma@6.19.3 247 files) +- pydantic1: 1757 ms (pydantic@1.10.18 176 files) +- pydantic2: 1177 ms (pydantic@2.9.2 80 files) +- react18: 2923 ms (@types/react@18.3.31 137 files, react@18.3.1 140 files, react-dom@18.3.1 140 files) +- react19: 2912 ms (@types/react@19.2.18 180 files, react@19.2.8 183 files, react-dom@19.2.8 183 files) +- rr6: 2148 ms (react@18.3.1 140 files, react-dom@18.3.1 140 files, react-router@6.26.2 118 files, react-router-dom@6.26.2 118 files) +- rr7: 2033 ms (react@19.0.0 184 files, react-dom@19.0.0 184 files, react-router@7.1.1 68 files) +- sqlalchemy14: 2995 ms (sqlalchemy@1.4.54 179 files) +- sqlalchemy20: 3057 ms (sqlalchemy@2.0.54 198 files) +- tailwind3: 3464 ms (tailwindcss@3.4.19 192 files) +- tailwind4: 7799 ms (tailwindcss@4.1.18 224 files) +- vite5: 1136 ms (vite@5.4.21 37 files) +- vite6: 1367 ms (vite@6.4.3 47 files) +- zod3: 630 ms (zod@3.23.8 4 files) +- zod4: 1337 ms (zod@4.1.5 18 files) ## Reading the results -- **lockdocs is ahead overall (55/70 vs 49/70), on older majors by a wide margin (24/33 vs 12/33), on tokens (875 vs 908 median) and on latency (87 ms vs 2,011 ms).** Context7 serves one or a few indexed versions per library, so questions about the version you actually pinned often get the newest API. -- **Context7 is ahead on the newest majors (34/34 vs 29/34) and on tokio (3/3 vs 2/3).** lockdocs misses where the answer lives only in a docs website that tracks a different major than the pinned one (Prisma 6: the Prisma docs now describe a later major), and on a few ranking misses (Next.js `after`, Tailwind 4's `@import "tailwindcss"`, Django `db_default`, tokio `select!` for "whichever finishes first"). -- **Configurations matter.** Keyword-only on package files: 41/70. Adding the local embedding model: 46/70. Adding `lockdocs fetch` (upstream docs at each version's git tag, plus docs-site repositories when the pinned major is the current one): 55/70. +- **lockdocs is ahead overall (96/105 vs 77/105) and on older majors by 2x (38/45 vs 19/45), ties Context7 on the newest majors (53/55 each) and on tokio (5/5 each), and is ahead on the held-out questions (16/18 vs 15/18).** It also uses fewer tokens (866 vs 908 median) and answers in 97 ms instead of 2,583 ms. +- **Where each still misses.** lockdocs: seven older-major questions (among them Next.js 14 `cookies()`, React Router 6 `json()`, FastAPI 0.88 `on_event`, Prisma 5 `Buffer`, ESLint 8 `env`), and two held-out newer-major questions (ESLint 9 plugins, Tailwind 4 `@source`). Context7 mostly answers older-major questions with the newest API, and two of its Tailwind answers came from unrelated libraries its search ranked first. +- **Configurations matter.** Keyword-only on package files: 59/105. Adding the local embedding model: 60/105. Adding `lockdocs fetch` (upstream docs at each version's git tag, plus docs-site repositories for your major): 96/105. - Latency for lockdocs is a fresh CLI process per question, including loading the embedding model; the MCP server keeps it loaded. -- Context7 answers for questions unchanged since the previous run on the same pinned version were reused from that run (disclosed in the results line) to stay within the anonymous quota. +- Context7 answers for questions whose wording, grading and pinned version are unchanged since the previous run were reused from that run (disclosed in the results line) to stay within the anonymous quota. - Contributions of new version-sensitive questions are welcome. diff --git a/docs/capabilities.md b/docs/capabilities.md new file mode 100644 index 0000000..d671d51 --- /dev/null +++ b/docs/capabilities.md @@ -0,0 +1,21 @@ +# lockdocs capabilities + +What lockdocs can do today and where the code is. CI checks that every path +in the Code column exists (`scripts/check-capabilities.ts`). The destination +is in [vision.md](vision.md). + +| ID | Capability | Status | Code | Depends on | +| --- | --- | --- | --- | --- | +| LD-LOCKFILE | Read exact versions from npm, pnpm, Yarn, Bun, Cargo, uv, Poetry, PDM, Pipenv, pip requirements and Go lockfiles | supported | crates/lockdocs-core/src/lockfile.rs, crates/lockdocs-core/src/project.rs | | +| LD-LOCATE | Find each package's files on disk (node_modules, Yarn Plug'n'Play, virtualenvs, Cargo registry and git checkouts, Go module cache) | supported | crates/lockdocs-core/src/locate.rs | LD-LOCKFILE | +| LD-REGISTRY-FETCH | Download a pinned package that is not installed, from its registry (opt-in) | supported | crates/lockdocs-core/src/fetch.rs | LD-LOCKFILE | +| LD-UPSTREAM | Download the docs folders of a package's GitHub repository at the pinned version's git tag (opt-in) | supported | crates/lockdocs-core/src/upstream.rs | LD-LOCATE | +| LD-DOCS-SITE | Add an official docs-site repository for the pinned major: the default branch for the latest major, a `vN` branch or the last commit before the next major for older ones (React, Express, Tailwind CSS, Prisma, tokio) | partial | crates/lockdocs-core/src/upstream.rs | LD-UPSTREAM | +| LD-EXTRACT | Split Markdown, MDX, reStructuredText and component-based docs pages into sections; extract TypeScript, Python, Rust and Go symbols with signatures and doc comments | supported | crates/lockdocs-core/src/extract.rs, crates/lockdocs-core/src/markdown.rs | LD-LOCATE | +| LD-INDEX | Cache one index per package, version and source location | supported | crates/lockdocs-core/src/index.rs, crates/lockdocs-core/src/cache.rs | LD-EXTRACT | +| LD-SEARCH | Rank sections and symbols: BM25 on full text and on headings, a local embedding model, and docs-specific signals | supported | crates/lockdocs-core/src/bm25.rs, crates/lockdocs-core/src/embed.rs, crates/lockdocs-core/src/query.rs | LD-INDEX | +| LD-MCP | MCP server with the `resolve`, `docs` and `api` tools over stdio | supported | crates/lockdocs/src/mcp.rs, crates/lockdocs/src/tools.rs | LD-SEARCH | +| LD-CLI | Command line with the same queries, plus `index`, `fetch` and `setup` | supported | crates/lockdocs/src/main.rs, crates/lockdocs/src/setup.rs | LD-SEARCH | +| LD-NPM | npm launcher and native binaries for five platforms | supported | packages/lockdocs, packages/npm | LD-CLI | +| LD-BENCH | Version-sensitive benchmark against Context7, run on GitHub-hosted runners | supported | bench/run.py, bench/questions.json, .github/workflows/bench.yml | LD-CLI | +| LD-SEMANTIC-RERANK | Rerank the top results with a stronger local model for questions worded differently from the docs | planned | | LD-SEARCH | diff --git a/docs/guide/fetch.md b/docs/guide/fetch.md index 23f3043..d095ffe 100644 --- a/docs/guide/fetch.md +++ b/docs/guide/fetch.md @@ -21,7 +21,15 @@ Fetched for 3 packages in 10016 ms (cached; later queries stay offline): next@15.1.0 upstream docs github.com/vercel/next.js@v15.1.0: 365 files (2.0 MB) ``` -Answers then cite `next@15.1.0 upstream:docs/01-app/.../cookies.mdx:12` and name the repository and tag in the header. Set `GITHUB_TOKEN` to raise GitHub's API limit (60 requests an hour without it; a package takes 2 to 12). +Answers then cite `next@15.1.0 upstream:docs/01-app/.../cookies.mdx:12` and name the repository and tag in the header. + +Some projects keep their docs in a separate website repository: React (react.dev), Express (expressjs.com), Tailwind CSS (tailwindcss.com), Prisma (prisma/docs) and tokio (tokio-rs/website). For these, `fetch` also takes the docs that describe your major: + +- your major is the latest: the site's default branch; +- an older major: the site's `vN` or `N.x` branch when it has one (Tailwind CSS `v3`, Prisma `v6`), otherwise the last commit before the next major was released (the date of its `N+1.0.0` tag). React is excluded from the second rule because react.dev documents APIs before they ship; +- pages about a later major (`v4-beta.mdx` on the v3 branch) are skipped. + +The header says which: `github.com/prisma/docs@v6 (branch v6 for major 6)`. After upgrading lockdocs, run `lockdocs fetch` again to refresh copies made by an older version. Set `GITHUB_TOKEN` to raise GitHub's API limit (60 requests an hour without it; a package takes 2 to 20). To have it happen automatically on first query instead, enable fetching: `--fetch`, `LOCKDOCS_FETCH=1`, or register the MCP server with `lockdocs setup --fetch`. diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index 2e655d5..2be4f67 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -2,9 +2,9 @@ 1. **Resolve.** Every lockfile in the project directory, and in monorepo roots above it for ecosystems not found yet, becomes a list of exact `(ecosystem, name, version)` triples. Direct dependencies are marked. 2. **Locate.** Each package's files are found on disk (see [Ecosystems](./ecosystems)). If the installed version differs from the lockfile, the answer says so and shows the installed one; with fetching enabled it reads the pinned version instead. -3. **Extract.** Markdown and reStructuredText are split into heading-scoped sections (long ones at paragraph boundaries, never inside code fences). Source files are parsed with tree-sitter (TypeScript, Python, Rust, Go) into symbols with a signature (the declaration without its body), a cleaned doc comment, a qualified path and a line number. -4. **Index.** Each entry becomes weighted terms: names and headings count most, doc prose next, signatures and code examples least. The tokenizer keeps identifiers whole and also splits camelCase, snake_case and digits; a light stemmer and a short programming synonym table (dict/dump, parse/validate, error/exception, ...) bridge wording. The index is cached on disk per package, version and source location, so a version is indexed once. -5. **Rank.** BM25 fused with embedding similarity (a static model2vec model: a text's vector is the mean of its WordPiece token vectors, so embedding every symbol of Next.js takes well under a second), each normalized to its best hit. Then signals that matter for docs: entries whose name or heading contains a query word, identifiers written in the question (`model_dump`, `z.object`), changelog sections only when the question is about changes, and lower weight for private modules, bundled tooling and legacy copies. Deprecation notes among the top results ("use `model_validate` instead") lift the API they point to. -6. **Pack.** Results are added in rank order until the token budget is spent (the last one trimmed at a line boundary), each with a `package@version path:line` citation. +3. **Extract.** Markdown and reStructuredText are split into heading-scoped sections (long ones at paragraph boundaries, never inside code fences). HTML headings in MDX (`

@import

`) count as headings, and `export const title` names the page. Docs pages written as React components (Tailwind's installation guides) become sections too: step titles are headings, JSX text is prose, and `code:` strings are code blocks. Source files are parsed with tree-sitter (TypeScript, Python, Rust, Go) into symbols with a signature (the declaration without its body), a cleaned doc comment, a qualified path and a line number. +4. **Index.** Each entry becomes weighted terms: names and headings count most, doc prose next, signatures and code examples least. The tokenizer keeps identifiers whole and also splits camelCase, snake_case and digits; a light stemmer (plurals, -ing, -ed, -ation/-ate, -ability/-able) and a short programming synonym table (dict/dump, parse/validate, parameter/param, JavaScript/JS, ...) bridge wording. The index is cached on disk per package, version and source location, so a version is indexed once. +5. **Rank.** BM25 over the full text, BM25 over just the heading (or name) and first sentence, so a long body does not bury what an entry says it is, and embedding similarity (a static model2vec model: a text's vector is the mean of its WordPiece token vectors, so embedding every symbol of Next.js takes well under a second), each normalized to its best hit. Then signals that matter for docs: entries whose name or heading contains a query word, identifiers written in the question (`model_dump`, `z.object`) and plain question words that name a documented top-level API of the package ("run code *after* the response" in Next.js, which exports `after`), changelog sections only when the question is about changes, and lower weight for private modules, bundled tooling, legacy copies, upgrade guides to an older major than yours ("Migrating to v6" in ESLint 9) and pages titled "(Deprecated)". Generic headings such as Parameters, Returns or Examples take their topic from the heading above them. Deprecation notes among the top results ("use `model_validate` instead") lift the API they point to. +6. **Pack.** The top result also quotes the first paragraph of its page and parent section (with the short list or code block that follows), when they add something: a deprecation notice at the top of the page, or the setup a subsection builds on. Results are added in rank order until the token budget is spent (the last one trimmed at a line boundary), each with a `package@version path:line` citation. `api` is exact rather than ranked: it finds entries whose name matches the last segment of the symbol, scores qualifiers (`Router` in `axum::Router::route`), follows re-exports, and adds overloads, members and other matches. diff --git a/docs/public/bench-results.json b/docs/public/bench-results.json index b8924b0..a0b9a14 100644 --- a/docs/public/bench-results.json +++ b/docs/public/bench-results.json @@ -2,13 +2,13 @@ "tokenizer": "tiktoken o200k_base", "index": { "axum07": { - "ms": 345, + "ms": 391, "report": { "missing": [], - "ms": 333, + "ms": 378, "packages": [ { - "build_ms": 209, + "build_ms": 164, "ecosystem": "cargo", "package": "axum@0.7.9", "sections": 262, @@ -16,7 +16,7 @@ "symbols": 236 }, { - "build_ms": 287, + "build_ms": 319, "ecosystem": "cargo", "package": "tokio@1.41.1", "sections": 551, @@ -27,13 +27,13 @@ } }, "axum08": { - "ms": 401, + "ms": 382, "report": { "missing": [], - "ms": 390, + "ms": 370, "packages": [ { - "build_ms": 179, + "build_ms": 170, "ecosystem": "cargo", "package": "axum@0.8.1", "sections": 272, @@ -41,7 +41,7 @@ "symbols": 264 }, { - "build_ms": 349, + "build_ms": 324, "ecosystem": "cargo", "package": "tokio@1.43.0", "sections": 561, @@ -52,13 +52,13 @@ } }, "django42": { - "ms": 473, + "ms": 532, "report": { "missing": [], - "ms": 459, + "ms": 516, "packages": [ { - "build_ms": 400, + "build_ms": 451, "ecosystem": "pypi", "package": "django@4.2.30", "sections": 159, @@ -69,13 +69,13 @@ } }, "django51": { - "ms": 480, + "ms": 534, "report": { "missing": [], - "ms": 467, + "ms": 517, "packages": [ { - "build_ms": 408, + "build_ms": 453, "ecosystem": "pypi", "package": "django@5.1.15", "sections": 159, @@ -86,16 +86,16 @@ } }, "eslint8": { - "ms": 182, + "ms": 185, "report": { "missing": [], - "ms": 177, + "ms": 179, "packages": [ { - "build_ms": 137, + "build_ms": 144, "ecosystem": "npm", "package": "eslint@8.57.1", - "sections": 26, + "sections": 27, "source": "node_modules/eslint", "symbols": 893 } @@ -103,16 +103,16 @@ } }, "eslint9": { - "ms": 80, + "ms": 78, "report": { "missing": [], - "ms": 75, + "ms": 73, "packages": [ { - "build_ms": 35, + "build_ms": 39, "ecosystem": "npm", "package": "eslint@9.39.5", - "sections": 27, + "sections": 28, "source": "node_modules/eslint", "symbols": 855 } @@ -120,10 +120,10 @@ } }, "express4": { - "ms": 72, + "ms": 81, "report": { "missing": [], - "ms": 67, + "ms": 77, "packages": [ { "build_ms": 2, @@ -134,7 +134,7 @@ "symbols": 35 }, { - "build_ms": 17, + "build_ms": 22, "ecosystem": "npm", "package": "express@4.21.2", "sections": 276, @@ -151,7 +151,7 @@ "ms": 44, "packages": [ { - "build_ms": 1, + "build_ms": 4, "ecosystem": "npm", "package": "@types/express@5.0.6", "sections": 5, @@ -159,7 +159,7 @@ "symbols": 34 }, { - "build_ms": 9, + "build_ms": 7, "ecosystem": "npm", "package": "express@5.2.1", "sections": 18, @@ -170,13 +170,13 @@ } }, "fastapi0115": { - "ms": 83, + "ms": 89, "report": { "missing": [], - "ms": 79, + "ms": 85, "packages": [ { - "build_ms": 34, + "build_ms": 36, "ecosystem": "pypi", "package": "fastapi@0.115.14", "sections": 27, @@ -187,10 +187,10 @@ } }, "fastapi088": { - "ms": 71, + "ms": 69, "report": { "missing": [], - "ms": 67, + "ms": 66, "packages": [ { "build_ms": 20, @@ -204,16 +204,16 @@ } }, "next14": { - "ms": 378, + "ms": 391, "report": { "missing": [], - "ms": 366, + "ms": 379, "packages": [ { - "build_ms": 322, + "build_ms": 331, "ecosystem": "npm", "package": "next@14.2.35", - "sections": 9, + "sections": 10, "source": "node_modules/next", "symbols": 4904 }, @@ -226,7 +226,7 @@ "symbols": 0 }, { - "build_ms": 232, + "build_ms": 235, "ecosystem": "npm", "package": "react-dom@18.3.1", "sections": 7, @@ -237,13 +237,13 @@ } }, "next15": { - "ms": 603, + "ms": 576, "report": { "missing": [], - "ms": 591, + "ms": 563, "packages": [ { - "build_ms": 404, + "build_ms": 426, "ecosystem": "npm", "package": "next@15.1.0", "sections": 9, @@ -251,7 +251,7 @@ "symbols": 5585 }, { - "build_ms": 53, + "build_ms": 337, "ecosystem": "npm", "package": "react@19.0.0", "sections": 4, @@ -259,7 +259,7 @@ "symbols": 0 }, { - "build_ms": 550, + "build_ms": 506, "ecosystem": "npm", "package": "react-dom@19.0.0", "sections": 7, @@ -270,13 +270,13 @@ } }, "next16": { - "ms": 868, + "ms": 1070, "report": { "missing": [], - "ms": 840, + "ms": 1038, "packages": [ { - "build_ms": 773, + "build_ms": 965, "ecosystem": "npm", "package": "next@16.3.6", "sections": 3598, @@ -284,7 +284,7 @@ "symbols": 8354 }, { - "build_ms": 429, + "build_ms": 455, "ecosystem": "npm", "package": "react@19.2.8", "sections": 4, @@ -292,7 +292,7 @@ "symbols": 0 }, { - "build_ms": 550, + "build_ms": 711, "ecosystem": "npm", "package": "react-dom@19.2.8", "sections": 7, @@ -303,13 +303,13 @@ } }, "prisma5": { - "ms": 92, + "ms": 97, "report": { "missing": [], - "ms": 87, + "ms": 92, "packages": [ { - "build_ms": 47, + "build_ms": 54, "ecosystem": "npm", "package": "@prisma/client@5.22.0", "sections": 4, @@ -317,7 +317,7 @@ "symbols": 814 }, { - "build_ms": 50, + "build_ms": 51, "ecosystem": "npm", "package": "prisma@5.22.0", "sections": 14, @@ -328,13 +328,13 @@ } }, "prisma6": { - "ms": 123, + "ms": 125, "report": { "missing": [], - "ms": 118, + "ms": 119, "packages": [ { - "build_ms": 82, + "build_ms": 84, "ecosystem": "npm", "package": "@prisma/client@6.19.3", "sections": 4, @@ -342,7 +342,7 @@ "symbols": 851 }, { - "build_ms": 77, + "build_ms": 79, "ecosystem": "npm", "package": "prisma@6.19.3", "sections": 14, @@ -353,13 +353,13 @@ } }, "pydantic1": { - "ms": 98, + "ms": 106, "report": { "missing": [], - "ms": 94, + "ms": 101, "packages": [ { - "build_ms": 50, + "build_ms": 56, "ecosystem": "pypi", "package": "pydantic@1.10.18", "sections": 135, @@ -370,13 +370,13 @@ } }, "pydantic2": { - "ms": 176, + "ms": 184, "report": { "missing": [], - "ms": 170, + "ms": 177, "packages": [ { - "build_ms": 121, + "build_ms": 129, "ecosystem": "pypi", "package": "pydantic@2.9.2", "sections": 191, @@ -387,13 +387,13 @@ } }, "react18": { - "ms": 361, + "ms": 370, "report": { "missing": [], - "ms": 351, + "ms": 359, "packages": [ { - "build_ms": 149, + "build_ms": 287, "ecosystem": "npm", "package": "@types/react@18.3.31", "sections": 5, @@ -401,7 +401,7 @@ "symbols": 1853 }, { - "build_ms": 15, + "build_ms": 18, "ecosystem": "npm", "package": "@types/react-dom@18.3.7", "sections": 5, @@ -409,7 +409,7 @@ "symbols": 295 }, { - "build_ms": 284, + "build_ms": 182, "ecosystem": "npm", "package": "react@18.3.1", "sections": 4, @@ -417,7 +417,7 @@ "symbols": 1853 }, { - "build_ms": 265, + "build_ms": 272, "ecosystem": "npm", "package": "react-dom@18.3.1", "sections": 7, @@ -428,13 +428,13 @@ } }, "react19": { - "ms": 659, + "ms": 662, "report": { "missing": [], - "ms": 649, + "ms": 651, "packages": [ { - "build_ms": 351, + "build_ms": 145, "ecosystem": "npm", "package": "@types/react@19.2.18", "sections": 5, @@ -450,7 +450,7 @@ "symbols": 206 }, { - "build_ms": 353, + "build_ms": 216, "ecosystem": "npm", "package": "react@19.2.8", "sections": 4, @@ -458,7 +458,7 @@ "symbols": 1829 }, { - "build_ms": 582, + "build_ms": 589, "ecosystem": "npm", "package": "react-dom@19.2.8", "sections": 7, @@ -469,13 +469,13 @@ } }, "rr6": { - "ms": 279, + "ms": 290, "report": { "missing": [], - "ms": 271, + "ms": 282, "packages": [ { - "build_ms": 45, + "build_ms": 47, "ecosystem": "npm", "package": "react@18.3.1", "sections": 4, @@ -483,7 +483,7 @@ "symbols": 0 }, { - "build_ms": 198, + "build_ms": 216, "ecosystem": "npm", "package": "react-dom@18.3.1", "sections": 7, @@ -491,7 +491,7 @@ "symbols": 0 }, { - "build_ms": 25, + "build_ms": 24, "ecosystem": "npm", "package": "react-router@6.26.2", "sections": 74, @@ -499,7 +499,7 @@ "symbols": 207 }, { - "build_ms": 204, + "build_ms": 218, "ecosystem": "npm", "package": "react-router-dom@6.26.2", "sections": 80, @@ -510,13 +510,13 @@ } }, "rr7": { - "ms": 524, + "ms": 515, "report": { "missing": [], - "ms": 516, + "ms": 507, "packages": [ { - "build_ms": 259, + "build_ms": 258, "ecosystem": "npm", "package": "react@19.0.0", "sections": 4, @@ -524,7 +524,7 @@ "symbols": 0 }, { - "build_ms": 461, + "build_ms": 450, "ecosystem": "npm", "package": "react-dom@19.0.0", "sections": 7, @@ -532,7 +532,7 @@ "symbols": 0 }, { - "build_ms": 281, + "build_ms": 296, "ecosystem": "npm", "package": "react-router@7.1.1", "sections": 92, @@ -543,13 +543,13 @@ } }, "sqlalchemy14": { - "ms": 466, + "ms": 500, "report": { "missing": [], - "ms": 451, + "ms": 483, "packages": [ { - "build_ms": 392, + "build_ms": 417, "ecosystem": "pypi", "package": "sqlalchemy@1.4.54", "sections": 8, @@ -560,13 +560,13 @@ } }, "sqlalchemy20": { - "ms": 604, + "ms": 650, "report": { "missing": [], - "ms": 588, + "ms": 631, "packages": [ { - "build_ms": 521, + "build_ms": 568, "ecosystem": "pypi", "package": "sqlalchemy@2.0.54", "sections": 8, @@ -577,10 +577,10 @@ } }, "tailwind3": { - "ms": 50, + "ms": 46, "report": { "missing": [], - "ms": 46, + "ms": 42, "packages": [ { "build_ms": 9, @@ -594,13 +594,13 @@ } }, "tailwind4": { - "ms": 46, + "ms": 44, "report": { "missing": [], - "ms": 43, + "ms": 40, "packages": [ { - "build_ms": 6, + "build_ms": 7, "ecosystem": "npm", "package": "tailwindcss@4.1.18", "sections": 4, @@ -611,13 +611,13 @@ } }, "vite5": { - "ms": 91, + "ms": 93, "report": { "missing": [], - "ms": 87, + "ms": 88, "packages": [ { - "build_ms": 46, + "build_ms": 51, "ecosystem": "npm", "package": "vite@5.4.21", "sections": 131, @@ -628,13 +628,13 @@ } }, "vite6": { - "ms": 90, + "ms": 100, "report": { "missing": [], - "ms": 85, + "ms": 94, "packages": [ { - "build_ms": 49, + "build_ms": 55, "ecosystem": "npm", "package": "vite@6.4.3", "sections": 91, @@ -645,13 +645,13 @@ } }, "zod3": { - "ms": 65, + "ms": 73, "report": { "missing": [], - "ms": 61, + "ms": 69, "packages": [ { - "build_ms": 28, + "build_ms": 31, "ecosystem": "npm", "package": "zod@3.23.8", "sections": 121, @@ -662,16 +662,16 @@ } }, "zod4": { - "ms": 106, + "ms": 115, "report": { "missing": [], - "ms": 101, + "ms": 109, "packages": [ { - "build_ms": 63, + "build_ms": 68, "ecosystem": "npm", "package": "zod@4.1.5", - "sections": 8, + "sections": 9, "source": "node_modules/zod", "symbols": 3164 } @@ -681,10 +681,10 @@ }, "fetch": { "axum07": { - "ms": 1636, + "ms": 2469, "report": { "model": true, - "ms": 1632, + "ms": 2465, "packages": [ { "files": 2, @@ -694,7 +694,7 @@ "tag": "axum-v0.7.9" }, { - "files": 1, + "files": 18, "note": null, "package": "tokio@1.41.1", "repo": "github.com/tokio-rs/tokio", @@ -704,10 +704,10 @@ } }, "axum08": { - "ms": 2101, + "ms": 2192, "report": { "model": true, - "ms": 2098, + "ms": 2187, "packages": [ { "files": 0, @@ -717,7 +717,7 @@ "tag": null }, { - "files": 1, + "files": 18, "note": null, "package": "tokio@1.43.0", "repo": "github.com/tokio-rs/tokio", @@ -727,10 +727,10 @@ } }, "django42": { - "ms": 4897, + "ms": 4160, "report": { "model": true, - "ms": 4893, + "ms": 4156, "packages": [ { "files": 601, @@ -743,10 +743,10 @@ } }, "django51": { - "ms": 4876, + "ms": 4787, "report": { "model": true, - "ms": 4872, + "ms": 4783, "packages": [ { "files": 629, @@ -759,10 +759,10 @@ } }, "eslint8": { - "ms": 3197, + "ms": 3476, "report": { "model": true, - "ms": 3193, + "ms": 3471, "packages": [ { "files": 409, @@ -775,10 +775,10 @@ } }, "eslint9": { - "ms": 3391, + "ms": 3330, "report": { "model": true, - "ms": 3387, + "ms": 3325, "packages": [ { "files": 435, @@ -791,10 +791,10 @@ } }, "express4": { - "ms": 2984, + "ms": 3582, "report": { "model": true, - "ms": 2980, + "ms": 3578, "packages": [ { "files": 6, @@ -814,10 +814,10 @@ } }, "express5": { - "ms": 3046, + "ms": 2913, "report": { "model": true, - "ms": 3042, + "ms": 2909, "packages": [ { "files": 38, @@ -837,10 +837,10 @@ } }, "fastapi0115": { - "ms": 2482, + "ms": 2264, "report": { "model": true, - "ms": 2478, + "ms": 2260, "packages": [ { "files": 180, @@ -853,10 +853,10 @@ } }, "fastapi088": { - "ms": 2335, + "ms": 2594, "report": { "model": true, - "ms": 2331, + "ms": 2590, "packages": [ { "files": 113, @@ -869,10 +869,10 @@ } }, "next14": { - "ms": 4976, + "ms": 3699, "report": { "model": true, - "ms": 4972, + "ms": 3694, "packages": [ { "files": 318, @@ -882,14 +882,14 @@ "tag": "v14.2.35" }, { - "files": 3, + "files": 140, "note": null, "package": "react@18.3.1", "repo": "github.com/facebook/react", "tag": "v18.3.1" }, { - "files": 3, + "files": 140, "note": null, "package": "react-dom@18.3.1", "repo": "github.com/facebook/react", @@ -899,10 +899,10 @@ } }, "next15": { - "ms": 3429, + "ms": 4137, "report": { "model": true, - "ms": 3424, + "ms": 4131, "packages": [ { "files": 365, @@ -929,10 +929,10 @@ } }, "next16": { - "ms": 4143, + "ms": 3730, "report": { "model": true, - "ms": 4137, + "ms": 3724, "packages": [ { "files": 458, @@ -959,20 +959,20 @@ } }, "prisma5": { - "ms": 1697, + "ms": 6014, "report": { "model": true, - "ms": 1694, + "ms": 6009, "packages": [ { - "files": 2, + "files": 236, "note": null, "package": "@prisma/client@5.22.0", "repo": "github.com/prisma/prisma", "tag": "5.22.0" }, { - "files": 2, + "files": 236, "note": null, "package": "prisma@5.22.0", "repo": "github.com/prisma/prisma", @@ -982,20 +982,20 @@ } }, "prisma6": { - "ms": 1739, + "ms": 3632, "report": { "model": true, - "ms": 1736, + "ms": 3626, "packages": [ { - "files": 2, + "files": 247, "note": null, "package": "@prisma/client@6.19.3", "repo": "github.com/prisma/prisma", "tag": "6.19.3" }, { - "files": 2, + "files": 247, "note": null, "package": "prisma@6.19.3", "repo": "github.com/prisma/prisma", @@ -1005,10 +1005,10 @@ } }, "pydantic1": { - "ms": 1635, + "ms": 1757, "report": { "model": true, - "ms": 1631, + "ms": 1754, "packages": [ { "files": 176, @@ -1021,10 +1021,10 @@ } }, "pydantic2": { - "ms": 1223, + "ms": 1177, "report": { "model": true, - "ms": 1219, + "ms": 1173, "packages": [ { "files": 80, @@ -1037,14 +1037,14 @@ } }, "react18": { - "ms": 2396, + "ms": 2923, "report": { "model": true, - "ms": 2394, + "ms": 2919, "packages": [ { - "files": 0, - "note": "no git tag found for 18.3.31", + "files": 137, + "note": null, "package": "@types/react@18.3.31", "repo": "github.com/DefinitelyTyped/DefinitelyTyped", "tag": null @@ -1057,14 +1057,14 @@ "tag": null }, { - "files": 3, + "files": 140, "note": null, "package": "react@18.3.1", "repo": "github.com/facebook/react", "tag": "v18.3.1" }, { - "files": 3, + "files": 140, "note": null, "package": "react-dom@18.3.1", "repo": "github.com/facebook/react", @@ -1074,10 +1074,10 @@ } }, "react19": { - "ms": 3314, + "ms": 2912, "report": { "model": true, - "ms": 3310, + "ms": 2908, "packages": [ { "files": 180, @@ -1111,20 +1111,20 @@ } }, "rr6": { - "ms": 2184, + "ms": 2148, "report": { "model": true, - "ms": 2180, + "ms": 2144, "packages": [ { - "files": 3, + "files": 140, "note": null, "package": "react@18.3.1", "repo": "github.com/facebook/react", "tag": "v18.3.1" }, { - "files": 3, + "files": 140, "note": null, "package": "react-dom@18.3.1", "repo": "github.com/facebook/react", @@ -1148,10 +1148,10 @@ } }, "rr7": { - "ms": 2112, + "ms": 2033, "report": { "model": true, - "ms": 2109, + "ms": 2029, "packages": [ { "files": 184, @@ -1178,10 +1178,10 @@ } }, "sqlalchemy14": { - "ms": 3270, + "ms": 2995, "report": { "model": true, - "ms": 3266, + "ms": 2991, "packages": [ { "files": 179, @@ -1194,10 +1194,10 @@ } }, "sqlalchemy20": { - "ms": 3201, + "ms": 3057, "report": { "model": true, - "ms": 3197, + "ms": 3053, "packages": [ { "files": 198, @@ -1210,13 +1210,13 @@ } }, "tailwind3": { - "ms": 725, + "ms": 3464, "report": { "model": true, - "ms": 723, + "ms": 3460, "packages": [ { - "files": 2, + "files": 192, "note": null, "package": "tailwindcss@3.4.19", "repo": "github.com/tailwindlabs/tailwindcss", @@ -1226,13 +1226,13 @@ } }, "tailwind4": { - "ms": 2472, + "ms": 7799, "report": { "model": true, - "ms": 2469, + "ms": 7795, "packages": [ { - "files": 200, + "files": 224, "note": null, "package": "tailwindcss@4.1.18", "repo": "github.com/tailwindlabs/tailwindcss", @@ -1242,10 +1242,10 @@ } }, "vite5": { - "ms": 1379, + "ms": 1136, "report": { "model": true, - "ms": 1376, + "ms": 1133, "packages": [ { "files": 37, @@ -1258,10 +1258,10 @@ } }, "vite6": { - "ms": 1511, + "ms": 1367, "report": { "model": true, - "ms": 1508, + "ms": 1364, "packages": [ { "files": 47, @@ -1274,10 +1274,10 @@ } }, "zod3": { - "ms": 612, + "ms": 630, "report": { "model": true, - "ms": 609, + "ms": 628, "packages": [ { "files": 4, @@ -1290,10 +1290,10 @@ } }, "zod4": { - "ms": 1219, + "ms": 1337, "report": { "model": true, - "ms": 1216, + "ms": 1334, "packages": [ { "files": 18, @@ -1315,34 +1315,36 @@ "question": "How do I reject unknown keys in an object schema?", "why": "v3: .strict() on ZodObject", "line": "older", + "set": "tuning", + "grading": "[[[\".strict(\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 1019, - "ms": 84.3 + "tokens": 1075, + "ms": 86.7 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 1079, - "ms": 29.1 + "tokens": 1091, + "ms": 32.0 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 1070, - "ms": 40.4 + "tokens": 1091, + "ms": 41.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 1019, - "ms": 84.3 + "tokens": 1075, + "ms": 86.7 } }, "context7": { @@ -1364,20 +1366,22 @@ "question": "How do I reject unknown keys in an object schema?", "why": "v4: z.strictObject() (.strict() is legacy)", "line": "newer", + "set": "tuning", + "grading": "[[[\"strictObject\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 946, - "ms": 133.8 + "tokens": 1015, + "ms": 141.6 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 1073, - "ms": 55.8 + "tokens": 1103, + "ms": 61.9 }, "hybrid": { "pass": false, @@ -1387,15 +1391,15 @@ ] ], "rejected": [], - "tokens": 1142, - "ms": 48.6 + "tokens": 1144, + "ms": 57.0 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 946, - "ms": 133.8 + "tokens": 1015, + "ms": 141.6 } }, "context7": { @@ -1417,34 +1421,36 @@ "question": "How do I validate that a string is an email address?", "why": "v3: z.string().email(); top-level z.email() does not exist", "line": "older", + "set": "tuning", + "grading": "[[[\".email(\"]], [\"z.email(\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 1167, - "ms": 40.4 + "tokens": 1119, + "ms": 47.1 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 961, - "ms": 5.5 + "tokens": 1018, + "ms": 6.7 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 1042, - "ms": 44.4 + "tokens": 1019, + "ms": 40.2 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 1167, - "ms": 40.4 + "tokens": 1119, + "ms": 47.1 } }, "context7": { @@ -1466,34 +1472,36 @@ "question": "How do I validate that a string is an email address?", "why": "v4: top-level z.email()", "line": "newer", + "set": "tuning", + "grading": "[[[\"z.email(\", \"function email(\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 965, - "ms": 58.6 + "tokens": 903, + "ms": 60.2 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 1147, - "ms": 10.6 + "tokens": 616, + "ms": 13.2 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 1038, - "ms": 47.6 + "tokens": 389, + "ms": 56.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 965, - "ms": 58.6 + "tokens": 903, + "ms": 60.2 } }, "context7": { @@ -1515,34 +1523,36 @@ "question": "How do I customize the error message when validation fails?", "why": "v3: message / required_error / errorMap params", "line": "older", + "set": "tuning", + "grading": "[[[\"required_error\", \"invalid_type_error\", \"errorMap\", \"{ message:\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 985, - "ms": 45.7 + "tokens": 991, + "ms": 47.3 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 868, - "ms": 5.4 + "tokens": 979, + "ms": 6.4 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 868, - "ms": 45.6 + "tokens": 920, + "ms": 46.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 985, - "ms": 45.7 + "tokens": 991, + "ms": 47.3 } }, "context7": { @@ -1564,34 +1574,36 @@ "question": "How do I customize the error message when validation fails?", "why": "v4: unified `error` param; required_error removed (reject of 'required_error' dropped: v4 migration notes legitimately mention it as removed)", "line": "newer", + "set": "tuning", + "grading": "[[[\"$ZodErrorMap\", \"{ error:\", \"error:\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 989, - "ms": 51.6 + "tokens": 991, + "ms": 59.9 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 389, - "ms": 9.5 + "tokens": 392, + "ms": 12.6 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 646, - "ms": 46.1 + "tokens": 608, + "ms": 55.5 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 989, - "ms": 51.6 + "tokens": 991, + "ms": 59.9 } }, "context7": { @@ -1613,12 +1625,14 @@ "question": "How do I define a record schema with string keys and number values?", "why": "v4: z.record(keySchema, valueSchema)", "line": "newer", + "set": "tuning", + "grading": "[[[\"record(\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 1026, - "ms": 57.2 + "tokens": 946, + "ms": 55.2 }, "variants": { "keyword": { @@ -1629,8 +1643,8 @@ ] ], "rejected": [], - "tokens": 1114, - "ms": 10.5 + "tokens": 1117, + "ms": 13.5 }, "hybrid": { "pass": false, @@ -1640,15 +1654,15 @@ ] ], "rejected": [], - "tokens": 1049, - "ms": 47.9 + "tokens": 1077, + "ms": 52.4 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 1026, - "ms": 57.2 + "tokens": 946, + "ms": 55.2 } }, "context7": { @@ -1670,6 +1684,8 @@ "question": "Is cookies() from next/headers synchronous or does it return a Promise?", "why": "14: cookies() returns ReadonlyRequestCookies synchronously", "line": "older", + "set": "tuning", + "grading": "[[[\"ReadonlyRequestCookies\"]], [\"Promise\", \"await cookies()\"]]", "lockdocs": { "pass": false, "missing": [ @@ -1678,8 +1694,8 @@ ] ], "rejected": [], - "tokens": 849, - "ms": 342.1 + "tokens": 926, + "ms": 394.7 }, "variants": { "keyword": { @@ -1691,14 +1707,14 @@ ], "rejected": [], "tokens": 302, - "ms": 117.9 + "ms": 134.7 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 340, - "ms": 61.4 + "tokens": 507, + "ms": 68.1 }, "fetched": { "pass": false, @@ -1708,8 +1724,8 @@ ] ], "rejected": [], - "tokens": 849, - "ms": 342.1 + "tokens": 926, + "ms": 394.7 } }, "context7": { @@ -1723,7 +1739,8 @@ "tokens": 1144, "ms": 2994, "library": "/vercel/next.js/v14.3.0-canary.87", - "version_match": "same major" + "version_match": "same major", + "reused": true } }, { @@ -1734,34 +1751,36 @@ "question": "Is cookies() from next/headers synchronous or does it return a Promise?", "why": "15: cookies() is async", "line": "newer", + "set": "tuning", + "grading": "[[[\"Promise\", \"await cookies()\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 892, - "ms": 392.2 + "tokens": 866, + "ms": 448.2 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 662, - "ms": 137.0 + "tokens": 737, + "ms": 155.7 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 618, - "ms": 62.6 + "tokens": 693, + "ms": 66.1 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 892, - "ms": 392.2 + "tokens": 866, + "ms": 448.2 } }, "context7": { @@ -1783,34 +1802,36 @@ "question": "How do I read the incoming request headers in a server component?", "why": "14: headers() is sync", "line": "older", + "set": "tuning", + "grading": "[[[\"headers()\", \"function headers(\"]], [\"Promise\", \"await headers()\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 892, - "ms": 79.7 + "tokens": 896, + "ms": 93.6 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 376, - "ms": 17.8 + "tokens": 360, + "ms": 23.8 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 869, - "ms": 55.9 + "tokens": 783, + "ms": 62.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 892, - "ms": 79.7 + "tokens": 896, + "ms": 93.6 } }, "context7": { @@ -1820,7 +1841,8 @@ "tokens": 1363, "ms": 1612, "library": "/vercel/next.js/v14.3.0-canary.87", - "version_match": "same major" + "version_match": "same major", + "reused": true } }, { @@ -1831,34 +1853,36 @@ "question": "How do I read the incoming request headers in a server component?", "why": "15: headers() is async", "line": "newer", + "set": "tuning", + "grading": "[[[\"Promise\", \"await headers()\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 872, - "ms": 84.5 + "tokens": 870, + "ms": 105.9 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 718, - "ms": 19.6 + "tokens": 351, + "ms": 26.5 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 897, - "ms": 64.8 + "tokens": 734, + "ms": 70.1 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 872, - "ms": 84.5 + "tokens": 870, + "ms": 105.9 } }, "context7": { @@ -1880,34 +1904,36 @@ "question": "How do I opt a server component out of caching so it renders dynamically?", "why": "14: unstable_noStore()", "line": "older", + "set": "tuning", + "grading": "[[[\"unstable_noStore\", \"noStore\", \"force-dynamic\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 761, - "ms": 82.1 + "tokens": 843, + "ms": 96.9 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 891, - "ms": 17.5 + "tokens": 908, + "ms": 23.6 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 933, - "ms": 56.0 + "tokens": 893, + "ms": 61.0 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 761, - "ms": 82.1 + "tokens": 843, + "ms": 96.9 } }, "context7": { @@ -1917,7 +1943,8 @@ "tokens": 744, "ms": 1564, "library": "/vercel/next.js/v14.3.0-canary.87", - "version_match": "same major" + "version_match": "same major", + "reused": true } }, { @@ -1928,34 +1955,36 @@ "question": "How do I opt a server component out of caching so it renders dynamically?", "why": "15: connection() (unstable_noStore deprecated)", "line": "newer", + "set": "tuning", + "grading": "[[[\"connection()\", \"function connection(\", \"unstable_noStore\", \"force-dynamic\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 798, - "ms": 93.1 + "tokens": 754, + "ms": 108.3 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 899, - "ms": 19.7 + "tokens": 934, + "ms": 26.8 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 924, - "ms": 61.4 + "tokens": 964, + "ms": 70.7 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 798, - "ms": 93.1 + "tokens": 754, + "ms": 108.3 } }, "context7": { @@ -1977,18 +2006,14 @@ "question": "How do I run code after the response has finished streaming?", "why": "15.1: after() from next/server (unstable_after in 15.0)", "line": "newer", + "set": "tuning", + "grading": "[[[\"unstable_after\", \"after(\", \"function after\"]], []]", "lockdocs": { - "pass": false, - "missing": [ - [ - "unstable_after", - "after(", - "function after" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 879, - "ms": 91.9 + "tokens": 827, + "ms": 99.7 }, "variants": { "keyword": { @@ -1996,27 +2021,21 @@ "missing": [], "rejected": [], "tokens": 923, - "ms": 19.6 + "ms": 25.9 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 855, - "ms": 63.3 + "tokens": 932, + "ms": 73.6 }, "fetched": { - "pass": false, - "missing": [ - [ - "unstable_after", - "after(", - "function after" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 879, - "ms": 91.9 + "tokens": 827, + "ms": 99.7 } }, "context7": { @@ -2038,6 +2057,8 @@ "question": "How do I return data with a custom HTTP status code from a loader?", "why": "v6: json() helper", "line": "older", + "set": "tuning", + "grading": "[[[\"json(\"]], []]", "lockdocs": { "pass": false, "missing": [ @@ -2047,7 +2068,7 @@ ], "rejected": [], "tokens": 796, - "ms": 98.9 + "ms": 120.4 }, "variants": { "keyword": { @@ -2055,14 +2076,14 @@ "missing": [], "rejected": [], "tokens": 913, - "ms": 8.9 + "ms": 11.0 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 913, - "ms": 42.0 + "tokens": 891, + "ms": 37.7 }, "fetched": { "pass": false, @@ -2073,7 +2094,7 @@ ], "rejected": [], "tokens": 796, - "ms": 98.9 + "ms": 120.4 } }, "context7": { @@ -2099,15 +2120,24 @@ "question": "How do I return data with a custom HTTP status code from a loader?", "why": "v7: data() helper (json() deprecated)", "line": "newer", + "set": "tuning", + "grading": "[[[\"data(\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 892, - "ms": 121.9 + "tokens": 876, + "ms": 143.5 }, "variants": { "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 909, + "ms": 47.8 + }, + "hybrid": { "pass": false, "missing": [ [ @@ -2115,22 +2145,15 @@ ] ], "rejected": [], - "tokens": 889, - "ms": 40.2 - }, - "hybrid": { - "pass": true, - "missing": [], - "rejected": [], - "tokens": 928, - "ms": 41.8 + "tokens": 970, + "ms": 43.6 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 892, - "ms": 121.9 + "tokens": 876, + "ms": 143.5 } }, "context7": { @@ -2152,34 +2175,36 @@ "question": "How do I stream slow data from a loader with deferred values?", "why": "v6: defer() + ", "line": "older", + "set": "tuning", + "grading": "[[[\"defer(\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 895, - "ms": 46.9 + "tokens": 872, + "ms": 50.1 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 851, - "ms": 3.3 + "tokens": 821, + "ms": 4.0 }, "hybrid": { "pass": true, "missing": [], "rejected": [], "tokens": 884, - "ms": 38.2 + "ms": 41.8 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 895, - "ms": 46.9 + "tokens": 872, + "ms": 50.1 } }, "context7": { @@ -2201,12 +2226,14 @@ "question": "Which future flag enables React.startTransition for state updates?", "why": "v6.26: future.v7_startTransition", "line": "older", + "set": "tuning", + "grading": "[[[\"v7_startTransition\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 815, - "ms": 51.6 + "tokens": 755, + "ms": 55.9 }, "variants": { "keyword": { @@ -2214,21 +2241,21 @@ "missing": [], "rejected": [], "tokens": 946, - "ms": 3.2 + "ms": 4.0 }, "hybrid": { "pass": true, "missing": [], "rejected": [], "tokens": 904, - "ms": 39.3 + "ms": 41.7 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 815, - "ms": 51.6 + "tokens": 755, + "ms": 55.9 } }, "context7": { @@ -2250,34 +2277,36 @@ "question": "How do I create a browser router with data loaders?", "why": "v7: createBrowserRouter from react-router", "line": "newer", + "set": "tuning", + "grading": "[[[\"createBrowserRouter\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 838, - "ms": 51.1 + "tokens": 807, + "ms": 58.5 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 866, - "ms": 7.5 + "tokens": 918, + "ms": 9.7 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 920, - "ms": 46.0 + "tokens": 931, + "ms": 43.6 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 838, - "ms": 51.1 + "tokens": 807, + "ms": 58.5 } }, "context7": { @@ -2299,34 +2328,36 @@ "question": "How do I convert a model instance to a dict?", "why": "v1: .dict()", "line": "older", + "set": "tuning", + "grading": "[[[\".dict(\", \"def dict(\"]], [\"model_dump\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 948, - "ms": 151.1 + "tokens": 846, + "ms": 157.0 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 953, - "ms": 68.2 + "tokens": 915, + "ms": 67.1 }, "hybrid": { "pass": true, "missing": [], "rejected": [], "tokens": 915, - "ms": 55.9 + "ms": 59.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 948, - "ms": 151.1 + "tokens": 846, + "ms": 157.0 } }, "context7": { @@ -2355,12 +2386,14 @@ "question": "How do I convert a model instance to a dict?", "why": "v2: .model_dump()", "line": "newer", + "set": "tuning", + "grading": "[[[\"model_dump\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 909, - "ms": 244.8 + "tokens": 952, + "ms": 278.7 }, "variants": { "keyword": { @@ -2371,26 +2404,22 @@ ] ], "rejected": [], - "tokens": 823, - "ms": 129.7 + "tokens": 924, + "ms": 141.6 }, "hybrid": { - "pass": false, - "missing": [ - [ - "model_dump" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 893, - "ms": 64.1 + "tokens": 843, + "ms": 64.2 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 909, - "ms": 244.8 + "tokens": 952, + "ms": 278.7 } }, "context7": { @@ -2412,12 +2441,14 @@ "question": "How do I create a model from a dict and validate it?", "why": "v1: Model.parse_obj()", "line": "older", + "set": "tuning", + "grading": "[[[\"parse_obj\"]], [\"model_validate\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 884, - "ms": 63.0 + "tokens": 845, + "ms": 63.7 }, "variants": { "keyword": { @@ -2428,8 +2459,8 @@ ] ], "rejected": [], - "tokens": 954, - "ms": 18.6 + "tokens": 900, + "ms": 21.1 }, "hybrid": { "pass": false, @@ -2439,15 +2470,15 @@ ] ], "rejected": [], - "tokens": 907, - "ms": 58.0 + "tokens": 922, + "ms": 59.2 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 884, - "ms": 63.0 + "tokens": 845, + "ms": 63.7 } }, "context7": { @@ -2475,12 +2506,14 @@ "question": "How do I create a model from a dict and validate it?", "why": "v2: Model.model_validate()", "line": "newer", + "set": "tuning", + "grading": "[[[\"model_validate\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 843, - "ms": 72.3 + "tokens": 825, + "ms": 76.0 }, "variants": { "keyword": { @@ -2492,25 +2525,21 @@ ], "rejected": [], "tokens": 941, - "ms": 22.2 + "ms": 25.6 }, "hybrid": { - "pass": false, - "missing": [ - [ - "model_validate" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 950, - "ms": 60.0 + "tokens": 858, + "ms": 59.6 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 843, - "ms": 72.3 + "tokens": 825, + "ms": 76.0 } }, "context7": { @@ -2532,39 +2561,36 @@ "question": "How do I add a custom validator for a field?", "why": "v1: @validator", "line": "older", + "set": "tuning", + "grading": "[[[\"@validator\", \"def validator(\"]], [\"field_validator\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 965, - "ms": 62.5 + "tokens": 961, + "ms": 62.4 }, "variants": { "keyword": { - "pass": false, - "missing": [ - [ - "@validator", - "def validator(" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 985, - "ms": 18.6 + "tokens": 908, + "ms": 20.4 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 823, - "ms": 54.0 + "tokens": 867, + "ms": 58.3 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 965, - "ms": 62.5 + "tokens": 961, + "ms": 62.4 } }, "context7": { @@ -2593,12 +2619,14 @@ "question": "How do I add a custom validator for a field?", "why": "v2: @field_validator", "line": "newer", + "set": "tuning", + "grading": "[[[\"field_validator\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 764, - "ms": 67.3 + "tokens": 775, + "ms": 77.4 }, "variants": { "keyword": { @@ -2609,22 +2637,22 @@ ] ], "rejected": [], - "tokens": 784, - "ms": 22.5 + "tokens": 826, + "ms": 25.4 }, "hybrid": { "pass": true, "missing": [], "rejected": [], "tokens": 867, - "ms": 63.3 + "ms": 60.2 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 764, - "ms": 67.3 + "tokens": 775, + "ms": 77.4 } }, "context7": { @@ -2646,34 +2674,36 @@ "question": "How do I get the JSON schema of a model?", "why": "v1: Model.schema() / schema_json()", "line": "older", + "set": "tuning", + "grading": "[[[\"schema_json\", \"def schema(\", \".schema(\"]], [\"model_json_schema\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 878, - "ms": 61.6 + "tokens": 818, + "ms": 68.0 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 923, - "ms": 18.5 + "tokens": 854, + "ms": 19.8 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 913, - "ms": 55.9 + "tokens": 915, + "ms": 53.8 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 878, - "ms": 61.6 + "tokens": 818, + "ms": 68.0 } }, "context7": { @@ -2703,34 +2733,36 @@ "question": "How do I get the JSON schema of a model?", "why": "v2: Model.model_json_schema()", "line": "newer", + "set": "tuning", + "grading": "[[[\"model_json_schema\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 817, - "ms": 72.3 + "tokens": 880, + "ms": 78.9 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 884, - "ms": 22.3 + "tokens": 839, + "ms": 26.0 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 858, - "ms": 56.3 + "tokens": 884, + "ms": 65.0 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 817, - "ms": 72.3 + "tokens": 880, + "ms": 78.9 } }, "context7": { @@ -2750,16 +2782,21 @@ "package": "pydantic", "version": "1.10.18", "question": "How do I allow extra fields on a model?", - "why": "v1: class Config: extra = Extra.allow", + "why": "v1: class Config: extra = Extra.allow. Rejects the v2 idiom model_config = ConfigDict (0 matches in pydantic 1.10.18 source and docs). Until 0.3 it rejected ConfigDict, which pydantic 1.10 also ships (config.py)", "line": "older", + "set": "tuning", + "grading": "[[[\"Extra.allow\", \"class Config\"]], [\"model_config = ConfigDict\"]]", "lockdocs": { "pass": false, - "missing": [], - "rejected": [ - "ConfigDict" + "missing": [ + [ + "Extra.allow", + "class Config" + ] ], - "tokens": 945, - "ms": 59.0 + "rejected": [], + "tokens": 923, + "ms": 64.1 }, "variants": { "keyword": { @@ -2771,8 +2808,8 @@ ] ], "rejected": [], - "tokens": 952, - "ms": 18.3 + "tokens": 878, + "ms": 20.5 }, "hybrid": { "pass": false, @@ -2783,27 +2820,30 @@ ] ], "rejected": [], - "tokens": 952, - "ms": 55.8 + "tokens": 945, + "ms": 55.1 }, "fetched": { "pass": false, - "missing": [], - "rejected": [ - "ConfigDict" + "missing": [ + [ + "Extra.allow", + "class Config" + ] ], - "tokens": 945, - "ms": 59.0 + "rejected": [], + "tokens": 923, + "ms": 64.1 } }, "context7": { "pass": false, "missing": [], "rejected": [ - "ConfigDict" + "model_config = ConfigDict" ], - "tokens": 718, - "ms": 1581, + "tokens": 683, + "ms": 2725, "library": "/pydantic/pydantic", "version_match": "unversioned", "reused": true @@ -2817,12 +2857,14 @@ "question": "How do I allow extra fields on a model?", "why": "v2: model_config = ConfigDict(extra='allow')", "line": "newer", + "set": "tuning", + "grading": "[[[\"ConfigDict\", \"model_config\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 934, - "ms": 70.6 + "tokens": 952, + "ms": 80.1 }, "variants": { "keyword": { @@ -2830,21 +2872,21 @@ "missing": [], "rejected": [], "tokens": 898, - "ms": 22.2 + "ms": 25.5 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 919, - "ms": 62.4 + "tokens": 892, + "ms": 64.2 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 934, - "ms": 70.6 + "tokens": 952, + "ms": 80.1 } }, "context7": { @@ -2866,34 +2908,36 @@ "question": "How do I declare a route with a path parameter?", "why": "0.7: /users/:id", "line": "older", + "set": "tuning", + "grading": "[[[\"/:\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 960, - "ms": 88.4 + "tokens": 1020, + "ms": 102.6 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 955, - "ms": 51.4 + "tokens": 1003, + "ms": 58.0 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 933, - "ms": 45.1 + "tokens": 1005, + "ms": 47.1 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 960, - "ms": 88.4 + "tokens": 1020, + "ms": 102.6 } }, "context7": { @@ -2919,34 +2963,36 @@ "question": "How do I declare a route with a path parameter?", "why": "0.8: /users/{id}", "line": "newer", + "set": "tuning", + "grading": "[[[\"/{\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 925, - "ms": 93.5 + "tokens": 962, + "ms": 96.9 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 947, - "ms": 52.0 + "tokens": 960, + "ms": 60.2 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 925, - "ms": 45.3 + "tokens": 962, + "ms": 48.8 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 925, - "ms": 93.5 + "tokens": 962, + "ms": 96.9 } }, "context7": { @@ -2968,34 +3014,36 @@ "question": "How do I implement a custom extractor with FromRequestParts?", "why": "0.7: #[async_trait] on the impl", "line": "older", + "set": "tuning", + "grading": "[[[\"async_trait\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 926, - "ms": 46.5 + "tokens": 912, + "ms": 45.8 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 908, - "ms": 7.1 + "tokens": 907, + "ms": 8.7 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 908, - "ms": 40.6 + "tokens": 911, + "ms": 41.0 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 926, - "ms": 46.5 + "tokens": 912, + "ms": 45.8 } }, "context7": { @@ -3021,34 +3069,36 @@ "question": "How do I make an extractor optional with Option?", "why": "0.8: OptionalFromRequestParts", "line": "newer", + "set": "tuning", + "grading": "[[[\"OptionalFromRequestParts\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 898, - "ms": 41.6 + "tokens": 891, + "ms": 41.4 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 663, - "ms": 6.9 + "tokens": 683, + "ms": 8.8 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 898, - "ms": 41.6 - }, + "tokens": 891, + "ms": 43.6 + }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 898, - "ms": 41.6 + "tokens": 891, + "ms": 41.4 } }, "context7": { @@ -3070,12 +3120,14 @@ "question": "How do I run blocking code without blocking the async runtime?", "why": "tokio::task::spawn_blocking", "line": "single", + "set": "tuning", + "grading": "[[[\"spawn_blocking\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 820, - "ms": 316.1 + "tokens": 823, + "ms": 362.6 }, "variants": { "keyword": { @@ -3083,21 +3135,21 @@ "missing": [], "rejected": [], "tokens": 796, - "ms": 249.7 + "ms": 276.2 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 793, - "ms": 57.4 + "tokens": 796, + "ms": 67.8 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 820, - "ms": 316.1 + "tokens": 823, + "ms": 362.6 } }, "context7": { @@ -3119,16 +3171,14 @@ "question": "How do I wait on several futures and take whichever finishes first?", "why": "tokio::select!", "line": "single", + "set": "tuning", + "grading": "[[[\"select!\"]], []]", "lockdocs": { - "pass": false, - "missing": [ - [ - "select!" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 969, - "ms": 60.1 + "tokens": 962, + "ms": 68.8 }, "variants": { "keyword": { @@ -3140,7 +3190,7 @@ ], "rejected": [], "tokens": 956, - "ms": 20.8 + "ms": 28.2 }, "hybrid": { "pass": false, @@ -3150,19 +3200,15 @@ ] ], "rejected": [], - "tokens": 958, - "ms": 55.0 + "tokens": 970, + "ms": 60.6 }, "fetched": { - "pass": false, - "missing": [ - [ - "select!" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 969, - "ms": 60.1 + "tokens": 962, + "ms": 68.8 } }, "context7": { @@ -3184,34 +3230,36 @@ "question": "How do I put a timeout on a future?", "why": "tokio::time::timeout", "line": "single", + "set": "tuning", + "grading": "[[[\"timeout(\", \"fn timeout\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 920, - "ms": 54.7 + "tokens": 941, + "ms": 62.7 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 881, - "ms": 20.1 + "tokens": 849, + "ms": 27.7 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 910, - "ms": 56.4 + "tokens": 903, + "ms": 61.6 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 920, - "ms": 54.7 + "tokens": 941, + "ms": 62.7 } }, "context7": { @@ -3233,18 +3281,14 @@ "question": "How do I add Tailwind to my main CSS file?", "why": "v3: @tailwind base/components/utilities directives (tailwindcss 3.4 docs, installation)", "line": "older", + "set": "tuning", + "grading": "[[[\"@tailwind base\", \"@tailwind components\", \"@tailwind utilities\"]], [\"@import \\\"tailwindcss\\\"\"]]", "lockdocs": { - "pass": false, - "missing": [ - [ - "@tailwind base", - "@tailwind components", - "@tailwind utilities" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 959, - "ms": 62.4 + "tokens": 971, + "ms": 169.9 }, "variants": { "keyword": { @@ -3257,8 +3301,8 @@ ] ], "rejected": [], - "tokens": 1116, - "ms": 10.4 + "tokens": 1134, + "ms": 11.5 }, "hybrid": { "pass": false, @@ -3270,21 +3314,15 @@ ] ], "rejected": [], - "tokens": 1080, - "ms": 40.6 + "tokens": 1025, + "ms": 36.6 }, "fetched": { - "pass": false, - "missing": [ - [ - "@tailwind base", - "@tailwind components", - "@tailwind utilities" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 959, - "ms": 62.4 + "tokens": 971, + "ms": 169.9 } }, "context7": { @@ -3314,17 +3352,14 @@ "question": "How do I add Tailwind to my main CSS file?", "why": "v4: @import \"tailwindcss\" replaces @tailwind directives (v4 upgrade guide)", "line": "newer", + "set": "tuning", + "grading": "[[[\"@import \\\"tailwindcss\\\"\", \"@import 'tailwindcss'\"]], []]", "lockdocs": { - "pass": false, - "missing": [ - [ - "@import \"tailwindcss\"", - "@import 'tailwindcss'" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 849, - "ms": 145.9 + "tokens": 979, + "ms": 192.6 }, "variants": { "keyword": { @@ -3336,8 +3371,8 @@ ] ], "rejected": [], - "tokens": 281, - "ms": 8.9 + "tokens": 358, + "ms": 9.4 }, "hybrid": { "pass": false, @@ -3348,20 +3383,15 @@ ] ], "rejected": [], - "tokens": 445, - "ms": 39.2 + "tokens": 461, + "ms": 39.3 }, "fetched": { - "pass": false, - "missing": [ - [ - "@import \"tailwindcss\"", - "@import 'tailwindcss'" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 849, - "ms": 145.9 + "tokens": 979, + "ms": 192.6 } }, "context7": { @@ -3383,34 +3413,36 @@ "question": "How do I add a custom brand color to the theme?", "why": "v3: theme.extend.colors in tailwind.config.js (package stubs/config.full.js; v3 docs customizing colors)", "line": "older", + "set": "tuning", + "grading": "[[[\"extend:\", \"theme.extend\", \"tailwind.config\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 1035, - "ms": 40.9 + "tokens": 1003, + "ms": 63.2 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 988, - "ms": 3.1 + "tokens": 1003, + "ms": 3.5 }, "hybrid": { "pass": true, "missing": [], "rejected": [], "tokens": 1047, - "ms": 41.3 + "ms": 40.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 1035, - "ms": 40.9 + "tokens": 1003, + "ms": 63.2 } }, "context7": { @@ -3432,12 +3464,14 @@ "question": "How do I add a custom brand color to the theme?", "why": "v4: CSS-first @theme { --color-brand: ... } (v4 docs, theme variables)", "line": "newer", + "set": "tuning", + "grading": "[[[\"@theme\", \"--color-\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 1147, - "ms": 54.0 + "tokens": 1144, + "ms": 63.9 }, "variants": { "keyword": { @@ -3449,8 +3483,8 @@ ] ], "rejected": [], - "tokens": 437, - "ms": 2.0 + "tokens": 286, + "ms": 2.1 }, "hybrid": { "pass": false, @@ -3461,15 +3495,15 @@ ] ], "rejected": [], - "tokens": 531, - "ms": 36.8 + "tokens": 764, + "ms": 39.3 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 1147, - "ms": 54.0 + "tokens": 1144, + "ms": 63.9 } }, "context7": { @@ -3491,34 +3525,36 @@ "question": "How do I configure ESLint rules for my project?", "why": "8.57: eslintrc (.eslintrc.*) is the default config system; flat config opt-in (eslint 8 docs, configuring)", "line": "older", + "set": "tuning", + "grading": "[[[\".eslintrc\", \"eslintrc\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 813, - "ms": 407.0 + "tokens": 796, + "ms": 473.9 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 866, - "ms": 133.9 + "tokens": 912, + "ms": 150.4 }, "hybrid": { "pass": true, "missing": [], "rejected": [], "tokens": 925, - "ms": 48.2 + "ms": 44.5 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 813, - "ms": 407.0 + "tokens": 796, + "ms": 473.9 } }, "context7": { @@ -3540,34 +3576,36 @@ "question": "How do I configure ESLint rules for my project?", "why": "9.x: flat config eslint.config.js is the default (eslint 9 migration guide)", "line": "newer", + "set": "tuning", + "grading": "[[[\"eslint.config.js\", \"eslint.config.mjs\", \"eslint.config\", \"flat config\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 741, - "ms": 360.1 + "tokens": 720, + "ms": 416.7 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 940, - "ms": 30.2 + "tokens": 882, + "ms": 34.7 }, "hybrid": { "pass": true, "missing": [], "rejected": [], "tokens": 961, - "ms": 45.1 + "ms": 40.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 741, - "ms": 360.1 + "tokens": 720, + "ms": 416.7 } }, "context7": { @@ -3589,12 +3627,14 @@ "question": "How do I make ESLint ignore certain files?", "why": "8.57: .eslintignore / ignorePatterns (lib/cli-engine/cli-engine.js mentions .eslintignore)", "line": "older", + "set": "tuning", + "grading": "[[[\".eslintignore\", \"ignorePatterns\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], "tokens": 854, - "ms": 83.5 + "ms": 88.1 }, "variants": { "keyword": { @@ -3606,8 +3646,8 @@ ] ], "rejected": [], - "tokens": 901, - "ms": 7.5 + "tokens": 871, + "ms": 10.4 }, "hybrid": { "pass": false, @@ -3618,15 +3658,15 @@ ] ], "rejected": [], - "tokens": 836, - "ms": 43.6 + "tokens": 898, + "ms": 50.7 }, "fetched": { "pass": true, "missing": [], "rejected": [], "tokens": 854, - "ms": 83.5 + "ms": 88.1 } }, "context7": { @@ -3648,12 +3688,14 @@ "question": "How do I make ESLint ignore certain files?", "why": "9.x: `ignores` in flat config / globalIgnores() from eslint/config (lib/types/config-api.d.ts); .eslintignore only warns", "line": "newer", + "set": "tuning", + "grading": "[[[\"ignores:\", \"\\\"ignores\\\"\", \"globalIgnores(\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 825, - "ms": 82.8 + "tokens": 849, + "ms": 98.9 }, "variants": { "keyword": { @@ -3666,8 +3708,8 @@ ] ], "rejected": [], - "tokens": 909, - "ms": 6.4 + "tokens": 919, + "ms": 7.9 }, "hybrid": { "pass": false, @@ -3679,15 +3721,15 @@ ] ], "rejected": [], - "tokens": 996, - "ms": 45.8 + "tokens": 898, + "ms": 52.8 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 825, - "ms": 82.8 + "tokens": 849, + "ms": 98.9 } }, "context7": { @@ -3709,6 +3751,8 @@ "question": "Which JavaScript type does Prisma Client use for Bytes fields?", "why": "5.x: Bytes map to Buffer (Prisma 6 upgrade guide: 'Buffer replaced by Uint8Array')", "line": "older", + "set": "tuning", + "grading": "[[[\"Buffer\"]], [\"Uint8Array\"]]", "lockdocs": { "pass": false, "missing": [ @@ -3717,8 +3761,8 @@ ] ], "rejected": [], - "tokens": 878, - "ms": 78.1 + "tokens": 846, + "ms": 384.9 }, "variants": { "keyword": { @@ -3729,8 +3773,8 @@ ] ], "rejected": [], - "tokens": 894, - "ms": 32.9 + "tokens": 945, + "ms": 34.7 }, "hybrid": { "pass": false, @@ -3740,8 +3784,8 @@ ] ], "rejected": [], - "tokens": 971, - "ms": 42.7 + "tokens": 963, + "ms": 45.5 }, "fetched": { "pass": false, @@ -3751,8 +3795,8 @@ ] ], "rejected": [], - "tokens": 878, - "ms": 78.1 + "tokens": 846, + "ms": 384.9 } }, "context7": { @@ -3780,42 +3824,36 @@ "question": "Which JavaScript type does Prisma Client use for Bytes fields?", "why": "6.x: Bytes = Uint8Array (@prisma/client 6.19.3 runtime/library.d.ts:167)", "line": "newer", + "set": "tuning", + "grading": "[[[\"Uint8Array\"]], []]", "lockdocs": { - "pass": false, - "missing": [ - [ - "Uint8Array" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 878, - "ms": 95.0 + "tokens": 848, + "ms": 437.5 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 967, - "ms": 49.6 + "tokens": 925, + "ms": 52.8 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 853, - "ms": 45.0 + "tokens": 910, + "ms": 41.2 }, "fetched": { - "pass": false, - "missing": [ - [ - "Uint8Array" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 878, - "ms": 95.0 + "tokens": 848, + "ms": 437.5 } }, "context7": { @@ -3837,16 +3875,14 @@ "question": "Which preview feature enables full-text search on PostgreSQL?", "why": "5.x: previewFeatures = [\"fullTextSearch\"] (Prisma 5 docs, full-text search)", "line": "older", + "set": "tuning", + "grading": "[[[\"fullTextSearch\"]], [\"fullTextSearchPostgres\"]]", "lockdocs": { - "pass": false, - "missing": [ - [ - "fullTextSearch" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 915, - "ms": 70.5 + "tokens": 849, + "ms": 384.6 }, "variants": { "keyword": { @@ -3857,8 +3893,8 @@ ] ], "rejected": [], - "tokens": 829, - "ms": 31.9 + "tokens": 583, + "ms": 35.3 }, "hybrid": { "pass": false, @@ -3868,19 +3904,15 @@ ] ], "rejected": [], - "tokens": 949, - "ms": 43.7 + "tokens": 948, + "ms": 40.3 }, "fetched": { - "pass": false, - "missing": [ - [ - "fullTextSearch" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 915, - "ms": 70.5 + "tokens": 849, + "ms": 384.6 } }, "context7": { @@ -3904,16 +3936,14 @@ "question": "Which preview feature enables full-text search on PostgreSQL?", "why": "6.0: renamed to fullTextSearchPostgres (Prisma 6 upgrade guide)", "line": "newer", + "set": "tuning", + "grading": "[[[\"fullTextSearchPostgres\"]], []]", "lockdocs": { - "pass": false, - "missing": [ - [ - "fullTextSearchPostgres" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 850, - "ms": 89.6 + "tokens": 846, + "ms": 471.2 }, "variants": { "keyword": { @@ -3924,8 +3954,8 @@ ] ], "rejected": [], - "tokens": 836, - "ms": 49.5 + "tokens": 583, + "ms": 52.8 }, "hybrid": { "pass": false, @@ -3935,19 +3965,15 @@ ] ], "rejected": [], - "tokens": 921, - "ms": 43.5 + "tokens": 942, + "ms": 44.5 }, "fetched": { - "pass": false, - "missing": [ - [ - "fullTextSearchPostgres" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 850, - "ms": 89.6 + "tokens": 846, + "ms": 471.2 } }, "context7": { @@ -3969,38 +3995,52 @@ "question": "Which hook updates state based on the result of a form action?", "why": "18.3: no action hook; useActionState absent from @types/react 18.3.31 (0 matches), use useState/useReducer", "line": "older", + "set": "tuning", + "grading": "[[[\"useReducer\", \"useState\"]], [\"useActionState\"]]", "lockdocs": { "pass": false, - "missing": [], + "missing": [ + [ + "useReducer", + "useState" + ] + ], "rejected": [ "useActionState" ], - "tokens": 951, - "ms": 128.8 + "tokens": 832, + "ms": 409.3 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 956, - "ms": 58.5 + "tokens": 863, + "ms": 66.2 }, "hybrid": { - "pass": true, + "pass": false, "missing": [], - "rejected": [], - "tokens": 921, - "ms": 45.0 + "rejected": [ + "useActionState" + ], + "tokens": 959, + "ms": 49.0 }, "fetched": { "pass": false, - "missing": [], + "missing": [ + [ + "useReducer", + "useState" + ] + ], "rejected": [ "useActionState" ], - "tokens": 951, - "ms": 128.8 + "tokens": 832, + "ms": 409.3 } }, "context7": { @@ -4029,12 +4069,14 @@ "question": "Which hook updates state based on the result of a form action?", "why": "19: useActionState (@types/react 19.2.18 index.d.ts)", "line": "newer", + "set": "tuning", + "grading": "[[[\"useActionState\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 869, - "ms": 416.7 + "tokens": 872, + "ms": 464.2 }, "variants": { "keyword": { @@ -4045,22 +4087,22 @@ ] ], "rejected": [], - "tokens": 891, - "ms": 53.6 + "tokens": 981, + "ms": 63.3 }, "hybrid": { "pass": true, "missing": [], "rejected": [], "tokens": 961, - "ms": 44.1 + "ms": 43.5 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 869, - "ms": 416.7 + "tokens": 872, + "ms": 464.2 } }, "context7": { @@ -4082,12 +4124,14 @@ "question": "How do I read the value of a promise during render, suspending until it resolves?", "why": "18.3: no `use` API (absent from @types/react 18.3.31); Suspense-enabled data library or useEffect", "line": "older", + "set": "tuning", + "grading": "[[[\"Suspense\", \"useEffect\"]], [\"function use<\", \"use(promise\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 804, - "ms": 47.8 + "tokens": 823, + "ms": 98.7 }, "variants": { "keyword": { @@ -4099,8 +4143,8 @@ ] ], "rejected": [], - "tokens": 687, - "ms": 6.9 + "tokens": 403, + "ms": 9.0 }, "hybrid": { "pass": false, @@ -4111,15 +4155,15 @@ ] ], "rejected": [], - "tokens": 891, - "ms": 42.3 + "tokens": 925, + "ms": 44.0 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 804, - "ms": 47.8 + "tokens": 823, + "ms": 98.7 } }, "context7": { @@ -4143,12 +4187,14 @@ "question": "How do I read the value of a promise during render, suspending until it resolves?", "why": "19: use(promise) (@types/react 19.2.18 index.d.ts:1973 `function use(usable: Usable): T`)", "line": "newer", + "set": "tuning", + "grading": "[[[\"use(\", \"function use<\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 859, - "ms": 93.9 + "tokens": 854, + "ms": 104.4 }, "variants": { "keyword": { @@ -4160,8 +4206,8 @@ ] ], "rejected": [], - "tokens": 664, - "ms": 6.7 + "tokens": 923, + "ms": 8.7 }, "hybrid": { "pass": false, @@ -4172,15 +4218,15 @@ ] ], "rejected": [], - "tokens": 896, - "ms": 48.9 + "tokens": 941, + "ms": 45.6 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 859, - "ms": 93.9 + "tokens": 854, + "ms": 104.4 } }, "context7": { @@ -4202,34 +4248,36 @@ "question": "How do I configure options separately for the client and SSR environments in the Vite config?", "why": "5.4: `ssr` options / build.ssr; no `environments` in UserConfig (dist/node/index.d.ts, 0 matches)", "line": "older", + "set": "tuning", + "grading": "[[[\"ssr:\", \"ssr?:\", \"SSROptions\", \"build.ssr\"]], [\"environments:\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 907, - "ms": 164.4 + "tokens": 897, + "ms": 185.9 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 915, - "ms": 43.8 + "tokens": 880, + "ms": 50.2 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 963, - "ms": 45.6 + "tokens": 966, + "ms": 45.3 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 907, - "ms": 164.4 + "tokens": 897, + "ms": 185.9 } }, "context7": { @@ -4253,34 +4301,36 @@ "question": "How do I configure options separately for the client and SSR environments in the Vite config?", "why": "6.x: Environment API, `environments?: Record` (vite 6.4.3 dist/node/index.d.ts:3901)", "line": "newer", + "set": "tuning", + "grading": "[[[\"environments\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 803, - "ms": 142.2 + "tokens": 839, + "ms": 164.9 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 902, - "ms": 48.2 + "tokens": 797, + "ms": 55.4 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 874, - "ms": 44.7 + "tokens": 899, + "ms": 51.8 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 803, - "ms": 142.2 + "tokens": 839, + "ms": 164.9 } }, "context7": { @@ -4302,12 +4352,14 @@ "question": "How do I define a route that matches every remaining path segment with a wildcard?", "why": "4.x: app.get('*') / '/files/*' (path-to-regexp 0.1; express 4 routing guide)", "line": "older", + "set": "tuning", + "grading": "[[[\"'*'\", \"\\\"*\\\"\", \"/*'\", \"/*\\\"\"]], [\"*splat\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 730, - "ms": 85.8 + "tokens": 813, + "ms": 95.9 }, "variants": { "keyword": { @@ -4321,8 +4373,8 @@ ] ], "rejected": [], - "tokens": 1113, - "ms": 18.1 + "tokens": 900, + "ms": 22.9 }, "hybrid": { "pass": false, @@ -4335,15 +4387,15 @@ ] ], "rejected": [], - "tokens": 1019, - "ms": 42.8 + "tokens": 163, + "ms": 43.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 730, - "ms": 85.8 + "tokens": 813, + "ms": 95.9 } }, "context7": { @@ -4367,12 +4419,14 @@ "question": "How do I define a route that matches every remaining path segment with a wildcard?", "why": "5.x: wildcards must be named, '/*splat' (express 5 migration guide, path-to-regexp v8)", "line": "newer", + "set": "tuning", + "grading": "[[[\"*splat\", \"/*name\", \"{*\", \"named wildcard\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 944, - "ms": 87.2 + "tokens": 932, + "ms": 97.0 }, "variants": { "keyword": { @@ -4386,8 +4440,8 @@ ] ], "rejected": [], - "tokens": 603, - "ms": 8.5 + "tokens": 473, + "ms": 8.8 }, "hybrid": { "pass": false, @@ -4401,14 +4455,14 @@ ], "rejected": [], "tokens": 968, - "ms": 37.8 + "ms": 42.0 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 944, - "ms": 87.2 + "tokens": 932, + "ms": 97.0 } }, "context7": { @@ -4430,34 +4484,36 @@ "question": "How do I declare a model column with Python type annotations?", "why": "1.4: Column(...) on declarative classes; mapped_column absent in 1.4.54 orm/ (verified)", "line": "older", + "set": "tuning", + "grading": "[[[\"Column(\"]], [\"mapped_column\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 869, - "ms": 854.9 + "tokens": 852, + "ms": 977.9 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 857, - "ms": 397.4 + "tokens": 847, + "ms": 431.6 }, "hybrid": { "pass": true, "missing": [], "rejected": [], "tokens": 860, - "ms": 89.3 + "ms": 92.0 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 869, - "ms": 854.9 + "tokens": 852, + "ms": 977.9 } }, "context7": { @@ -4481,12 +4537,14 @@ "question": "How do I declare a model column with Python type annotations?", "why": "2.0: Mapped[...] = mapped_column() (sqlalchemy 2.0.54 orm/_orm_constructors.py)", "line": "newer", + "set": "tuning", + "grading": "[[[\"mapped_column\", \"Mapped[\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 841, - "ms": 1036.5 + "tokens": 815, + "ms": 1167.4 }, "variants": { "keyword": { @@ -4498,22 +4556,22 @@ ] ], "rejected": [], - "tokens": 913, - "ms": 518.7 + "tokens": 810, + "ms": 575.8 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 805, - "ms": 102.4 + "tokens": 872, + "ms": 104.2 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 841, - "ms": 1036.5 + "tokens": 815, + "ms": 1167.4 } }, "context7": { @@ -4535,12 +4593,14 @@ "question": "How do I create the declarative base class for my models?", "why": "1.4: Base = declarative_base(); DeclarativeBase class absent in 1.4.54 (verified)", "line": "older", + "set": "tuning", + "grading": "[[[\"declarative_base(\"]], [\"(DeclarativeBase)\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 831, - "ms": 152.1 + "tokens": 828, + "ms": 166.4 }, "variants": { "keyword": { @@ -4548,21 +4608,21 @@ "missing": [], "rejected": [], "tokens": 917, - "ms": 47.2 + "ms": 54.0 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 919, - "ms": 85.9 + "tokens": 931, + "ms": 95.2 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 831, - "ms": 152.1 + "tokens": 828, + "ms": 166.4 } }, "context7": { @@ -4586,34 +4646,36 @@ "question": "How do I create the declarative base class for my models?", "why": "2.0: class Base(DeclarativeBase) (sqlalchemy 2.0.54 orm/decl_api.py)", "line": "newer", + "set": "tuning", + "grading": "[[[\"DeclarativeBase\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 860, - "ms": 167.2 + "tokens": 863, + "ms": 189.8 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 956, - "ms": 56.8 + "tokens": 831, + "ms": 67.4 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 920, - "ms": 94.3 + "tokens": 897, + "ms": 103.4 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 860, - "ms": 167.2 + "tokens": 863, + "ms": 189.8 } }, "context7": { @@ -4635,16 +4697,14 @@ "question": "How do I set a database-computed default value for a model field?", "why": "4.2: only Python-side default=; db_default absent in 4.2.30 fields/__init__.py (0 matches)", "line": "older", + "set": "tuning", + "grading": "[[[\"default=\"]], [\"db_default\"]]", "lockdocs": { - "pass": false, - "missing": [ - [ - "default=" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 852, - "ms": 482.8 + "tokens": 812, + "ms": 1185.4 }, "variants": { "keyword": { @@ -4655,8 +4715,8 @@ ] ], "rejected": [], - "tokens": 848, - "ms": 396.2 + "tokens": 770, + "ms": 444.6 }, "hybrid": { "pass": false, @@ -4666,19 +4726,15 @@ ] ], "rejected": [], - "tokens": 835, - "ms": 87.7 + "tokens": 839, + "ms": 101.1 }, "fetched": { - "pass": false, - "missing": [ - [ - "default=" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 852, - "ms": 482.8 + "tokens": 812, + "ms": 1185.4 } }, "context7": { @@ -4700,16 +4756,14 @@ "question": "How do I set a database-computed default value for a model field?", "why": "5.0+: Field.db_default (django 5.1.15 fields/__init__.py, 19 matches)", "line": "newer", + "set": "tuning", + "grading": "[[[\"db_default\"]], []]", "lockdocs": { - "pass": false, - "missing": [ - [ - "db_default" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 858, - "ms": 490.7 + "tokens": 802, + "ms": 1214.2 }, "variants": { "keyword": { @@ -4720,30 +4774,22 @@ ] ], "rejected": [], - "tokens": 847, - "ms": 420.8 + "tokens": 860, + "ms": 473.4 }, "hybrid": { - "pass": false, - "missing": [ - [ - "db_default" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 841, - "ms": 93.9 + "tokens": 777, + "ms": 104.4 }, "fetched": { - "pass": false, - "missing": [ - [ - "db_default" - ] - ], + "pass": true, + "missing": [], "rejected": [], - "tokens": 858, - "ms": 490.7 + "tokens": 802, + "ms": 1214.2 } }, "context7": { @@ -4765,12 +4811,14 @@ "question": "How do I require login for all views by default?", "why": "4.2: @login_required / LoginRequiredMixin per view; LoginRequiredMiddleware absent in 4.2.30", "line": "older", + "set": "tuning", + "grading": "[[[\"login_required\", \"LoginRequiredMixin\"]], [\"LoginRequiredMiddleware\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 816, - "ms": 86.6 + "tokens": 790, + "ms": 205.6 }, "variants": { "keyword": { @@ -4778,21 +4826,21 @@ "missing": [], "rejected": [], "tokens": 862, - "ms": 46.2 + "ms": 57.8 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 846, - "ms": 89.2 + "tokens": 853, + "ms": 101.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 816, - "ms": 86.6 + "tokens": 790, + "ms": 205.6 } }, "context7": { @@ -4814,12 +4862,14 @@ "question": "How do I require login for all views by default?", "why": "5.1: django.contrib.auth.middleware.LoginRequiredMiddleware (django 5.1.15)", "line": "newer", + "set": "tuning", + "grading": "[[[\"LoginRequiredMiddleware\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 855, - "ms": 91.7 + "tokens": 736, + "ms": 207.1 }, "variants": { "keyword": { @@ -4827,21 +4877,21 @@ "missing": [], "rejected": [], "tokens": 520, - "ms": 46.3 + "ms": 58.4 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 835, - "ms": 94.5 + "tokens": 845, + "ms": 97.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 855, - "ms": 91.7 + "tokens": 736, + "ms": 207.1 } }, "context7": { @@ -4863,6 +4913,8 @@ "question": "How do I run code when the application starts up and shuts down?", "why": "0.88: @app.on_event('startup'/'shutdown'); FastAPI(lifespan=) absent (0.88.0 applications.py, 0 matches; added 0.93)", "line": "older", + "set": "tuning", + "grading": "[[[\"on_event\"]], [\"lifespan=\"]]", "lockdocs": { "pass": false, "missing": [ @@ -4871,8 +4923,8 @@ ] ], "rejected": [], - "tokens": 833, - "ms": 180.9 + "tokens": 873, + "ms": 203.4 }, "variants": { "keyword": { @@ -4883,8 +4935,8 @@ ] ], "rejected": [], - "tokens": 576, - "ms": 34.8 + "tokens": 372, + "ms": 34.4 }, "hybrid": { "pass": false, @@ -4895,7 +4947,7 @@ ], "rejected": [], "tokens": 555, - "ms": 47.9 + "ms": 47.4 }, "fetched": { "pass": false, @@ -4905,8 +4957,8 @@ ] ], "rejected": [], - "tokens": 833, - "ms": 180.9 + "tokens": 873, + "ms": 203.4 } }, "context7": { @@ -4934,12 +4986,14 @@ "question": "How do I run code when the application starts up and shuts down?", "why": "0.115: FastAPI(lifespan=...) with asynccontextmanager (0.115.14 applications.py)", "line": "newer", + "set": "tuning", + "grading": "[[[\"lifespan\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 867, - "ms": 231.3 + "tokens": 828, + "ms": 256.8 }, "variants": { "keyword": { @@ -4950,8 +5004,8 @@ ] ], "rejected": [], - "tokens": 900, - "ms": 47.7 + "tokens": 729, + "ms": 49.9 }, "hybrid": { "pass": false, @@ -4961,15 +5015,15 @@ ] ], "rejected": [], - "tokens": 936, - "ms": 53.4 + "tokens": 898, + "ms": 55.8 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 867, - "ms": 231.3 + "tokens": 828, + "ms": 256.8 } }, "context7": { @@ -4991,12 +5045,14 @@ "question": "How do I declare a query parameter with validation metadata?", "why": "0.88: q: str = Query(default=None, max_length=50); Annotated support added in 0.95 (0.88.0 dependencies/utils.py, 0 matches)", "line": "older", + "set": "tuning", + "grading": "[[[\"Query(\"]], [\"Annotated[\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 875, - "ms": 69.3 + "tokens": 865, + "ms": 75.7 }, "variants": { "keyword": { @@ -5007,8 +5063,8 @@ ] ], "rejected": [], - "tokens": 884, - "ms": 15.1 + "tokens": 803, + "ms": 15.4 }, "hybrid": { "pass": false, @@ -5018,15 +5074,15 @@ ] ], "rejected": [], - "tokens": 975, - "ms": 49.6 + "tokens": 985, + "ms": 52.4 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 875, - "ms": 69.3 + "tokens": 865, + "ms": 75.7 } }, "context7": { @@ -5050,34 +5106,36 @@ "question": "How do I declare a query parameter with validation metadata?", "why": "0.115: q: Annotated[str | None, Query(max_length=50)] recommended (0.115.14 dependencies/utils.py)", "line": "newer", + "set": "tuning", + "grading": "[[[\"Annotated[\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 838, - "ms": 70.1 + "tokens": 766, + "ms": 81.7 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 926, - "ms": 15.5 + "tokens": 933, + "ms": 15.7 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 926, - "ms": 54.0 + "tokens": 930, + "ms": 51.1 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 838, - "ms": 70.1 + "tokens": 766, + "ms": 81.7 } }, "context7": { @@ -5099,34 +5157,36 @@ "question": "How do I run code before a request is completed, for example to redirect or rewrite?", "why": "15.1: middleware.ts / NextMiddleware; Proxy does not exist before 16", "line": "older", + "set": "tuning", + "grading": "[[[\"middleware\"]], [\"NextProxy\", \"proxy.ts\"]]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 831, - "ms": 90.1 + "tokens": 797, + "ms": 103.5 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 363, - "ms": 19.7 + "tokens": 897, + "ms": 26.4 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 936, - "ms": 61.2 + "tokens": 946, + "ms": 63.8 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 831, - "ms": 90.1 + "tokens": 797, + "ms": 103.5 } }, "context7": { @@ -5150,34 +5210,36 @@ "question": "How do I run code before a request is completed, for example to redirect or rewrite?", "why": "16: middleware renamed to proxy (next 16.3.6 dist/server/web/types.d.ts: '@deprecated Use NextProxy instead. Middleware has been renamed to Proxy.')", "line": "newer", + "set": "tuning", + "grading": "[[[\"proxy.ts\", \"proxy.js\", \"NextProxy\", \"export function proxy\", \"Proxy allows\"]], []]", "lockdocs": { "pass": true, "missing": [], "rejected": [], - "tokens": 869, - "ms": 629.5 + "tokens": 816, + "ms": 718.2 }, "variants": { "keyword": { "pass": true, "missing": [], "rejected": [], - "tokens": 837, - "ms": 431.7 + "tokens": 847, + "ms": 520.0 }, "hybrid": { "pass": true, "missing": [], "rejected": [], - "tokens": 789, - "ms": 119.9 + "tokens": 865, + "ms": 142.9 }, "fetched": { "pass": true, "missing": [], "rejected": [], - "tokens": 869, - "ms": 629.5 + "tokens": 816, + "ms": 718.2 } }, "context7": { @@ -5190,40 +5252,2052 @@ "version_match": "same major", "reused": true } + }, + { + "id": "next14-params", + "project": "next14", + "package": "next", + "version": "14.2.35", + "question": "How do I read the dynamic route parameters in a page component?", + "why": "14: params is a plain object prop (no 'await params' in v14.2.35 docs/02-app/02-api-reference/02-file-conventions/page.mdx)", + "line": "older", + "set": "tuning", + "grading": "[[[\"params\"]], [\"await params\", \"Promise<{\"]]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 881, + "ms": 96.9 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 874, + "ms": 24.6 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 917, + "ms": 66.1 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 881, + "ms": 96.9 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1037, + "ms": 2606, + "library": "/vercel/next.js/v14.3.0-canary.87", + "version_match": "same major", + "reused": true + } + }, + { + "id": "next15-params", + "project": "next15", + "package": "next", + "version": "15.1.0", + "question": "How do I read the dynamic route parameters in a page component?", + "why": "15: params is a Promise (v15.1.0 page.mdx, 18 matches)", + "line": "newer", + "set": "tuning", + "grading": "[[[\"await params\", \"Promise<{\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 880, + "ms": 104.9 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "await params", + "Promise<{" + ] + ], + "rejected": [], + "tokens": 927, + "ms": 27.2 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "await params", + "Promise<{" + ] + ], + "rejected": [], + "tokens": 946, + "ms": 69.7 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 880, + "ms": 104.9 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1427, + "ms": 2666, + "library": "/vercel/next.js/v15.1.11", + "version_match": "same major", + "reused": true + } + }, + { + "id": "tw3-dark", + "project": "tailwind3", + "package": "tailwindcss", + "version": "3.4.19", + "question": "How do I switch dark mode to a class instead of the operating system setting?", + "why": "v3: darkMode: 'selector' / 'class' in tailwind.config.js (v3 site dark-mode.mdx)", + "line": "older", + "set": "tuning", + "grading": "[[[\"darkMode\"]], [\"@custom-variant\"]]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 896, + "ms": 65.7 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 420, + "ms": 3.5 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 420, + "ms": 43.6 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 896, + "ms": 65.7 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 578, + "ms": 6783, + "library": "/erimicel/select2-tailwindcss-theme", + "version_match": "unversioned" + } + }, + { + "id": "tw4-dark", + "project": "tailwind4", + "package": "tailwindcss", + "version": "4.1.18", + "question": "How do I switch dark mode to a class instead of the operating system setting?", + "why": "v4: @custom-variant dark (&:where(.dark, .dark *)) in CSS (v4 site dark-mode.mdx)", + "line": "newer", + "set": "tuning", + "grading": "[[[\"@custom-variant\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 874, + "ms": 67.8 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "@custom-variant" + ] + ], + "rejected": [], + "tokens": 297, + "ms": 2.1 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "@custom-variant" + ] + ], + "rejected": [], + "tokens": 516, + "ms": 39.3 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 874, + "ms": 67.8 + } + }, + "context7": { + "pass": false, + "missing": [ + [ + "@custom-variant" + ] + ], + "rejected": [], + "tokens": 578, + "ms": 6704, + "library": "/erimicel/select2-tailwindcss-theme", + "version_match": "unversioned" + } + }, + { + "id": "react18-ref", + "project": "react18", + "package": "react", + "version": "18.3.1", + "question": "How do I pass a ref through to a child component?", + "why": "18: forwardRef (@types/react 18.3.31)", + "line": "older", + "set": "tuning", + "grading": "[[[\"forwardRef\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 899, + "ms": 101.2 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 856, + "ms": 8.8 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 907, + "ms": 43.9 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 899, + "ms": 101.2 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 570, + "ms": 2826, + "library": "/reactjs/react.dev", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "react19-ref", + "project": "react19", + "package": "react", + "version": "19.2.8", + "question": "How do I pass a ref through to a child component?", + "why": "19: ref is a regular prop; forwardRef is no longer necessary (react.dev forwardRef.md)", + "line": "newer", + "set": "tuning", + "grading": "[[[\"as a prop\", \"no longer necessary\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 796, + "ms": 111.4 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "as a prop", + "no longer necessary" + ] + ], + "rejected": [], + "tokens": 925, + "ms": 8.7 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "as a prop", + "no longer necessary" + ] + ], + "rejected": [], + "tokens": 816, + "ms": 46.4 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 796, + "ms": 111.4 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 587, + "ms": 2618, + "library": "/reactjs/react.dev", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "rr6-types", + "project": "rr6", + "package": "react-router", + "version": "6.26.2", + "question": "How do I get typed loader data in my route component?", + "why": "v6: useLoaderData() (docs/hooks/use-loader-data.md); generated route types arrived in v7", + "line": "older", + "set": "tuning", + "grading": "[[[\"useLoaderData\"]], [\"Route.ComponentProps\", \"+types\"]]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 790, + "ms": 57.1 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 792, + "ms": 3.9 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 881, + "ms": 37.9 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 790, + "ms": 57.1 + } + }, + "context7": { + "pass": false, + "missing": [], + "rejected": [ + "Route.ComponentProps", + "+types" + ], + "tokens": 848, + "ms": 2558, + "library": "/websites/reactrouter", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "rr7-types", + "project": "rr7", + "package": "react-router", + "version": "7.1.1", + "question": "How do I get typed loader data in my route component?", + "why": "v7: generated ./+types/ and Route.ComponentProps (react-router@7.1.1 docs/start/framework/data-loading.md)", + "line": "newer", + "set": "tuning", + "grading": "[[[\"Route.ComponentProps\", \"+types\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 851, + "ms": 58.4 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "Route.ComponentProps", + "+types" + ] + ], + "rejected": [], + "tokens": 918, + "ms": 9.2 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "Route.ComponentProps", + "+types" + ] + ], + "rejected": [], + "tokens": 868, + "ms": 48.1 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 851, + "ms": 58.4 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 913, + "ms": 2768, + "library": "/websites/reactrouter", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "eslint8-globals", + "project": "eslint8", + "package": "eslint", + "version": "8.57.1", + "question": "How do I tell ESLint about browser global variables?", + "why": "8: env: { browser: true } in .eslintrc (v8.57.1 language-options.md)", + "line": "older", + "set": "tuning", + "grading": "[[[\"\\\"browser\\\": true\", \"browser: true\"]], []]", + "lockdocs": { + "pass": false, + "missing": [ + [ + "\"browser\": true", + "browser: true" + ] + ], + "rejected": [], + "tokens": 768, + "ms": 95.2 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "\"browser\": true", + "browser: true" + ] + ], + "rejected": [], + "tokens": 866, + "ms": 9.9 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "\"browser\": true", + "browser: true" + ] + ], + "rejected": [], + "tokens": 843, + "ms": 46.1 + }, + "fetched": { + "pass": false, + "missing": [ + [ + "\"browser\": true", + "browser: true" + ] + ], + "rejected": [], + "tokens": 768, + "ms": 95.2 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 654, + "ms": 2587, + "library": "/eslint/eslint/v8.57.1", + "version_match": "exact", + "reused": true + } + }, + { + "id": "eslint9-globals", + "project": "eslint9", + "package": "eslint", + "version": "9.39.5", + "question": "How do I tell ESLint about browser global variables?", + "why": "9: languageOptions: { globals: globals.browser } (v9.39.5 language-options.md)", + "line": "newer", + "set": "tuning", + "grading": "[[[\"globals.browser\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 741, + "ms": 96.4 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "globals.browser" + ] + ], + "rejected": [], + "tokens": 1027, + "ms": 7.8 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "globals.browser" + ] + ], + "rejected": [], + "tokens": 1017, + "ms": 40.8 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 741, + "ms": 96.4 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 602, + "ms": 2783, + "library": "/eslint/eslint/v9.39.3", + "version_match": "same major", + "reused": true + } + }, + { + "id": "dj51-generated", + "project": "django51", + "package": "django", + "version": "5.1.15", + "question": "How do I add a model field whose value the database computes from other columns?", + "why": "5.0+: models.GeneratedField (5.1.15 docs/ref/models/fields.txt)", + "line": "newer", + "set": "tuning", + "grading": "[[[\"GeneratedField\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 750, + "ms": 217.7 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "GeneratedField" + ] + ], + "rejected": [], + "tokens": 798, + "ms": 60.3 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "GeneratedField" + ] + ], + "rejected": [], + "tokens": 803, + "ms": 99.0 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 750, + "ms": 217.7 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 616, + "ms": 2802, + "library": "/django/django", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "fa0115-querymodel", + "project": "fastapi0115", + "package": "fastapi", + "version": "0.115.14", + "question": "How do I declare a group of query parameters with a Pydantic model?", + "why": "0.115.0+: query parameter models (docs/en/docs/tutorial/query-param-models.md)", + "line": "newer", + "set": "tuning", + "grading": "[[[\"Query Parameter Models\", \"FilterParams\", \"query parameter model\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 887, + "ms": 84.7 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "Query Parameter Models", + "FilterParams", + "query parameter model" + ] + ], + "rejected": [], + "tokens": 896, + "ms": 16.6 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "Query Parameter Models", + "FilterParams", + "query parameter model" + ] + ], + "rejected": [], + "tokens": 877, + "ms": 56.1 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 887, + "ms": 84.7 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 546, + "ms": 2583, + "library": "/websites/fastapi_tiangolo", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "tokio-channel", + "project": "axum08", + "package": "tokio", + "version": "1.43.0", + "question": "How do I send messages from many tasks to a single consumer task?", + "why": "tokio::sync::mpsc", + "line": "single", + "set": "tuning", + "grading": "[[[\"mpsc\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 881, + "ms": 70.7 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 851, + "ms": 27.6 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 842, + "ms": 64.8 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 881, + "ms": 70.7 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1489, + "ms": 2830, + "library": "/websites/rs_tokio_tokio", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "zod3-datetime", + "project": "zod3", + "package": "zod", + "version": "3.23.8", + "question": "How do I validate an ISO 8601 datetime string?", + "why": "3.23: z.string().datetime(); z.iso arrived in zod 4 (0 matches in v3.23.8 README)", + "line": "older", + "set": "tuning", + "grading": "[[[\".datetime(\"]], [\"z.iso.\"]]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1162, + "ms": 44.0 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 985, + "ms": 6.4 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 854, + "ms": 45.0 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1162, + "ms": 44.0 + } + }, + "context7": { + "pass": false, + "missing": [], + "rejected": [ + "z.iso." + ], + "tokens": 1110, + "ms": 3012, + "library": "/colinhacks/zod/v3.24.2", + "version_match": "same major", + "reused": true + } + }, + { + "id": "zod4-datetime", + "project": "zod4", + "package": "zod", + "version": "4.1.5", + "question": "How do I validate an ISO 8601 datetime string?", + "why": "4: z.iso.datetime() (v4.1.5 packages/docs/content/api.mdx)", + "line": "newer", + "set": "tuning", + "grading": "[[[\"z.iso.datetime\", \"iso.datetime(\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1158, + "ms": 61.1 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 510, + "ms": 13.2 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 386, + "ms": 56.6 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1158, + "ms": 61.1 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1499, + "ms": 2418, + "library": "/colinhacks/zod/v4.0.1", + "version_match": "same major", + "reused": true + } + }, + { + "id": "pyd1-frozen", + "project": "pydantic1", + "package": "pydantic", + "version": "1.10.18", + "question": "How do I make model instances immutable?", + "why": "v1: class Config: allow_mutation = False / frozen = True (v1.10.18 docs/usage/model_config.md). Rejects the v2 idiom model_config = ConfigDict", + "line": "older", + "set": "tuning", + "grading": "[[[\"allow_mutation\"]], [\"model_config = ConfigDict\"]]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 930, + "ms": 69.0 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 972, + "ms": 19.9 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "allow_mutation" + ] + ], + "rejected": [], + "tokens": 949, + "ms": 58.3 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 930, + "ms": 69.0 + } + }, + "context7": { + "pass": false, + "missing": [], + "rejected": [ + "model_config = ConfigDict" + ], + "tokens": 1092, + "ms": 1857, + "library": "/pydantic/pydantic", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "pyd2-frozen", + "project": "pydantic2", + "package": "pydantic", + "version": "2.9.2", + "question": "How do I make model instances immutable?", + "why": "v2: model_config = ConfigDict(frozen=True) (v2.9.2 docs/concepts/models.md)", + "line": "newer", + "set": "tuning", + "grading": "[[[\"frozen=True\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 884, + "ms": 81.9 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "frozen=True" + ] + ], + "rejected": [], + "tokens": 829, + "ms": 25.3 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "frozen=True" + ] + ], + "rejected": [], + "tokens": 917, + "ms": 59.1 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 884, + "ms": 81.9 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1214, + "ms": 2487, + "library": "/pydantic/pydantic", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "next15-form", + "project": "next15", + "package": "next", + "version": "15.1.0", + "question": "How do I build a search form that navigates to a results page with client-side navigation?", + "why": "15.0+: from next/form (v15.1.0 docs/01-app/03-api-reference/02-components/form.mdx)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"next/form\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 898, + "ms": 103.3 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "next/form" + ] + ], + "rejected": [], + "tokens": 895, + "ms": 27.4 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "next/form" + ] + ], + "rejected": [], + "tokens": 881, + "ms": 71.0 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 898, + "ms": 103.3 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 950, + "ms": 3170, + "library": "/vercel/next.js/v15.1.11", + "version_match": "same major", + "reused": true + } + }, + { + "id": "next16-cache", + "project": "next16", + "package": "next", + "version": "16.3.6", + "question": "How do I cache the result of an expensive function across requests?", + "why": "16: the 'use cache' directive (v16.3.6 docs/01-app/03-api-reference/01-directives/use-cache.mdx)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"use cache\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 856, + "ms": 142.4 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 881, + "ms": 92.0 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 834, + "ms": 147.5 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 856, + "ms": 142.4 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1258, + "ms": 3159, + "library": "/vercel/next.js/v16.2.9", + "version_match": "same major", + "reused": true + } + }, + { + "id": "zod3-meta", + "project": "zod3", + "package": "zod", + "version": "3.23.8", + "question": "How do I attach a description to a schema?", + "why": "3.23: .describe(); .meta() arrived in zod 4 (0 matches in v3.23.8 README)", + "line": "older", + "set": "held-out", + "grading": "[[[\".describe(\"]], [\".meta(\"]]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 975, + "ms": 45.2 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 858, + "ms": 6.5 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 957, + "ms": 40.1 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 975, + "ms": 45.2 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 132, + "ms": 2243, + "library": "/colinhacks/zod/v3.24.2", + "version_match": "same major", + "reused": true + } + }, + { + "id": "zod4-meta", + "project": "zod4", + "package": "zod", + "version": "4.1.5", + "question": "How do I attach a description to a schema?", + "why": "4: .meta() with the global registry (v4.1.5 packages/docs/content/metadata.mdx)", + "line": "newer", + "set": "held-out", + "grading": "[[[\".meta(\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 888, + "ms": 55.0 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + ".meta(" + ] + ], + "rejected": [], + "tokens": 196, + "ms": 12.1 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + ".meta(" + ] + ], + "rejected": [], + "tokens": 1139, + "ms": 48.8 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 888, + "ms": 55.0 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1048, + "ms": 3495, + "library": "/colinhacks/zod/v4.0.1", + "version_match": "same major", + "reused": true + } + }, + { + "id": "pyd2-computed", + "project": "pydantic2", + "package": "pydantic", + "version": "2.9.2", + "question": "How do I include a computed property when serializing a model?", + "why": "2: @computed_field (v2.9.2 docs/concepts/fields.md)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"computed_field\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 892, + "ms": 81.5 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 901, + "ms": 25.6 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 901, + "ms": 63.9 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 892, + "ms": 81.5 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1135, + "ms": 2870, + "library": "/pydantic/pydantic", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "pyd1-json", + "project": "pydantic1", + "package": "pydantic", + "version": "1.10.18", + "question": "How do I parse and validate a model from a JSON string?", + "why": "1: Model.parse_raw() (v1.10.18 docs/usage/models.md)", + "line": "older", + "set": "held-out", + "grading": "[[[\"parse_raw\"]], [\"model_validate_json\"]]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 938, + "ms": 69.8 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "parse_raw" + ] + ], + "rejected": [], + "tokens": 909, + "ms": 19.7 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "parse_raw" + ] + ], + "rejected": [], + "tokens": 882, + "ms": 66.5 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 938, + "ms": 69.8 + } + }, + "context7": { + "pass": false, + "missing": [ + [ + "parse_raw" + ] + ], + "rejected": [ + "model_validate_json" + ], + "tokens": 1319, + "ms": 2189, + "library": "/pydantic/pydantic", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "pyd2-json", + "project": "pydantic2", + "package": "pydantic", + "version": "2.9.2", + "question": "How do I parse and validate a model from a JSON string?", + "why": "2: Model.model_validate_json() (v2.9.2 docs/concepts/models.md)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"model_validate_json\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 872, + "ms": 79.0 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 861, + "ms": 25.1 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 908, + "ms": 64.0 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 872, + "ms": 79.0 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1319, + "ms": 1630, + "library": "/pydantic/pydantic", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "sa20-dataclass", + "project": "sqlalchemy20", + "package": "sqlalchemy", + "version": "2.0.54", + "question": "How do I declare ORM models that are also Python dataclasses?", + "why": "2.0: MappedAsDataclass (rel_2_0_54 doc/build/orm/dataclasses.rst)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"MappedAsDataclass\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 867, + "ms": 192.9 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1017, + "ms": 62.5 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 1017, + "ms": 102.7 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 867, + "ms": 192.9 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 976, + "ms": 2516, + "library": "/websites/sqlalchemy_en_20", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "eslint8-plugins", + "project": "eslint8", + "package": "eslint", + "version": "8.57.1", + "question": "How do I add a plugin to my ESLint configuration?", + "why": "8: \"plugins\": [...] in .eslintrc (v8.57.1 docs/src/use/configure/plugins.md)", + "line": "older", + "set": "held-out", + "grading": "[[[\"\\\"plugins\\\": [\", \"plugins: [\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 784, + "ms": 90.0 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "\"plugins\": [", + "plugins: [" + ] + ], + "rejected": [], + "tokens": 895, + "ms": 10.3 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "\"plugins\": [", + "plugins: [" + ] + ], + "rejected": [], + "tokens": 905, + "ms": 47.9 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 784, + "ms": 90.0 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 990, + "ms": 2698, + "library": "/eslint/eslint/v8.57.1", + "version_match": "exact", + "reused": true + } + }, + { + "id": "eslint9-plugins", + "project": "eslint9", + "package": "eslint", + "version": "9.39.5", + "question": "How do I add a plugin to my ESLint configuration?", + "why": "9: plugins: { name: plugin } in eslint.config.js (v9.39.5 docs/src/use/configure/plugins.md)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"plugins: {\"]], []]", + "lockdocs": { + "pass": false, + "missing": [ + [ + "plugins: {" + ] + ], + "rejected": [], + "tokens": 788, + "ms": 101.2 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "plugins: {" + ] + ], + "rejected": [], + "tokens": 943, + "ms": 7.8 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "plugins: {" + ] + ], + "rejected": [], + "tokens": 943, + "ms": 48.0 + }, + "fetched": { + "pass": false, + "missing": [ + [ + "plugins: {" + ] + ], + "rejected": [], + "tokens": 788, + "ms": 101.2 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 959, + "ms": 2626, + "library": "/eslint/eslint/v9.39.3", + "version_match": "same major", + "reused": true + } + }, + { + "id": "vite6-envapi", + "project": "vite6", + "package": "vite", + "version": "6.4.3", + "question": "How does a plugin know which environment it is running in?", + "why": "6: this.environment in plugin hooks (v6.4.3 docs/guide/api-environment-plugins.md)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"this.environment\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 673, + "ms": 62.4 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 917, + "ms": 10.5 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "this.environment" + ] + ], + "rejected": [], + "tokens": 851, + "ms": 51.5 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 673, + "ms": 62.4 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 840, + "ms": 2923, + "library": "/vitejs/vite", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "react18-context", + "project": "react18", + "package": "react", + "version": "18.3.1", + "question": "How do I provide a context value to child components?", + "why": "18: (@types/react 18.3.31)", + "line": "older", + "set": "held-out", + "grading": "[[[\".Provider\"]], [\"Starting in React 19\"]]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 823, + "ms": 97.5 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 952, + "ms": 8.7 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + ".Provider" + ] + ], + "rejected": [], + "tokens": 948, + "ms": 42.4 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 823, + "ms": 97.5 + } + }, + "context7": { + "pass": false, + "missing": [], + "rejected": [ + "Starting in React 19" + ], + "tokens": 1155, + "ms": 2796, + "library": "/reactjs/react.dev", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "react19-context", + "project": "react19", + "package": "react", + "version": "19.2.8", + "question": "How do I provide a context value to child components?", + "why": "19: render as the provider; .Provider is legacy (react.dev createContext.md)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"Starting in React 19\", \"is a legacy way\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 812, + "ms": 104.1 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "Starting in React 19", + "is a legacy way" + ] + ], + "rejected": [], + "tokens": 902, + "ms": 8.9 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "Starting in React 19", + "is a legacy way" + ] + ], + "rejected": [], + "tokens": 903, + "ms": 51.1 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 812, + "ms": 104.1 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 778, + "ms": 2187, + "library": "/reactjs/react.dev", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "rr7-routesfile", + "project": "rr7", + "package": "react-router", + "version": "7.1.1", + "question": "Where do I configure my app's routes?", + "why": "7 framework mode: app/routes.ts (react-router@7.1.1 docs/start/framework/routing.md)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"routes.ts\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 898, + "ms": 55.4 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "routes.ts" + ] + ], + "rejected": [], + "tokens": 883, + "ms": 9.2 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "routes.ts" + ] + ], + "rejected": [], + "tokens": 923, + "ms": 49.5 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 898, + "ms": 55.4 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 583, + "ms": 2735, + "library": "/websites/reactrouter", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "dj51-facets", + "project": "django51", + "package": "django", + "version": "5.1.15", + "question": "How do I show counts next to the admin list filters?", + "why": "5.0+: ModelAdmin.show_facets (5.1.15 docs/ref/contrib/admin/index.txt)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"show_facets\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 769, + "ms": 209.4 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "show_facets" + ] + ], + "rejected": [], + "tokens": 873, + "ms": 61.1 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "show_facets" + ] + ], + "rejected": [], + "tokens": 912, + "ms": 103.2 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 769, + "ms": 209.4 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 440, + "ms": 2828, + "library": "/django/django", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "tw3-source", + "project": "tailwind3", + "package": "tailwindcss", + "version": "3.4.19", + "question": "How do I tell Tailwind which files to scan for class names?", + "why": "v3: content: [...] in tailwind.config.js (v3 site content-configuration.mdx)", + "line": "older", + "set": "held-out", + "grading": "[[[\"content:\"]], [\"@source\"]]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 860, + "ms": 61.7 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "content:" + ] + ], + "rejected": [], + "tokens": 1276, + "ms": 3.6 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "content:" + ] + ], + "rejected": [], + "tokens": 1209, + "ms": 44.1 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 860, + "ms": 61.7 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 511, + "ms": 1488, + "library": "/rails/tailwindcss-rails", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "tw4-source", + "project": "tailwind4", + "package": "tailwindcss", + "version": "4.1.18", + "question": "How do I tell Tailwind which files to scan for class names?", + "why": "v4: automatic detection plus @source in CSS (v4 site detecting-classes-in-source-files.mdx)", + "line": "newer", + "set": "held-out", + "grading": "[[[\"@source\"]], []]", + "lockdocs": { + "pass": false, + "missing": [ + [ + "@source" + ] + ], + "rejected": [], + "tokens": 939, + "ms": 66.7 + }, + "variants": { + "keyword": { + "pass": false, + "missing": [ + [ + "@source" + ] + ], + "rejected": [], + "tokens": 402, + "ms": 2.2 + }, + "hybrid": { + "pass": false, + "missing": [ + [ + "@source" + ] + ], + "rejected": [], + "tokens": 434, + "ms": 41.7 + }, + "fetched": { + "pass": false, + "missing": [ + [ + "@source" + ] + ], + "rejected": [], + "tokens": 939, + "ms": 66.7 + } + }, + "context7": { + "pass": false, + "missing": [ + [ + "@source" + ] + ], + "rejected": [], + "tokens": 511, + "ms": 1274, + "library": "/rails/tailwindcss-rails", + "version_match": "unversioned", + "reused": true + } + }, + { + "id": "tokio-interval", + "project": "axum08", + "package": "tokio", + "version": "1.43.0", + "question": "How do I run a task every few seconds?", + "why": "tokio::time::interval", + "line": "single", + "set": "held-out", + "grading": "[[[\"interval(\"]], []]", + "lockdocs": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 890, + "ms": 65.2 + }, + "variants": { + "keyword": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 877, + "ms": 27.5 + }, + "hybrid": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 840, + "ms": 62.6 + }, + "fetched": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 890, + "ms": 65.2 + } + }, + "context7": { + "pass": true, + "missing": [], + "rejected": [], + "tokens": 133, + "ms": 2762, + "library": "/websites/rs_tokio_tokio", + "version_match": "unversioned", + "reused": true + } } ], "summary": { "keyword": { - "answered": 70, - "passed": 41, - "total": 70, - "median_tokens": 898, - "median_ms": 22.2, - "p95_ms": 397.4 + "answered": 105, + "passed": 59, + "total": 105, + "median_tokens": 882, + "median_ms": 24.6, + "p95_ms": 276.2 }, "hybrid": { - "answered": 70, - "passed": 46, - "total": 70, - "median_tokens": 915, - "median_ms": 48.6, - "p95_ms": 94.3 + "answered": 105, + "passed": 60, + "total": 105, + "median_tokens": 903, + "median_ms": 52.4, + "p95_ms": 103.2 }, "fetched": { - "answered": 70, - "passed": 55, - "total": 70, - "median_tokens": 875, - "median_ms": 86.6, - "p95_ms": 490.7 + "answered": 105, + "passed": 96, + "total": 105, + "median_tokens": 866, + "median_ms": 96.9, + "p95_ms": 473.9 }, "context7": { - "answered": 70, - "passed": 49, - "total": 70, + "answered": 105, + "passed": 77, + "total": 105, "median_tokens": 908, - "median_ms": 2011, - "p95_ms": 3327 + "median_ms": 2583, + "p95_ms": 3398 } }, "variants": [ @@ -5241,13 +7315,13 @@ ] ], "context7_calls": { - "calls": 4, + "calls": 8, "rate_limited": 0, - "errors": 0, + "errors": 4, "stopped": false, "limit": "200", - "remaining": "196", - "reused": 67 + "remaining": "190", + "reused": 103 }, "runner": { "os": "Linux", diff --git a/docs/vision.md b/docs/vision.md new file mode 100644 index 0000000..c02626b --- /dev/null +++ b/docs/vision.md @@ -0,0 +1,46 @@ +# lockdocs vision + +## What we are building + +lockdocs gives AI coding agents the documentation for the exact library +versions a project uses. It reads the project's lockfile, finds each package on +disk (or downloads that exact version when asked), and answers questions from +that version's own files: its README, docs folders, type declarations and +doc comments, plus the upstream docs at the version's git tag. + +An agent that writes code for Next.js 14 should get the Next.js 14 API, not the +newest one. That is the one job. + +## Who it is for + +- Developers who use an AI coding agent (Claude Code, Codex, Cursor, VS Code, + Windsurf, Gemini CLI) on projects that do not track the newest release of + every dependency. +- Teams that cannot send their dependency list to a hosted service, or that + hit rate limits on one. + +## Boundaries + +- Local first: it runs on the developer's machine, needs no account or API + key, and works offline after the one-time downloads. +- Network use is opt-in and limited to public sources: package registries, + GitHub (docs folders at a tag, and official docs-site repositories), and the + embedding model on Hugging Face. +- Three MCP tools only: `resolve`, `docs`, `api`. New abilities go into those + tools, not into more tools. +- Four ecosystems: npm, PyPI, Cargo and Go. A new ecosystem needs a lockfile + parser, a way to find installed files, and a symbol extractor. +- No hosted index. We do not crawl or store other people's documentation on a + server. + +## What good looks like + +- On the version-sensitive benchmark (`bench/`), lockdocs is correct at least + as often as the best hosted alternative on every column: older majors, newer + majors, single-version libraries and the held-out questions. Results are + measured on GitHub-hosted runners and published as they come out. +- A question costs tens of milliseconds and about a thousand tokens. +- Every answer cites `package@version path:line`, so an agent or a person can + check it. + +Current capabilities and their code: [capabilities.md](capabilities.md). diff --git a/package.json b/package.json index b10501d..e906a6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lockdocs-workspace", - "version": "0.2.1", + "version": "0.3.0", "private": true, "type": "module", "description": "lockdocs monorepo: Rust engine (crates/), npm packages (packages/), docs (docs/), benchmark (bench/).", diff --git a/packages/lockdocs/package.json b/packages/lockdocs/package.json index 22d4423..c01d93e 100644 --- a/packages/lockdocs/package.json +++ b/packages/lockdocs/package.json @@ -1,6 +1,6 @@ { "name": "@sylphx/lockdocs", - "version": "0.2.1", + "version": "0.3.0", "mcpName": "io.github.SylphxAI/lockdocs", "description": "Exact-version library docs from your lockfile — local, offline, no rate limits.", "bin": { @@ -15,11 +15,11 @@ "node": ">=18" }, "optionalDependencies": { - "@sylphx/lockdocs-darwin-arm64": "0.2.1", - "@sylphx/lockdocs-darwin-x64": "0.2.1", - "@sylphx/lockdocs-linux-x64-gnu": "0.2.1", - "@sylphx/lockdocs-linux-arm64-gnu": "0.2.1", - "@sylphx/lockdocs-win32-x64-msvc": "0.2.1" + "@sylphx/lockdocs-darwin-arm64": "0.3.0", + "@sylphx/lockdocs-darwin-x64": "0.3.0", + "@sylphx/lockdocs-linux-x64-gnu": "0.3.0", + "@sylphx/lockdocs-linux-arm64-gnu": "0.3.0", + "@sylphx/lockdocs-win32-x64-msvc": "0.3.0" }, "keywords": [ "mcp", diff --git a/packages/npm/darwin-arm64/package.json b/packages/npm/darwin-arm64/package.json index a1a07f7..5ca13e4 100644 --- a/packages/npm/darwin-arm64/package.json +++ b/packages/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sylphx/lockdocs-darwin-arm64", - "version": "0.2.1", + "version": "0.3.0", "description": "lockdocs native binary for darwin-arm64", "os": [ "darwin" diff --git a/packages/npm/darwin-x64/package.json b/packages/npm/darwin-x64/package.json index 3a58179..e0d0b5a 100644 --- a/packages/npm/darwin-x64/package.json +++ b/packages/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sylphx/lockdocs-darwin-x64", - "version": "0.2.1", + "version": "0.3.0", "description": "lockdocs native binary for darwin-x64", "os": [ "darwin" diff --git a/packages/npm/linux-arm64-gnu/package.json b/packages/npm/linux-arm64-gnu/package.json index 6693558..f9f9900 100644 --- a/packages/npm/linux-arm64-gnu/package.json +++ b/packages/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@sylphx/lockdocs-linux-arm64-gnu", - "version": "0.2.1", + "version": "0.3.0", "description": "lockdocs native binary for linux-arm64-gnu", "os": [ "linux" diff --git a/packages/npm/linux-x64-gnu/package.json b/packages/npm/linux-x64-gnu/package.json index b9ab01c..6ea8784 100644 --- a/packages/npm/linux-x64-gnu/package.json +++ b/packages/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@sylphx/lockdocs-linux-x64-gnu", - "version": "0.2.1", + "version": "0.3.0", "description": "lockdocs native binary for linux-x64-gnu", "os": [ "linux" diff --git a/packages/npm/win32-x64-msvc/package.json b/packages/npm/win32-x64-msvc/package.json index c72511f..de0a3c0 100644 --- a/packages/npm/win32-x64-msvc/package.json +++ b/packages/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@sylphx/lockdocs-win32-x64-msvc", - "version": "0.2.1", + "version": "0.3.0", "description": "lockdocs native binary for win32-x64-msvc", "os": [ "win32" diff --git a/scripts/check-capabilities.ts b/scripts/check-capabilities.ts new file mode 100644 index 0000000..27caf97 --- /dev/null +++ b/scripts/check-capabilities.ts @@ -0,0 +1,24 @@ +// docs/capabilities.md: every path a supported or partial row names exists, +// and planned or retired rows name none. +import { existsSync, readFileSync } from "node:fs"; + +const rows = readFileSync("docs/capabilities.md", "utf8") + .split("\n") + .filter((l) => l.startsWith("| LD-")) + .map((l) => l.split("|").slice(1, -1).map((c) => c.trim())); +const bad: string[] = []; +const statuses = ["supported", "partial", "planned", "retired"]; +for (const [id, , status, code] of rows) { + const paths = code ? code.split(",").map((p) => p.trim()).filter(Boolean) : []; + if (!statuses.includes(status) && !status.startsWith("rename-to:")) bad.push(`${id}: unknown status ${status}`); + if (status === "supported" || status === "partial") { + if (!paths.length) bad.push(`${id}: ${status} but names no code`); + for (const p of paths) if (!existsSync(p)) bad.push(`${id}: ${p} does not exist`); + } else if (paths.length) bad.push(`${id}: ${status} but names code (${paths.join(", ")})`); +} +if (!rows.length) bad.push("no capability rows found"); +if (bad.length) { + console.error(`docs/capabilities.md:\n ${bad.join("\n ")}`); + process.exit(1); +} +console.log(`capabilities: ${rows.length} rows, every named path exists`); diff --git a/server.json b/server.json index afc097c..6bd4d20 100644 --- a/server.json +++ b/server.json @@ -7,13 +7,13 @@ "url": "https://github.com/SylphxAI/lockdocs", "source": "github" }, - "version": "0.2.1", + "version": "0.3.0", "websiteUrl": "https://sylphxai.github.io/lockdocs/", "packages": [ { "registryType": "npm", "identifier": "@sylphx/lockdocs", - "version": "0.2.1", + "version": "0.3.0", "transport": { "type": "stdio" },