diff --git a/.claude/settings.json b/.claude/settings.json index 81ddc98..8941bb2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "printf '\\nπŸ“¦ Agent Friendly Code β€” current release: 0.6.0\\n β€’ Read AGENTS.md for conventions, CONTRIBUTING.md for the PR workflow.\\n β€’ Roadmap: 0.7.0 (maintainer ownership + at-scale discovery β€” OAuth opt-out + package overlay at scale) β†’ 1.0.0 (production cut β€” Postgres + at-scale indexing + benchmark harness).\\n β€’ Changelog rule: user-facing capabilities only. Codebase hygiene (CI / linter / tests / CONTRIBUTING) does NOT go in lib/changelog.ts.\\n'" + "command": "printf '\\nπŸ“¦ Agent Friendly Code β€” current release: 0.7.0\\n β€’ Read AGENTS.md for conventions, CONTRIBUTING.md for the PR workflow.\\n β€’ Roadmap: 0.8.0 (maintainer ownership + at-scale discovery) β†’ 1.0.0 (production cut β€” Postgres + at-scale indexing + benchmark harness).\\n β€’ Changelog rule: user-facing capabilities only. Codebase hygiene (CI / linter / tests / CONTRIBUTING) does NOT go in lib/changelog.ts.\\n'" } ] } diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index f58cb4c..71b2ed2 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -38,7 +38,7 @@ Run through this checklist on any diff. Flag violations with the specific line a ## Components - If you're writing the same markup 2–3 times, extract a component into `components/`. -- Components are presentational β€” no data fetching, no side effects. +- Components are presentational β€” no data fetching, no side effects. One carve-out: a client component may read/write `localStorage` in an effect (`RecentScores`, `RecordScore`, `ReleaseAnnouncement`), because per-visitor state has nowhere else to live in a site with no accounts. The storage access itself must be extracted to `lib/` (`live-score/recents.ts`, `release-notice.ts`) and wrapped in try/catch β€” private mode throws β€” and the read must happen after mount, never during render, or the server HTML will not match. - Props are typed explicitly; avoid `any`. ## Icons @@ -54,7 +54,7 @@ Only `@phosphor-icons/react`. Block Lucide, Heroicons, React Icons, inline SVG, ## Security - Parameterised SQL only. -- `dangerouslySetInnerHTML` is allowed only for the existing server-built JSON-LD scripts (`app/layout.tsx`, `app/page.tsx`, `app/action/page.tsx`, `app/skill/page.tsx`, `app/methodology/page.tsx`, `app/repo/[id]/page.tsx`, `app/package/[registry]/[name]/page.tsx`) with the `<` β†’ `<` escape preserved. Reject any new use. +- `dangerouslySetInnerHTML` is allowed only for the existing server-built JSON-LD scripts (`app/layout.tsx`, `app/page.tsx`, `app/about/page.tsx`, `app/action/page.tsx`, `app/skill/page.tsx`, `app/score/page.tsx`, `app/methodology/page.tsx`, `app/repo/[id]/page.tsx`, `app/package/[registry]/[name]/page.tsx`, plus the `BreadcrumbJsonLd` component) with the `<` β†’ `<` escape preserved. Keep this list in step with the one in `AGENTS.md`. Reject any new use. - External links include `rel="noopener noreferrer"`. - Never execute code from a cloned repo. diff --git a/.claude/skills/quality-check/SKILL.md b/.claude/skills/quality-check/SKILL.md index ae5c645..da8a784 100644 --- a/.claude/skills/quality-check/SKILL.md +++ b/.claude/skills/quality-check/SKILL.md @@ -39,7 +39,7 @@ Run the four checks below on any diff affecting UI or I/O. Report findings group ## Security - **SQL parameterisation**: every query uses `?` placeholders. No string concatenation. -- **`dangerouslySetInnerHTML`** is allowed only for server-built JSON-LD (`app/layout.tsx`, `app/page.tsx`, `app/action/page.tsx`, `app/skill/page.tsx`, `app/methodology/page.tsx`, `app/repo/[id]/page.tsx`, `app/package/[registry]/[name]/page.tsx`) and must keep the `<` β†’ `<` escape. Any other use must be rejected. +- **`dangerouslySetInnerHTML`** is allowed only for server-built JSON-LD (`app/layout.tsx`, `app/page.tsx`, `app/about/page.tsx`, `app/action/page.tsx`, `app/skill/page.tsx`, `app/score/page.tsx`, `app/methodology/page.tsx`, `app/repo/[id]/page.tsx`, `app/package/[registry]/[name]/page.tsx`, plus the `BreadcrumbJsonLd` component) and must keep the `<` β†’ `<` escape. Keep this list in step with `AGENTS.md` and the `code-review` skill. Any other use must be rejected. - **External URLs** in `` always include `rel="noopener noreferrer"`. - **User input at every boundary** is validated: `parseRepoUrl` for repo URLs, `Number.isFinite` for numeric params, length caps on search strings. - **Clone safety**: `git clone --depth 1 --single-branch`; never execute code from a clone (no `bun install`, no `npm install`, no post-clone scripts). diff --git a/.env.example b/.env.example index 1c68696..d2c02c4 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,12 @@ NEXT_PUBLIC_APP_URL=https://agent-friendly-code.vercel.app # ---------- Host API tokens ---------- # Raise rate limits when fetching repo metadata (stars, default branch) during -# `bun run score` / `bun run seed`. Scoring works without these, just slower -# against the unauthenticated ceiling. +# `bun run score` / `bun run seed`. Optional for the CLI β€” just slower against +# the unauthenticated ceiling. +# +# GITHUB_TOKEN is REQUIRED for the deployed app: /score/* calls api.github.com, +# which allows 60 requests/hour per IP unauthenticated, and serverless egress +# IPs are shared. Tokenized it is 5,000/hour. # Classic or fine-grained PAT with public_repo read access is enough. GITHUB_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd51fb6..3d7263f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ concurrency: jobs: check: - name: Lint Β· Format Β· Typecheck Β· Test + name: Lint Β· Format Β· Typecheck Β· Test Β· Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -38,3 +38,9 @@ jobs: - name: Test run: bun run test + + # tsc does not see what only the bundler sees β€” a server-only import + # pulled into a client component, an RSC boundary violation. Without this + # the first place a broken build shows up is the Vercel deploy. + - name: Build + run: bun run build diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml new file mode 100644 index 0000000..d83d489 --- /dev/null +++ b/.github/workflows/parity.yml @@ -0,0 +1,58 @@ +name: Score parity + +# The live-score path and `bun run score` must produce identical numbers. A +# signal that reads a new file would otherwise score as empty on the live path +# only β€” no error, just a wrong number on a public page. + +on: + pull_request: + branches: [main] + paths: + - "lib/scoring/**" + - "lib/live-score/**" + - "lib/badge-adoption.ts" + - "lib/clients/git.ts" + - "scripts/parity-check.ts" + schedule: + # 03:00 UTC. Runs the curated fixture set in scripts/parity-check.ts β€” the + # failure classes, not the seed list: parity is a property of the two code + # paths, so more repos of the same shape buy no coverage and cost clone time. + - cron: "0 3 * * *" + workflow_dispatch: + +concurrency: + group: parity-${{ github.ref }} + cancel-in-progress: true + +jobs: + parity: + name: Clone vs live path + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.16 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install + run: bun install --frozen-lockfile + + - name: Parity (PR subset) + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bun run parity-check --pr + + - name: Parity (full fixtures) + if: github.event_name != 'pull_request' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bun run parity-check diff --git a/AGENTS.md b/AGENTS.md index 1eb130e..7e53c4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ bun run seed # score the curated set across GH / GL / BB bun run dev # http://localhost:3000 bun run score # score a single repo bun run audit-seeds # check every seed is public, original, and still there +bun run parity-check # clone-vs-live-path scores must match (add --pr for the PR subset) bun run test # unit tests (node --test + tsx) β€” requires Node β‰₯20.9.0 ``` @@ -61,6 +62,11 @@ app/ twitter-image.tsx # next/og convention β€” twitter:image, re-exports opengraph-image (auto-wired) repo/[id]/opengraph-image.tsx # next/og convention β€” per-repo OG image (auto-wired) repo/[id]/twitter-image.tsx # next/og convention β€” per-repo twitter:image, re-exports (auto-wired) + score/page.tsx # Live Score entry β€” URL form, past scores, FAQ + score/opengraph-image.tsx # next/og convention β€” Live Score OG image (auto-wired) + score/twitter-image.tsx # next/og convention β€” /score twitter:image, re-exports (auto-wired) + score/[host]/[owner]/[name]/page.tsx # live score; result cached 1h per repo (unstable_cache); redirects to /repo/:id when indexed + score/[host]/[owner]/[name]/error.tsx # retry boundary β€” page.tsx throws transient failures here so they aren't cached package/page.tsx # explainer + try-it examples package/[registry]/[name]/page.tsx # scored | not_scored | unresolved states action/page.tsx # PR-diff GitHub Action explainer + install snippet (SEO landing for the sibling action repo) @@ -75,7 +81,8 @@ components/ # Tailwind-styled React components AlternativesStrip.tsx, BreadcrumbJsonLd.tsx, HomeJsonLd.tsx, ExternalLink.tsx, BadgeEmbed.tsx, ActionEmbed.tsx, PeerlistCard.tsx, PeerlistBadge.tsx, ProductHuntBadge.tsx, CopySnippet.tsx, PackageLookupForm.tsx, - BadgeAdoptedTag.tsx, BackToTop.tsx, GoogleAnalytics.tsx + BadgeAdoptedTag.tsx, BackToTop.tsx, GoogleAnalytics.tsx, + LiveScoreForm.tsx, RecentScores.tsx, RecordScore.tsx, ReleaseAnnouncement.tsx lib/ constants/ scoring.ts # score thresholds, visible limits @@ -94,6 +101,13 @@ lib/ git.ts, github.ts, registries.ts # registries.ts: npm/PyPI/Cargo package β†’ source-repo URL types/ db.ts # shared row-shape types for lib/db.ts (RepoRow, LeaderboardRow, …) + live-score/ # on-the-fly scoring: host tree API β†’ materialized dir β†’ scoreRepo + hosts.ts # per-host tree listing / raw / blob URLs + MAX_ENTRIES guard + materialize.ts # build a scoreable dir; symlinks reproduced, never repaired + content-files.ts # the ~14 paths whose *bytes* a signal reads + supported.ts # SUPPORTED_HOSTS β€” client-importable (no node:fs) + recents.ts # localStorage read/write for the visitor's own scores + score.ts # liveScore(): commit resolve + metadata + materialize + scoreRepo package-lookup.ts # shared registry β†’ repo lookup (used by /api/package + /package page) badge-adoption.ts # detectBadgeEmbed β€” reads the cloned README for an embedded AFC badge (dashboard metadata, NOT a scored signal; never vendored to siblings) db.ts # better-sqlite3 schema + queries @@ -101,8 +115,10 @@ lib/ changelog.ts # typed ChangelogEntry[] roadmap.ts # typed RoadmapVersion[] skill-content.ts # SKILL_FAQ + SCORE_BANDS + hook snippets β€” content for /skill page + release-notice.ts # localStorage "seen" marker for the home-page release announcement scripts/ init-db.ts, score.ts, seed.ts, seed-list.ts, seed-packages.ts (auto-runs after seed.ts) + parity-check.ts # asserts the live-score path equals `bun run score`. CI gate: .github/workflows/parity.yml audit-seeds.ts # flags seeds that are forks / mirrors / archived / renamed / gone. # Needs a valid GITHUB_TOKEN β€” unauthenticated it covers ~60 repos/hr. tests/ @@ -112,6 +128,7 @@ tests/ scorer.test.ts # scoreRepo, topImprovements badge-adoption.test.ts # detectBadgeEmbed β€” README badge-embed detection path-resolution.test.ts # firstExisting / resolveRelative / resolveAllRelative β€” case-insensitive lookup + live-score.test.ts # content-candidate coverage vs the signals, path traversal, host URLs signals/ # one *.test.ts per signal tasks/ README.md @@ -121,7 +138,8 @@ tasks/ 0.4.0/ # released β€” credible scores + discoverability (docs-cited rationales + agent-specific signals + About/llms.txt/OG) 0.5.0/ # released β€” quick wins (PR score-diff action + agent skill) 0.6.0/ # released β€” auto-refresh (scheduled rescoring) - 0.7.0/ # planned β€” maintainer ownership + at-scale discovery (OAuth opt-out + package overlay at scale) + 0.7.0/ # released β€” Live Score (tree materializer + parity harness + live score pages + release notice) + 0.8.0/ # planned β€” maintainer ownership + at-scale discovery (OAuth opt-out + package overlay at scale) 1.0.0/ # planned β€” production cut (Postgres + at-scale indexing + benchmark harness) .claude/ settings.json # SessionStart + Stop hooks (Stop β†’ hooks/stop-guard.sh) @@ -147,6 +165,7 @@ Keep it that way when adding features. If a component needs data, fetch in the p - **All SQL** lives in `lib/db.ts`. Don't scatter `db.prepare(...)` elsewhere. - **Signal IDs** are stable strings (`agents_md`, `tests`, etc.). Changing one = migration. - **Repo path lookups** in `lib/scoring/signals/` go through `firstExisting` / `resolveRelative` / `resolveAllRelative` in `helpers.ts` β€” never a raw `existsSync(join(repo, …))`. They match case-insensitively because README / LICENSE / CONTRIBUTING casing varies in the wild (`readme.md`, `Readme.md`, `README.MD`); an exact-match lookup scores those files as missing on case-sensitive filesystems, so Linux CI and a macOS dev box disagree on the same commit. `resolveAllRelative` dedupes by resolved path β€” a candidate list carrying two spellings of one file must not count as two hits. +- **Client-side persistence**: components stay presentational, except that a `"use client"` component may read/write `localStorage` in an effect β€” there are no accounts, so per-visitor state has nowhere else to live. The storage access goes in `lib/` (`live-score/recents.ts`, `release-notice.ts`), wrapped in try/catch because private mode throws, and the read happens after mount so the server HTML still matches. - **Tailwind first**, then `@theme` tokens. Avoid inline styles; avoid custom classes unless the pattern is truly repeatable. - **No comments** explaining _what_ the code does. Only comment _why_ β€” the shallow-clone rationale in `lib/clients/git.ts` is the model. - **Brand on UI**: "Agent Friendly Code" (no hyphen). Repo/package slug + GitHub `User-Agent` string: `agent-friendly-code`. @@ -194,7 +213,8 @@ If either sibling isn't present locally, flag it; never silently skip the propag 1. Extend `parseRepoUrl` in `lib/clients/github.ts`. 2. Extend `fetchRepoMeta` with that host's API (use `process.env._TOKEN` if needed). 3. Add a seed URL to the `SEEDS` list in `scripts/seed-list.ts`. -4. Add the label to `lib/constants/hosts.ts`. +4. Add the label and domain to `lib/constants/hosts.ts`. +5. For the live-score path: add tree listing / raw / blob URL builders in `lib/live-score/hosts.ts`, a fixture to `FIXTURES` in `scripts/parity-check.ts`, and only then the host id to `SUPPORTED_HOSTS` in `lib/live-score/supported.ts`. Parity has to be green **before** the host ships β€” a host that lists a tree but scores differently is worse than one that says "coming". Note that `/score/[host]/[owner]/[name]` is a single path segment per field, so a host with nested namespaces (GitLab subgroups) needs a route change too. ## Working from tasks/ @@ -228,15 +248,16 @@ Hooks docs: . ## Security / threat surface (read before changing I/O) - We `git clone --depth 1 --single-branch` arbitrary URLs β€” safe by default. We never run post-clone scripts, never `npm install`, never execute code from the clone. +- `/score/[host]/[owner]/[name]` turns a visitor-supplied slug into host API calls and a `/tmp` directory. Two guards carry that: the `SLUG` regex on the route (host slug alphabet β€” everything else is a probe, and each miss costs a tree-API call), and `safeAbsolute` in `lib/live-score/materialize.ts`, which is the only thing between an attacker-chosen tree path and the filesystem. Both are load-bearing; `tests/live-score.test.ts` covers the traversal cases. Fetched bytes are written to disk and read back by the scorer β€” never executed. - SQL: all queries parameterised. No interpolation. -- HTML: React auto-escapes. The only `dangerouslySetInnerHTML` is server-built JSON-LD with `<` escaped to `<` (`app/layout.tsx`, `app/page.tsx`, `app/about/page.tsx`, `app/action/page.tsx`, `app/skill/page.tsx`, `app/methodology/page.tsx`, `app/package/[registry]/[name]/page.tsx`, `app/repo/[id]/page.tsx`, plus the `BreadcrumbJsonLd` component used by About / Changelog / Methodology / Packages / Privacy / Roadmap / Terms); never feed user-controlled strings into it. +- HTML: React auto-escapes. The only `dangerouslySetInnerHTML` is server-built JSON-LD with `<` escaped to `<` (`app/layout.tsx`, `app/page.tsx`, `app/about/page.tsx`, `app/action/page.tsx`, `app/skill/page.tsx`, `app/score/page.tsx`, `app/methodology/page.tsx`, `app/package/[registry]/[name]/page.tsx`, `app/repo/[id]/page.tsx`, plus the `BreadcrumbJsonLd` component used by About / Changelog / Methodology / Packages / Privacy / Roadmap / Terms); never feed user-controlled strings into it. - Local-path mode reads files; never writes outside `data/` and the clone workspace passed to `shallowClone`. -- No auth yet (read-only dashboard). When auth lands (`tasks/0.7.0/01-opt-out-claim-flow.md`), do it via OAuth and gate DB writes per user. +- No auth yet (read-only dashboard). When auth lands (`tasks/0.8.0/01-opt-out-claim-flow.md`), do it via OAuth and gate DB writes per user. **Operational concerns** (not code-level security) worth flagging before public launch: -- The clone workspace can fill disk β€” add a cron/cap. -- Unauthenticated API β†’ add rate limits before going public. +- The clone workspace lives in the OS temp dir and each clone is removed after it is scored (`scripts/score.ts`); a crashed run can still leave one behind. +- Unauthenticated API β†’ add rate limits before going public. `/score/*` is the expensive one: an uncached slug costs a tree listing plus a burst of raw fetches against the shared `GITHUB_TOKEN` quota. ISR absorbs repeats, not breadth. - Sandbox the cloner in a container when running on remote infra, just in case of future git CVEs. ## Things to leave alone diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8a2083..d1cb464 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,9 @@ Don't squash-amend published commits. Don't skip hooks (`--no-verify`); if a hoo 3. **test** β€” `bun run test` when any `*.{ts,tsx}` file is staged. Runs the full `node --test` suite (~1–2 s); blocks on regressions. 4. **file-length** β€” blocks staged `.ts`/`.tsx` under `app/`, `components/`, `lib/` that exceed 300 lines. Split into subcomponents or pull helpers into `lib/utils/`. `scripts/` is exempt. -Run `bun run prepare-hooks` once after cloning. CI (`.github/workflows/`) runs the same checks on PR for belt-and-braces. +Run `bun run prepare-hooks` once after cloning. CI (`.github/workflows/ci.yml`) runs the same checks on PR for belt-and-braces. + +One extra workflow fires only when it has to: `.github/workflows/parity.yml` runs `bun run parity-check --pr` when a PR touches `lib/scoring/`, `lib/live-score/`, `lib/clients/git.ts` or `lib/badge-adoption.ts`. It clones a handful of real repos and asserts the live-score path produces the same numbers as `bun run score`, so it takes minutes rather than seconds β€” the full fixture set runs nightly. If it reports a diff, the score shown on `/score/…` and the score on the leaderboard have drifted apart; fix that before merging rather than re-running. ## PR workflow diff --git a/README.md b/README.md index 6d78064..2a1b70d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Agent Friendly Code -[![Release](https://img.shields.io/badge/release-0.6.0-blue?style=flat-square)](./lib/changelog.ts) +[![Release](https://img.shields.io/badge/release-0.7.0-blue?style=flat-square)](./lib/changelog.ts) [![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) [![Next.js 16](https://img.shields.io/badge/Next.js-16-black?style=flat-square)](https://nextjs.org) [![Node β‰₯20.9](https://img.shields.io/badge/node-%E2%89%A520.9-43853d?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org) @@ -9,7 +9,7 @@ **A public dashboard that ranks open-source repos by how friendly they are for AI coding agents β€” per model.** -Next.js 16 + SQLite (`better-sqlite3`), styled with Tailwind CSS 4. Spans GitHub, GitLab, and Bitbucket out of the box. Current release: **0.6.0**. +Next.js 16 + SQLite (`better-sqlite3`), styled with Tailwind CSS 4. Spans GitHub, GitLab, and Bitbucket out of the box. Current release: **0.7.0**. ![Agent Friendly Code β€” leaderboard](./public/demo/light.png) @@ -62,9 +62,9 @@ Not pretending the idea is free of risk: - **Per-model scoring is the hardest part and the easiest to fake.** Per-model rationales are now sourced from each agent's published docs (see `MODELS[].sources` in `lib/scoring/weights.ts`), but the weight values themselves are still pre-benchmark. Real "Claude ranks this higher than GPT-5" requires actually running each agent on each repo. That's `tasks/1.0.0/03-benchmark-harness.md`. - **Factory.ai is already in this space.** Differentiation has to stay sharp. -- **Public-shaming risk.** Ranking #47,823 without consent invites angry maintainers. Planned via `tasks/0.7.0/01-opt-out-claim-flow.md`. +- **Public-shaming risk.** Ranking #47,823 without consent invites angry maintainers. Planned via `tasks/0.8.0/01-opt-out-claim-flow.md`. - **Score gaming.** Once public, people add boilerplate `AGENTS.md` to pass the rubric without being useful. Dynamic (actually-run-an-agent) checks are the counter β€” see benchmark harness. -- **Freshness.** Scores decay with every push. A 6-hourly GitHub Actions cron rescores the curated set; webhook-driven sub-minute refresh is deferred until the claim flow lands in 0.7.0. +- **Freshness.** Scores decay with every push. A 6-hourly GitHub Actions cron rescores the curated set; webhook-driven sub-minute refresh is deferred until the claim flow lands in 0.8.0. See `/methodology` in the running app for a candid walkthrough of what's measured today and what isn't. @@ -84,7 +84,7 @@ Short answer: **low risk**. The app: - Rate limiting the public API. - Sandbox the cloner in a container (future-proofing against hypothetical git CVEs). -Auth and per-maintainer controls land with the opt-out / claim flow in v0.7.0. +Auth and per-maintainer controls land with the opt-out / claim flow in v0.8.0. ## Quickstart @@ -110,7 +110,7 @@ Run the unit tests with `bun run test` (uses `node --test` + `tsx`; requires Nod ## Versioning -`lib/version.ts` and `package.json` carry the current release number (currently **0.6.0**). Bumps happen only when we actually cut a release β€” never when merging intermediate work. The version pill in the header surfaces the number directly; `/changelog` lists what each release shipped. +`lib/version.ts` and `package.json` carry the current release number (currently **0.7.0**). Bumps happen only when we actually cut a release β€” never when merging intermediate work. The version pill in the header surfaces the number directly; `/changelog` lists what each release shipped. ## Stack & rationale @@ -125,11 +125,14 @@ Run the unit tests with `bun run test` (uses `node --test` + `tsx`; requires Nod | **Exact-pinned deps** | Deterministic scoring across environments. | Never. | | **One file per signal** | Each signal is a small, independent concern β€” keeps `git log` and code review focused. | When we bundle signals into dynamic checks (then the unit becomes the bundle). | -## Why do we clone at all (instead of host APIs)? +## Why clone for the batch path, but not for live scoring? -- Static signals need to read **file contents** (AGENTS.md length, `pyproject.toml [tool.X]` sections, package.json scripts count) β€” not just existence. -- One clone is faster than N API calls for content-heavy scoring, and respects rate limits. -- Any real version of this dashboard needs dynamic signals (run tests, run an agent). Those absolutely need code on disk. +Both paths run the **same** `scoreRepo()` against a real directory β€” they differ only in how that directory is produced. + +- **Batch (`bun run score`, the 6-hourly rescore)** clones. One `git clone --depth 1` is a single uniform substrate across GitHub, GitLab and Bitbucket, needs no token, and puts code on disk for the dynamic signals a benchmark harness will eventually need. +- **Live (`/score/…`)** can't clone β€” a Vercel function has no `git` binary β€” so it materializes the tree from the host API instead: every path present, real bytes fetched only for the ~14 files a signal actually reads. `scripts/parity-check.ts` asserts the two produce identical scores. + +Note what it deliberately does **not** use: the host tarball endpoints. Those run `git archive`, which honors `export-ignore` in `.gitattributes`, so an archive reflects a release rather than the repository β€” measured at 7.4% of repos scoring differently. See `tasks/0.7.0/01-tree-materializer.md`. ## Layout @@ -138,6 +141,7 @@ app/ Next.js App Router β€” pages + API + SEO layout.tsx root layout, root metadata (OG + Twitter cards) page.tsx leaderboard repo/[id]/ repo detail (generateMetadata + per-repo OG image) + score/ Live Score β€” entry form + /score/[host]/[owner]/[name] result page (cached 1h per repo) methodology/ how scoring works today roadmap/ upcoming versions (from lib/roadmap.ts) changelog/ what's shipped (from lib/changelog.ts) @@ -148,7 +152,7 @@ app/ Next.js App Router β€” pages + API + SEO skill/ agent-skill explainer + install command package/ registry β†’ repo lookup (form + per-package state pages) api/ /repos, /repo/[id], /score, /badge///, /package// - robots.ts /robots.txt β€” allows "/", blocks "/api/" + robots.ts /robots.txt β€” allows "/", blocks "/api/" and "/score/" (unbounded URL space) sitemap.ts /sitemap.xml β€” static routes + every repo llms.txt/ markdown manifest for LLM crawlers globals.css Tailwind import + @theme tokens @@ -162,7 +166,7 @@ lib/ package-lookup.ts shared registry β†’ repo lookup (used by /api/package + /package page) version.ts app + sibling URLs, install snippets (ACTION_USES, SKILL_INSTALL_CMD), SIBLING_VERSION pin changelog.ts / roadmap.ts / skill-content.ts -scripts/ CLI entries run via `tsx` (Node) β€” score, seed, init-db +scripts/ CLI entries run via `tsx` (Node) β€” score, seed, init-db, audit-seeds, parity-check tests/ `node --test` unit tests β€” scorer, signals, URL parser, formatters tasks/ Per-version task breakdown (agent-readable) public/ Static assets β€” demo/ screenshots used by the README + OG image @@ -174,6 +178,12 @@ CLAUDE.md Pointer β†’ AGENTS.md LICENSE MIT ``` +## Live Score + +[`/score`](https://www.agentfriendlycode.com/score) takes any public GitHub repository URL and returns its full score β€” signals, per-model breakdown, and the gaps worth fixing first β€” for repos the leaderboard has never indexed. Results are computed from the repository's current commit, and cached for an hour per repo; nothing about a scored repo is stored. Repos already on the board redirect to their canonical `/repo/:id` page. + +GitLab and Bitbucket are implemented and score identically to a clone, but ship behind a "support coming" state: GitLab paginates its tree at 100 entries (a large project needs hundreds of sequential calls) and Bitbucket allows 60 unauthenticated API requests an hour. + ## Companion: PR-diff GitHub Action [`hsnice16/agent-friendly-action`](https://github.com/hsnice16/agent-friendly-action) runs the same scorer inside your CI and posts a per-PR score-delta comment β€” _"this PR drops your Claude Code score by 4.1 points because it removed CI config."_ Opt-in via an `AGENTS_BADGE_TOKEN` secret; falls through silently when unset. Each repo detail page on the dashboard ships a copy-paste workflow snippet under "Catch score regressions on every PR". @@ -200,7 +210,7 @@ See `/roadmap` in the running app or the per-version `tasks/` folders for the fu Versions are sequenced cheap-first so the highest-impact small additions don't get gated on heavy infra: -- **0.7.0 β€” maintainer ownership + at-scale discovery**: OAuth opt-out / claim flow for maintainers + at-scale package overlay (per-registry leaderboards + userscript that renders the badge inline on npmjs.com / PyPI / crates.io). +- **0.8.0 β€” maintainer ownership + at-scale discovery**: OAuth opt-out / claim flow for maintainers + at-scale package overlay (per-registry leaderboards + userscript that renders the badge inline on npmjs.com / PyPI / crates.io). - **1.0.0 β€” production cut**: Postgres migration for concurrent writers + auto-discovered crawl (target 10k repos) + benchmark harness that derives per-model weights from measured agent success. From here on, breaking API changes require a MAJOR bump. ## Defensibility diff --git a/app/api/repo/[id]/route.ts b/app/api/repo/[id]/route.ts index 40ba3f0..add34d0 100644 --- a/app/api/repo/[id]/route.ts +++ b/app/api/repo/[id]/route.ts @@ -3,6 +3,8 @@ import { getModelScores, getRepo, getSignalResults } from "@/lib/db"; export const dynamic = "force-dynamic"; +const HEADERS = { "Cache-Control": "public, max-age=3600, s-maxage=3600" }; + export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) { const { id: idStr } = await ctx.params; const id = Number(idStr); @@ -16,9 +18,12 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> return NextResponse.json({ error: "not found" }, { status: 404 }); } - return NextResponse.json({ - repo, - signals: getSignalResults(id), - modelScores: getModelScores(id), - }); + return NextResponse.json( + { + repo, + signals: getSignalResults(id), + modelScores: getModelScores(id), + }, + { headers: HEADERS }, + ); } diff --git a/app/api/repos/route.ts b/app/api/repos/route.ts index f0a2a45..02f360e 100644 --- a/app/api/repos/route.ts +++ b/app/api/repos/route.ts @@ -3,6 +3,10 @@ import { listLeaderboardOverall } from "@/lib/db"; export const dynamic = "force-dynamic"; +// `data/rank.db` ships inside the deployment, so this response cannot change +// until the next deploy. Uncached, every caller re-serialises the whole table. +const HEADERS = { "Cache-Control": "public, max-age=3600, s-maxage=3600" }; + export async function GET() { - return NextResponse.json(listLeaderboardOverall()); + return NextResponse.json(listLeaderboardOverall(), { headers: HEADERS }); } diff --git a/app/api/score/route.ts b/app/api/score/route.ts index ca857b3..b6985ca 100644 --- a/app/api/score/route.ts +++ b/app/api/score/route.ts @@ -5,6 +5,8 @@ import { getModelScores, getRepoByHostOwnerName, getSignalResults } from "@/lib/ export const dynamic = "force-dynamic"; +const HEADERS = { "Cache-Control": "public, max-age=3600, s-maxage=3600" }; + export async function GET(req: Request) { const url = new URL(req.url); const repoParam = url.searchParams.get("repo"); @@ -29,9 +31,12 @@ export async function GET(req: Request) { return NextResponse.json({ error: "not_indexed" }, { status: 404 }); } - return NextResponse.json({ - repo, - signals: getSignalResults(repo.id), - modelScores: getModelScores(repo.id), - }); + return NextResponse.json( + { + repo, + signals: getSignalResults(repo.id), + modelScores: getModelScores(repo.id), + }, + { headers: HEADERS }, + ); } diff --git a/app/globals.css b/app/globals.css index 7ba207a..4f11326 100644 --- a/app/globals.css +++ b/app/globals.css @@ -32,6 +32,19 @@ --font-mono: ui-monospace, "SF Mono", Menlo, monospace; --radius-card: 10px; + + --animate-pop-in: afc-pop-in 200ms ease-out; + + @keyframes afc-pop-in { + from { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + to { + opacity: 1; + transform: none; + } + } } /* Dark theme β€” same token names, different values. */ diff --git a/app/layout.tsx b/app/layout.tsx index 4e045b2..fa44ea4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -104,9 +104,11 @@ export const viewport: Viewport = { colorScheme: "light dark", }; +// Alphabetical by label β€” a new entry has exactly one place to go. const NAV_LINKS = [ - { href: "/action", label: "GitHub Action" }, { href: "/skill", label: "Agent Skill" }, + { href: "/action", label: "GitHub Action" }, + { href: "/score", label: "Live Score" }, { href: "/methodology", label: "Methodology" }, ]; @@ -121,6 +123,7 @@ const FOOTER_LINKS_PRIMARY = [ const FOOTER_LINKS_TOOLS = [ { href: "/skill", label: "Agent Skill" }, { href: "/action", label: "GitHub Action" }, + { href: "/score", label: "Live Score" }, { href: "/package", label: "Packages" }, ]; @@ -172,7 +175,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
-
{children}
+
+ {children} +

Signals are static heuristics β€” no agent is actually run. Per-model rationales are docs-cited; the weight diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index 46c6a74..37c9487 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -52,6 +52,7 @@ ${MODELS.map((m) => `- ${m.label} β€” ${m.rationale}`).join("\n")} - [Changelog](${APP_URL}/changelog): What shipped per release - [Methodology](${APP_URL}/methodology): How scores are computed; signals, weights, and limitations - [Package lookup](${APP_URL}/package): Resolve npm / PyPI / Cargo packages to their source-repo agent-friendliness score +- [Live Score](${APP_URL}/score): Score any public GitHub repository on demand, including repos the leaderboard has not indexed - [Roadmap](${APP_URL}/roadmap): Upcoming versions - [Skill](${APP_URL}/skill): Portable agent skill β€” install snippet, score β†’ model mapping, optional SessionStart hooks for Claude Code / Codex - [Sitemap](${APP_URL}/sitemap.xml): Every indexed URL diff --git a/app/page.tsx b/app/page.tsx index 037773a..196cd46 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,9 +9,11 @@ import { HostSelect } from "@/components/HostSelect"; import { Medal } from "@/components/Medal"; import { ModelPills } from "@/components/ModelPills"; import { Pagination } from "@/components/Pagination"; +import { ReleaseAnnouncement } from "@/components/ReleaseAnnouncement"; import { ScoreCell } from "@/components/ScoreCell"; import { SearchBar } from "@/components/SearchBar"; import { SortSelect } from "@/components/SortSelect"; +import { CHANGELOG } from "@/lib/changelog"; import { type Host, isHost } from "@/lib/constants/hosts"; import { LEADERBOARD_PAGE_SIZE, LEADERBOARD_PAGE_SIZE_MOBILE } from "@/lib/constants/scoring"; import { DEFAULT_DIR, DEFAULT_SORT, isSortDir, isSortKey, type SortDir, type SortKey } from "@/lib/constants/sort"; @@ -19,7 +21,7 @@ import { getLeaderboardStats, listLeaderboard, listLeaderboardOverall } from "@/ import { MODEL_BY_ID, MODELS, type ModelId } from "@/lib/scoring/weights"; import type { LeaderboardRow } from "@/lib/types/db"; import { compactStars, relativeTime } from "@/lib/utils/format"; -import { OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; +import { APP_VERSION, OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; const HOME_TITLE = "Agent Friendly Code β€” AI coding agent friendliness leaderboard for Claude Code, Cursor, Devin, Codex, Gemini, Kimi, Aider, OpenHands, Pi"; @@ -126,6 +128,13 @@ export default async function Page({ searchParams }: { searchParams: Promise + + {/* Announced only once the release it describes is the one deployed β€” + otherwise a version bump ahead of the changelog entry (or behind it) + would advertise the wrong thing. */} + {CHANGELOG[0]?.label === APP_VERSION && ( + + )}

Which public repos are friendliest to an AI coding agent? @@ -144,6 +153,17 @@ export default async function Page({ searchParams }: { searchParams: Promise{" "} by name.

+ +

+ Repo not on the board?{" "} + + Score any public GitHub repo live + {" "} + from its current commit. +

s.pass >= 1).slice(0, STRENGTHS_GAPS_VISIBLE_LIMIT); const gaps = signals.filter((s) => s.pass === 0).slice(0, STRENGTHS_GAPS_VISIBLE_LIMIT); @@ -183,7 +183,7 @@ export default async function Page({
- +
diff --git a/app/robots.ts b/app/robots.ts index c7fc56f..28c486a 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -22,12 +22,16 @@ const AI_CRAWLERS = [ "Meta-ExternalAgent", ]; +// /score/* is unbounded β€” one URL per repo that exists anywhere. Canonical +// repo pages live at /repo/:id and are the only ones in the sitemap. +const DISALLOW = ["/api/", "/score/"]; + export default function robots(): MetadataRoute.Robots { return { sitemap: `${APP_URL}/sitemap.xml`, rules: [ - { userAgent: "*", allow: "/", disallow: "/api/" }, - { userAgent: AI_CRAWLERS, allow: "/", disallow: "/api/" }, + { userAgent: "*", allow: "/", disallow: DISALLOW }, + { userAgent: AI_CRAWLERS, allow: "/", disallow: DISALLOW }, ], }; } diff --git a/app/score/[host]/[owner]/[name]/error.tsx b/app/score/[host]/[owner]/[name]/error.tsx new file mode 100644 index 0000000..37f5ace --- /dev/null +++ b/app/score/[host]/[owner]/[name]/error.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Link from "next/link"; +import { Panel } from "@/components/Panel"; + +// Reached when scoring failed for a reason that could resolve on its own β€” a +// host rate limit, a timeout. It exists so page.tsx can throw instead of +// rendering an apology: a throw is never cached, so the failure is not served +// to everyone else for the next hour. +export default function LiveScoreError({ reset }: { error: Error; reset: () => void }) { + return ( + +

Couldn't score this repo right now

+

+ Nothing is necessarily wrong with the repository β€” the host's API may be rate-limiting us, or the tree took + too long to read. Trying again usually works. +

+ +
+ + + ← score another repo + +
+
+ ); +} diff --git a/app/score/[host]/[owner]/[name]/page.tsx b/app/score/[host]/[owner]/[name]/page.tsx new file mode 100644 index 0000000..7d94854 --- /dev/null +++ b/app/score/[host]/[owner]/[name]/page.tsx @@ -0,0 +1,205 @@ +import type { Metadata } from "next"; +import { unstable_cache } from "next/cache"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { AlternativesStrip } from "@/components/AlternativesStrip"; +import { ModelSuggestions } from "@/components/ModelSuggestions"; +import { Panel, PanelHeading } from "@/components/Panel"; +import { PerModelScores } from "@/components/PerModelScores"; +import { RecordScore } from "@/components/RecordScore"; +import { RepoHero } from "@/components/RepoHero"; +import { SignalListCard } from "@/components/SignalListCard"; +import { SignalRow } from "@/components/SignalRow"; +import { type ParsedRepo, parseRepoUrl } from "@/lib/clients/github"; +import { HOST_DOMAINS, isHost } from "@/lib/constants/hosts"; +import { ALTERNATIVES_LIMIT, STRENGTHS_GAPS_VISIBLE_LIMIT } from "@/lib/constants/scoring"; +import { getAlternativesFor, getRepoByHostOwnerName } from "@/lib/db"; +import { TooLargeError } from "@/lib/live-score/hosts"; +import { liveScore } from "@/lib/live-score/score"; +import { SUPPORTED_HOSTS } from "@/lib/live-score/supported"; +import { topImprovements } from "@/lib/scoring/scorer"; +import { MODEL_BY_ID, type ModelId } from "@/lib/scoring/weights"; +import type { RepoRow } from "@/lib/types/db"; +import { hostLabel } from "@/lib/utils/format"; +import { OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; + +// Hobby defaults to 10s; a large tree needs more. +export const maxDuration = 60; + +const CACHE_SECONDS = 3600; + +type Params = { host: string; owner: string; name: string }; + +// Reading `searchParams` for `?model=` makes this route dynamic, so a +// segment-level `revalidate` would never cache the render β€” every visit would +// re-list the tree and re-score. Cache the expensive half explicitly instead, +// keyed by repo and not by model: the score is the same for all of them, and +// the result is deterministic given (commit, weights), so an hour of staleness +// costs nothing. A throw is not cached, which is what keeps a transient host +// failure from being pinned here for the hour. +function cachedLiveScore(parsed: ParsedRepo) { + return unstable_cache(() => liveScore(parsed), ["live-score", parsed.host, parsed.owner, parsed.name], { + revalidate: CACHE_SECONDS, + })(); +} + +// Every miss here is a tree-API call and a cold function, and the route is +// crawler-reachable regardless of robots.txt. GitHub, GitLab and Bitbucket all +// restrict slugs to this alphabet, so anything else is a probe. +const SLUG = /^[A-Za-z0-9._-]+$/; + +export async function generateMetadata({ params }: { params: Promise }): Promise { + const { host, owner, name } = await params; + const title = `${owner}/${name} β€” Live Score`; + + return { + title, + description: `On-demand agent-friendliness score for ${owner}/${name}, computed from its current commit.`, + twitter: { ...TWITTER_DEFAULTS, title }, + alternates: { canonical: `/score/${host}/${owner}/${name}` }, + // Unbounded URL space; robots.ts disallows /score/* and the sitemap stays on /repo/:id. + robots: { index: false, follow: true }, + openGraph: { ...OG_DEFAULTS, title, url: `/score/${host}/${owner}/${name}`, type: "website" }, + }; +} + +function Unsupported({ host }: { host: string }) { + const label = hostLabel(host); + + return ( + +

{label} support is coming

+

+ {`Scoring works for ${label} repositories, but its API needs guards this page doesn't have yet β€” a wrong score would be worse than none. GitHub repositories work today.`} +

+ + ← try a GitHub repo + +
+ ); +} + +function Unavailable({ reason }: { reason: string }) { + return ( + +

Couldn't score this repo

+

{reason}

+ + ← try another repo + +
+ ); +} + +export default async function LiveScorePage({ + params, + searchParams, +}: { + params: Promise; + searchParams: Promise<{ model?: string }>; +}) { + const { host, owner, name } = await params; + const { model } = await searchParams; + + if (!isHost(host) || !SLUG.test(owner) || !SLUG.test(name)) notFound(); + + const parsed = parseRepoUrl(`https://${HOST_DOMAINS[host]}/${owner}/${name}`); + if (!parsed || parsed.host !== host) notFound(); + + // Indexed repos get the canonical page: better SEO, and it absorbs the popular + // repos that are also the most expensive to score cold. + const indexed = getRepoByHostOwnerName(host, owner, name); + if (indexed) redirect(`/repo/${indexed.id}`); + + if (!SUPPORTED_HOSTS.includes(parsed.host)) return ; + + let score: Awaited>; + try { + score = await cachedLiveScore(parsed); + } catch (err) { + if (err instanceof TooLargeError) { + return ( + + ); + } + // Everything else is transient β€” a rate limit, a host blip. Let it reach + // error.tsx rather than rendering an apology: a throw is never cached, so + // the failure isn't served to everyone else for the next hour. Too-large is + // the exception above; it is stable, so rendering (and caching) it is right. + throw err; + } + + if (!score) { + return ; + } + + const selected: ModelId = model && model in MODEL_BY_ID ? (model as ModelId) : "claude-code"; + const suggestions = topImprovements(selected, score.signals); + const strengths = score.signals.filter((s) => s.pass >= 1).slice(0, STRENGTHS_GAPS_VISIBLE_LIMIT); + const gaps = score.signals.filter((s) => s.pass === 0).slice(0, STRENGTHS_GAPS_VISIBLE_LIMIT); + const alternatives = getAlternativesFor(host, score.language, selected, ALTERNATIVES_LIMIT); + + const repo: RepoRow = { + id: -1, + host, + name, + owner, + url: parsed.canonicalUrl, + stars: score.stars, + language: score.language, + last_scored_at: null, + overall_score: score.overall, + previous_overall_score: null, + default_branch: score.defaultBranch, + badge_embedded: score.badgeEmbedded ? 1 : 0, + }; + + return ( + <> + + ← score another repo + + + + + + +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + Signal breakdown + {score.signals.map((s) => ( + + ))} + +
+ + ); +} diff --git a/app/score/opengraph-image.tsx b/app/score/opengraph-image.tsx new file mode 100644 index 0000000..05f4fe0 --- /dev/null +++ b/app/score/opengraph-image.tsx @@ -0,0 +1,67 @@ +import { ImageResponse } from "next/og"; + +import { APP_NAME } from "@/lib/version"; + +export const contentType = "image/png"; +export const alt = `${APP_NAME} β€” Live Score: any public GitHub repo, on demand`; +export const size = { width: 1200, height: 630 }; + +export default function Image() { + return new ImageResponse( +
+
+
+ A +
+
{APP_NAME}
+
+ +
+
+ Live Score +
+ +
+ Paste a public GitHub URL β€” scored from its current commit, per model. +
+
+ +
+
agentfriendlycode.com/score
+
+
, + size, + ); +} diff --git a/app/score/page.tsx b/app/score/page.tsx new file mode 100644 index 0000000..70fe605 --- /dev/null +++ b/app/score/page.tsx @@ -0,0 +1,177 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { LiveScoreForm } from "@/components/LiveScoreForm"; +import { Panel, PanelHeading } from "@/components/Panel"; +import { RecentScores } from "@/components/RecentScores"; +import { listLeaderboardOverall } from "@/lib/db"; +import { APP_KEYWORDS, APP_URL, OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; + +const PAGE_TITLE = "Live Score β€” on-demand AI agent-friendliness check for any GitHub repository"; +const PAGE_DESCRIPTION = + "Paste any public GitHub repository URL and get its agent-friendliness score on demand β€” overall, per model (Claude Code, Cursor, Devin, GPT-5 Codex, Gemini CLI, Kimi CLI, Aider, OpenHands, Pi), with the gaps worth fixing first. Scored from the current commit, no sign-up, nothing stored."; + +const PAGE_KEYWORDS = [ + ...APP_KEYWORDS, + "score a repo", + "live repo score", + "score any github repo", + "check repo ai readiness", + "agent friendliness checker", + "is my repo agent friendly", + "AGENTS.md checker", + "repo agent readiness test", +]; + +export const metadata: Metadata = { + title: PAGE_TITLE, + keywords: PAGE_KEYWORDS, + description: PAGE_DESCRIPTION, + alternates: { canonical: "/score" }, + twitter: { ...TWITTER_DEFAULTS, title: PAGE_TITLE, description: PAGE_DESCRIPTION }, + openGraph: { ...OG_DEFAULTS, title: PAGE_TITLE, description: PAGE_DESCRIPTION, url: "/score", type: "website" }, +}; + +type FaqEntry = { + q: string; + a: string; + /** Turns one phrase of `a` into a link. `a` stays the plain-text source the JSON-LD needs. */ + link?: { phrase: string; href: string }; +}; + +const FAQ: FaqEntry[] = [ + { + q: "Does the repo have to be on the leaderboard?", + a: "No β€” that is the point of this page. Paste any public GitHub repository URL and it is scored on the spot, whether or not we have ever indexed it. Repos already on the leaderboard redirect to their permanent page instead, which carries the same numbers plus score history.", + }, + { + q: "How is this different from the leaderboard's own scores?", + a: "It is not. Both run the same scorer over the same signals and weights; they differ only in how the files get there. The leaderboard clones each repo on a six-hourly cron, while this page reconstructs the repository from GitHub's tree API at request time. A CI job asserts the two produce identical numbers on a fixture set chosen for the ways they could disagree.", + }, + { + q: "Is my repository stored or listed anywhere?", + a: "No. A live score is computed, rendered, and discarded β€” nothing about the repository is written to the database, and scoring a repo never adds it to the public leaderboard. The result page is marked noindex, so it will not turn up in search. The list of repos you have scored is kept in your own browser and never sent anywhere.", + }, + { + q: "How fresh is the score?", + a: "It is computed from the repository's current commit, and the page shows the short SHA it used. Results are cached per URL for an hour, so a push made minutes ago may not be reflected until the cache turns over.", + }, + { + q: "Does it work on private repos, GitLab, or Bitbucket?", + a: "Public GitHub repositories only, for now. Private repos would need authorization we deliberately do not ask for. GitLab and Bitbucket are implemented and score identically to a clone, but are held back until their API limits are guarded β€” GitLab paginates its tree at 100 entries and Bitbucket allows 60 unauthenticated requests an hour. To score a private repo today, run the agent skill locally instead.", + link: { phrase: "the agent skill", href: "/skill" }, + }, + { + q: "Why did a very large repository refuse to score?", + a: "Reconstructing a tree of several hundred thousand entries would take longer than a request can run, so past a ceiling the page declines rather than timing out or returning a partial score. The agent skill scores those locally with no such limit.", + link: { phrase: "The agent skill", href: "/skill" }, + }, +]; + +const JSON_LD = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Home", item: `${APP_URL}/` }, + { "@type": "ListItem", position: 2, name: "Live Score", item: `${APP_URL}/score` }, + ], + }, + { + "@type": "WebApplication", + "@id": `${APP_URL}/score#app`, + url: `${APP_URL}/score`, + name: "Live Score", + isAccessibleForFree: true, + description: PAGE_DESCRIPTION, + publisher: { "@id": `${APP_URL}/#org` }, + operatingSystem: "Any", + applicationCategory: "DeveloperApplication", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + browserRequirements: "Requires JavaScript-enabled modern browser", + }, + { + "@type": "FAQPage", + mainEntity: FAQ.map((entry) => ({ + "@type": "Question", + name: entry.q, + acceptedAnswer: { "@type": "Answer", text: entry.a }, + })), + }, + ], +}; + +// Splitting the rendered answer rather than storing a second, marked-up copy: +// the JSON-LD needs plain text, and two copies of the same sentence is one copy +// too many. A phrase that stops matching degrades to plain text, not a crash. +function Answer({ entry }: { entry: FaqEntry }) { + const at = entry.link ? entry.a.indexOf(entry.link.phrase) : -1; + if (!entry.link || at === -1) return <>{entry.a}; + + return ( + <> + {entry.a.slice(0, at)} + + {entry.link.phrase} + + {entry.a.slice(at + entry.link.phrase.length)} + + ); +} + +const EXAMPLES_SHOWN = 10; + +export default function ScoreIndexPage() { + // Shown alongside the visitor's own scores, not replaced by them β€” otherwise + // the curated list vanishes the moment someone scores anything. + const examples = listLeaderboardOverall().slice(0, EXAMPLES_SHOWN); + + return ( + <> +