From a7646a32d396b9a5c117c564a3e430b7e90298cd Mon Sep 17 00:00:00 2001
From: Himanshu Singh
Date: Tue, 25 Aug 2026 16:36:15 +0530
Subject: [PATCH 1/6] Add on-demand scoring for any public GitHub repo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/score takes a repository URL and returns the full score β signals,
per-model breakdown, suggestions β for repos the leaderboard has never
indexed. Indexed repos redirect to their canonical /repo/:id page.
A Vercel function has no git binary, so the live path materializes the
tree from the host API instead of cloning: every path present, real bytes
only for the ~14 files a signal reads. Both paths then run the same
scoreRepo(), and scripts/parity-check.ts asserts they agree β a signal
that starts reading a new file would otherwise score as empty on the live
path only, with no error and a wrong number on a public page.
Reading searchParams for ?model= makes the route dynamic, so a
segment-level revalidate would never cache the render. The score is
cached explicitly instead, keyed by repo rather than model. Throws are
not cached, so a rate limit or host blip reaches error.tsx rather than
being pinned on a repo for the hour.
GitLab and Bitbucket are implemented and score identically to a clone,
but ship behind "support coming": GitLab paginates its tree at 100
entries and Bitbucket allows 60 unauthenticated requests an hour.
Co-Authored-By: Claude Opus 5 (1M context)
---
.claude/settings.json | 2 +-
.claude/skills/code-review/SKILL.md | 4 +-
.claude/skills/quality-check/SKILL.md | 2 +-
.env.example | 8 +-
.github/workflows/parity.yml | 58 ++++
AGENTS.md | 35 ++-
CONTRIBUTING.md | 4 +-
README.md | 36 ++-
app/layout.tsx | 9 +-
app/llms.txt/route.ts | 1 +
app/repo/[id]/page.tsx | 8 +-
app/robots.ts | 8 +-
app/score/[host]/[owner]/[name]/error.tsx | 33 +++
app/score/[host]/[owner]/[name]/page.tsx | 205 ++++++++++++++
app/score/opengraph-image.tsx | 67 +++++
app/score/page.tsx | 177 ++++++++++++
app/score/twitter-image.tsx | 1 +
app/sitemap.ts | 6 +
components/LiveScoreForm.tsx | 75 +++++
components/ModelSuggestions.tsx | 7 +-
components/RecentScores.tsx | 72 +++++
components/RecordScore.tsx | 16 ++
components/RepoHero.tsx | 21 +-
lib/changelog.ts | 12 +
lib/constants/hosts.ts | 6 +
lib/constants/scoring.ts | 1 +
lib/db.ts | 42 ++-
lib/live-score/content-files.ts | 23 ++
lib/live-score/hosts.ts | 265 ++++++++++++++++++
lib/live-score/materialize.ts | 148 ++++++++++
lib/live-score/recents.ts | 45 +++
lib/live-score/score.ts | 62 ++++
lib/live-score/supported.ts | 10 +
lib/roadmap.ts | 6 +-
lib/version.ts | 2 +-
package.json | 3 +-
scripts/parity-check.ts | 137 +++++++++
tasks/0.3.0/05-package-registry-overlay.md | 6 +-
tasks/0.3.0/README.md | 2 +-
tasks/0.5.0/02-score-diff-on-pr.md | 2 +-
tasks/0.6.0/01-scheduled-rescoring.md | 4 +-
tasks/0.6.0/02-alternatives-v2-embeddings.md | 2 +-
tasks/0.6.0/README.md | 2 +-
tasks/0.7.0/01-tree-materializer.md | 87 ++++++
tasks/0.7.0/02-score-parity-harness.md | 59 ++++
tasks/0.7.0/03-live-score-pages.md | 68 +++++
tasks/0.7.0/README.md | 20 +-
.../{0.7.0 => 0.8.0}/01-opt-out-claim-flow.md | 0
.../02-package-registry-overlay.md | 2 +-
tasks/0.8.0/README.md | 10 +
tasks/1.0.0/03-benchmark-harness.md | 4 +
tests/live-score.test.ts | 85 ++++++
52 files changed, 1893 insertions(+), 77 deletions(-)
create mode 100644 .github/workflows/parity.yml
create mode 100644 app/score/[host]/[owner]/[name]/error.tsx
create mode 100644 app/score/[host]/[owner]/[name]/page.tsx
create mode 100644 app/score/opengraph-image.tsx
create mode 100644 app/score/page.tsx
create mode 100644 app/score/twitter-image.tsx
create mode 100644 components/LiveScoreForm.tsx
create mode 100644 components/RecentScores.tsx
create mode 100644 components/RecordScore.tsx
create mode 100644 lib/live-score/content-files.ts
create mode 100644 lib/live-score/hosts.ts
create mode 100644 lib/live-score/materialize.ts
create mode 100644 lib/live-score/recents.ts
create mode 100644 lib/live-score/score.ts
create mode 100644 lib/live-score/supported.ts
create mode 100644 scripts/parity-check.ts
create mode 100644 tasks/0.7.0/01-tree-materializer.md
create mode 100644 tasks/0.7.0/02-score-parity-harness.md
create mode 100644 tasks/0.7.0/03-live-score-pages.md
rename tasks/{0.7.0 => 0.8.0}/01-opt-out-claim-flow.md (100%)
rename tasks/{0.7.0 => 0.8.0}/02-package-registry-overlay.md (96%)
create mode 100644 tasks/0.8.0/README.md
create mode 100644 tests/live-score.test.ts
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/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
-[](./lib/changelog.ts)
+[](./lib/changelog.ts)
[](./LICENSE)
[](https://nextjs.org)
[](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**.

@@ -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/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/repo/[id]/page.tsx b/app/repo/[id]/page.tsx
index 8acbd38..195703f 100644
--- a/app/repo/[id]/page.tsx
+++ b/app/repo/[id]/page.tsx
@@ -12,7 +12,7 @@ import { RepoHero } from "@/components/RepoHero";
import { SignalListCard } from "@/components/SignalListCard";
import { SignalRow } from "@/components/SignalRow";
-import { STRENGTHS_GAPS_VISIBLE_LIMIT } from "@/lib/constants/scoring";
+import { ALTERNATIVES_LIMIT, STRENGTHS_GAPS_VISIBLE_LIMIT } from "@/lib/constants/scoring";
import { getAlternatives, getModelScores, getRepo, getSignalResults } from "@/lib/db";
import { topImprovements } from "@/lib/scoring/scorer";
import { MODEL_BY_ID, MODELS, type ModelId } from "@/lib/scoring/weights";
@@ -84,9 +84,9 @@ export default async function Page({
const signals = getSignalResults(id);
const modelScores = getModelScores(id);
- const alternatives = getAlternatives(id, selected, 3);
+ const alternatives = getAlternatives(id, selected, ALTERNATIVES_LIMIT);
- const suggestions = topImprovements(selected, signals, 3);
+ const suggestions = topImprovements(selected, signals);
const strengths = signals.filter((s) => 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.
+
+
+
+
+ Try again
+
+
+ β 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(
+
+
+
+
+
+ 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 (
+ <>
+
+
+
+ Live Score
+
+ Paste a public GitHub repository URL for a score based on what an agent can find, read, and run in it β the
+ same numbers the leaderboard uses. Scored on the spot from the current commit, and stored nowhere.
+
+
+
+
+ GitLab and Bitbucket support coming.
+
+
+ ({ id: r.id, host: r.host, owner: r.owner, name: r.name, score: r.score ?? 0 }))}
+ />
+
+
+
+ Questions
+
+
+ {FAQ.map((entry) => (
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/app/score/twitter-image.tsx b/app/score/twitter-image.tsx
new file mode 100644
index 0000000..8f726ce
--- /dev/null
+++ b/app/score/twitter-image.tsx
@@ -0,0 +1 @@
+export { alt, contentType, default, size } from "./opengraph-image";
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 4a4c311..bb0628b 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -19,6 +19,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
lastModified: lastScored,
changeFrequency: "daily",
},
+ {
+ priority: 0.9,
+ url: `${APP_URL}/score`,
+ lastModified: lastScored,
+ changeFrequency: "weekly",
+ },
{
priority: 0.8,
url: `${APP_URL}/package`,
diff --git a/components/LiveScoreForm.tsx b/components/LiveScoreForm.tsx
new file mode 100644
index 0000000..73ea4be
--- /dev/null
+++ b/components/LiveScoreForm.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { ArrowRight } from "@phosphor-icons/react";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+import { parseRepoUrl } from "@/lib/clients/github";
+import { SUPPORTED_HOSTS } from "@/lib/live-score/supported";
+import { hostLabel } from "@/lib/utils/format";
+
+export function LiveScoreForm() {
+ const router = useRouter();
+ const [value, setValue] = useState("");
+ const [error, setError] = useState(null);
+ const [pending, setPending] = useState(false);
+
+ function submit(e: React.FormEvent) {
+ e.preventDefault();
+
+ const parsed = parseRepoUrl(value);
+ if (!parsed) {
+ setError("That doesn't look like a repo URL. Try github.com/owner/name.");
+ return;
+ }
+
+ if (!SUPPORTED_HOSTS.includes(parsed.host)) {
+ setError(`${hostLabel(parsed.host)} support is coming β GitHub repos work today.`);
+ return;
+ }
+
+ setError(null);
+ setPending(true);
+ router.push(`/score/${parsed.host}/${parsed.owner}/${parsed.name}`);
+ }
+
+ return (
+
+ );
+}
diff --git a/components/ModelSuggestions.tsx b/components/ModelSuggestions.tsx
index 3f52ef1..b43de27 100644
--- a/components/ModelSuggestions.tsx
+++ b/components/ModelSuggestions.tsx
@@ -6,12 +6,13 @@ import { Panel, PanelHeading } from "./Panel";
import { SuggestionItem } from "./SuggestionItem";
type Props = {
- repoId: number;
+ /** Page the model pills link back to β `/repo/:id` or `/score/:host/:owner/:name`. */
+ basePath: string;
selected: ModelId;
suggestions: ImprovementSuggestion[];
};
-export function ModelSuggestions({ repoId, selected, suggestions }: Props) {
+export function ModelSuggestions({ basePath, selected, suggestions }: Props) {
return (
Suggestions to improve for a specific model
@@ -19,7 +20,7 @@ export function ModelSuggestions({ repoId, selected, suggestions }: Props) {
`/repo/${repoId}?model=${m}`}
+ hrefFor={(m) => `${basePath}?model=${m}`}
label="Select a model for per-model suggestions"
/>
diff --git a/components/RecentScores.tsx b/components/RecentScores.tsx
new file mode 100644
index 0000000..f262f9d
--- /dev/null
+++ b/components/RecentScores.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+
+import { type RecentScore, readRecents } from "@/lib/live-score/recents";
+
+import { HostPill } from "./HostPill";
+import { Panel, PanelHeading } from "./Panel";
+import { ScoreNumber } from "./ScoreNumber";
+
+/** `id` marks an indexed repo β see `hrefFor`. */
+type Row = RecentScore & { id?: number };
+
+type Props = {
+ /** Leaderboard rows, so there is something to look at before you have scored anything. */
+ past: Row[];
+};
+
+// An indexed repo's /score/β¦ URL only redirects to /repo/:id, at the cost of a
+// cold server render first.
+function hrefFor(row: Row): string {
+ return row.id == null ? `/score/${row.host}/${row.owner}/${row.name}` : `/repo/${row.id}`;
+}
+
+function ScoreList({ rows }: { rows: Row[] }) {
+ return (
+
+ {rows.map((row) => (
+
+
+
+ {row.owner}/{row.name}
+
+
+
+
+
+ ))}
+
+ );
+}
+
+export function RecentScores({ past }: Props) {
+ // Read after mount: localStorage during render would mismatch the server HTML.
+ const [mine, setMine] = useState([]);
+
+ useEffect(() => {
+ setMine(readRecents());
+ }, []);
+
+ return (
+ <>
+ {mine.length > 0 && (
+
+
+ Your recent scores
+
+
+
+ )}
+
+
+ Past scores
+
+
+ >
+ );
+}
diff --git a/components/RecordScore.tsx b/components/RecordScore.tsx
new file mode 100644
index 0000000..3cc7bca
--- /dev/null
+++ b/components/RecordScore.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import { useEffect } from "react";
+
+import { type RecentScore, writeRecent } from "@/lib/live-score/recents";
+
+/** Records a successful live score so /score can offer it back. Renders nothing. */
+export function RecordScore({ host, owner, name, score }: RecentScore) {
+ // Primitives, not the props object: a fresh object identity every render would
+ // re-run the write on every render.
+ useEffect(() => {
+ writeRecent({ host, owner, name, score });
+ }, [host, owner, name, score]);
+
+ return null;
+}
diff --git a/components/RepoHero.tsx b/components/RepoHero.tsx
index 5e91255..38c8e8f 100644
--- a/components/RepoHero.tsx
+++ b/components/RepoHero.tsx
@@ -7,7 +7,9 @@ import { HostPill } from "./HostPill";
import { Panel } from "./Panel";
import { ScoreDeltaPopover } from "./ScoreDeltaPopover";
-export function RepoHero({ repo }: { repo: RepoRow }) {
+// `commitSha` is the live-score path: there is no "last scored" when the page
+// render *is* the scoring, so the commit is the only honest freshness fact.
+export function RepoHero({ repo, commitSha }: { repo: RepoRow; commitSha?: string }) {
const overall = repo.overall_score ?? 0;
const overallTier = scoreTier(overall);
@@ -46,10 +48,19 @@ export function RepoHero({ repo }: { repo: RepoRow }) {
-
Last scored:
-
- {repo.last_scored_at ? relativeTime(repo.last_scored_at) : "β"}
-
+ {commitSha ? (
+ <>
+ Commit:
+ {commitSha.slice(0, 7)}
+ >
+ ) : (
+ <>
+ Last scored:
+
+ {repo.last_scored_at ? relativeTime(repo.last_scored_at) : "β"}
+
+ >
+ )}
diff --git a/lib/changelog.ts b/lib/changelog.ts
index 8e7390b..5bab925 100644
--- a/lib/changelog.ts
+++ b/lib/changelog.ts
@@ -7,6 +7,18 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ label: "0.7.0",
+ date: "2026-08-25",
+ title: "Score any repo on the fly",
+ highlights: [
+ "On-demand scoring at `/score` β paste any public GitHub repository URL and get its full score in about a second, even if the leaderboard has never indexed it. Same signals, same per-model breakdown, same suggestions as an indexed repo page. Already-indexed repos redirect to their canonical `/repo/:id` page.",
+ "Scored without cloning β the repo is reconstructed from GitHub's tree API rather than `git clone`, so a score costs one API call and a handful of small file fetches. Results are cached for an hour, and nothing about a scored repo is stored.",
+ "Recent scores on `/score` β the repos you have scored are kept in your own browser and listed for one-click return. They are never sent anywhere.",
+ "Every live score carries the commit it was computed from, so a cached result says exactly what it reflects.",
+ "The home page now tells you once, on your first visit after a release, what that release shipped β then stays quiet until the next one.",
+ ],
+ },
{
label: "0.6.0",
date: "2026-05-20",
diff --git a/lib/constants/hosts.ts b/lib/constants/hosts.ts
index 57be52d..d7d6566 100644
--- a/lib/constants/hosts.ts
+++ b/lib/constants/hosts.ts
@@ -7,6 +7,12 @@ export const HOST_LABELS: Record = {
export const HOSTS = ["github", "gitlab", "bitbucket"] as const;
export type Host = (typeof HOSTS)[number];
+export const HOST_DOMAINS: Record = {
+ github: "github.com",
+ gitlab: "gitlab.com",
+ bitbucket: "bitbucket.org",
+};
+
export function isHost(v: string | undefined | null): v is Host {
return v != null && (HOSTS as readonly string[]).includes(v);
}
diff --git a/lib/constants/scoring.ts b/lib/constants/scoring.ts
index e14941e..e3b6fbd 100644
--- a/lib/constants/scoring.ts
+++ b/lib/constants/scoring.ts
@@ -5,4 +5,5 @@ export const LEADERBOARD_PAGE_SIZE = 32;
export const LEADERBOARD_PAGE_SIZE_MOBILE = 16;
export const DEFAULT_SUGGESTION_LIMIT = 3;
+export const ALTERNATIVES_LIMIT = 3;
export const STRENGTHS_GAPS_VISIBLE_LIMIT = 5;
diff --git a/lib/db.ts b/lib/db.ts
index 0c65c85..ee72ae9 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -229,35 +229,49 @@ export function getSignalResults(repoId: number): SignalResult[] {
.all(repoId) as any as SignalResult[];
}
-export function getAlternatives(repoId: number, modelId: string | null, limit: number): AlternativeRow[] {
+// Keyed by language + host rather than a repo id, because a live-scored repo has
+// no row of its own to derive them from. `excludeId` keeps an indexed repo out of
+// its own alternatives list.
+export function getAlternativesFor(
+ host: string,
+ language: string | null,
+ modelId: string | null,
+ limit: number,
+ excludeId?: number,
+): AlternativeRow[] {
+ if (!language) return [];
+
+ const skip = excludeId == null ? "" : "AND r.id != ?";
+ const args = excludeId == null ? [language, host, limit] : [language, host, excludeId, limit];
+
if (modelId) {
return db
.prepare(
`SELECT r.id, r.host, r.owner, r.name, r.stars, m.score
FROM repo r
JOIN model_score m ON m.repo_id = r.id AND m.model_id = ?
- WHERE r.id != ?
- AND r.language IS NOT NULL
- AND r.language = (SELECT language FROM repo WHERE id = ?)
- AND r.host = (SELECT host FROM repo WHERE id = ?)
+ WHERE r.language = ? AND r.host = ? ${skip}
ORDER BY m.score DESC
LIMIT ?`,
)
- .all(modelId, repoId, repoId, repoId, limit) as AlternativeRow[];
+ .all(modelId, ...args) as AlternativeRow[];
}
return db
.prepare(
- `SELECT id, host, owner, name, stars, overall_score AS score
- FROM repo
- WHERE id != ?
- AND language IS NOT NULL
- AND language = (SELECT language FROM repo WHERE id = ?)
- AND host = (SELECT host FROM repo WHERE id = ?)
- ORDER BY overall_score DESC
+ `SELECT r.id, r.host, r.owner, r.name, r.stars, r.overall_score AS score
+ FROM repo r
+ WHERE r.language = ? AND r.host = ? ${skip}
+ ORDER BY r.overall_score DESC
LIMIT ?`,
)
- .all(repoId, repoId, repoId, limit) as AlternativeRow[];
+ .all(...args) as AlternativeRow[];
+}
+
+export function getAlternatives(repoId: number, modelId: string | null, limit: number): AlternativeRow[] {
+ const repo = getRepo(repoId);
+ if (!repo) return [];
+ return getAlternativesFor(repo.host, repo.language, modelId, limit, repoId);
}
export function getLeaderboardStats(): LeaderboardStats {
diff --git a/lib/live-score/content-files.ts b/lib/live-score/content-files.ts
new file mode 100644
index 0000000..5a9a702
--- /dev/null
+++ b/lib/live-score/content-files.ts
@@ -0,0 +1,23 @@
+// Not in `lib/scoring/`: the siblings vendor that directory and neither
+// materializes a tree, so it would force a re-vendor into both for unused code.
+//
+// Derived from every `readSafe` / `readFileSync` call site in
+// `lib/scoring/signals/` plus `lib/badge-adoption.ts`. A signal that reads a new
+// file must be added here, and only `scripts/parity-check.ts` catches the
+// omission β the live path would score the file as empty and raise nothing.
+export const CONTENT_CANDIDATES = [
+ "README.md",
+ "README.rst",
+ "README.txt",
+ "README",
+ "AGENTS.md",
+ "CLAUDE.md",
+ "AGENT.md",
+ ".cursorrules",
+ ".cursor/rules",
+ "GEMINI.md",
+ ".openhands/setup.sh",
+ "package.json",
+ "pyproject.toml",
+ ".gitignore",
+] as const;
diff --git a/lib/live-score/hosts.ts b/lib/live-score/hosts.ts
new file mode 100644
index 0000000..1e61170
--- /dev/null
+++ b/lib/live-score/hosts.ts
@@ -0,0 +1,265 @@
+// A tree API, not the tarball: `codeload` / GitLab `archive.tar.gz` run
+// `git archive`, which honors `export-ignore` in `.gitattributes` β so an
+// archive reflects a release while a tree reflects the repository. Measured
+// impact and the rejected alternatives: tasks/0.7.0/01-tree-materializer.md.
+
+import type { RepoHost } from "../clients/github";
+
+export type EntryKind = "dir" | "file" | "symlink";
+
+export type TreeEntry = {
+ path: string;
+ kind: EntryKind;
+ /** Blob SHA, when the host exposes one. Used to recover a dangling symlink's target. */
+ sha?: string;
+};
+
+const USER_AGENT = "agent-friendly-code";
+
+const SYMLINK_MODE = "120000";
+
+/** Deepest level any signal inspects is `size`'s MAX_DEPTH; 10 leaves headroom. */
+const BITBUCKET_MAX_DEPTH = 10;
+
+// Refuse rather than hang. Sits above the largest repo we score today
+// (JetBrains/kotlin, ~97k entries); it exists for the pathological tail, and
+// bites hardest on GitLab, which paginates at 100 entries per call.
+export const MAX_ENTRIES = 150_000;
+
+export class TooLargeError extends Error {
+ constructor(host: RepoHost) {
+ super(`Repository is too large to score on demand (${host})`);
+ this.name = "TooLargeError";
+ }
+}
+
+// Distinct from a generic failure so the page can say "come back shortly"
+// instead of "check the URL" β unauthenticated GitHub allows 60 requests/hour
+// per IP and serverless egress IPs are shared, so this is the failure a missing
+// GITHUB_TOKEN actually produces.
+export class RateLimitedError extends Error {
+ constructor(host: RepoHost) {
+ super(`Host API rate limit reached (${host})`);
+ this.name = "RateLimitedError";
+ }
+}
+
+// 429 is explicit; GitHub and GitLab both also answer a spent quota with 403.
+function assertNotRateLimited(host: RepoHost, res: Response): void {
+ if (res.status === 429 || res.status === 403) throw new RateLimitedError(host);
+}
+
+export function requestHeaders(host: RepoHost, token?: string): Record {
+ const base: Record = { "User-Agent": USER_AGENT };
+ if (!token) return base;
+ if (host === "gitlab") base["PRIVATE-TOKEN"] = token;
+ if (host === "github") base.Authorization = `Bearer ${token}`;
+ return base;
+}
+
+function gitlabProjectId(owner: string, name: string): string {
+ // Subgroups arrive in `owner` as a nested path, so the whole slug is encoded.
+ return encodeURIComponent(`${owner}/${name}`);
+}
+
+async function paginate(
+ host: RepoHost,
+ first: string,
+ init: RequestInit,
+ nextUrl: (body: unknown, res: Response) => string | null,
+): Promise {
+ const bodies: unknown[] = [];
+ let url: string | null = first;
+ let fetched = 0;
+
+ while (url) {
+ const res: Response = await fetch(url, init);
+ if (!res.ok) {
+ assertNotRateLimited(host, res);
+ throw new Error(`${res.status} listing tree`);
+ }
+ const body: unknown = await res.json();
+ bodies.push(body);
+
+ fetched += Array.isArray(body) ? body.length : ((body as { values?: unknown[] }).values?.length ?? 0);
+ if (fetched > MAX_ENTRIES) throw new TooLargeError(host);
+
+ url = nextUrl(body, res);
+ }
+
+ return bodies;
+}
+
+type GitHubNode = { path: string; type: string; mode: string; sha: string };
+
+async function listGitHub(owner: string, name: string, ref: string, token?: string): Promise {
+ const init = { headers: requestHeaders("github", token) };
+ const api = `https://api.github.com/repos/${owner}/${name}/git/trees`;
+
+ const res = await fetch(`${api}/${ref}?recursive=1`, init);
+ if (!res.ok) {
+ assertNotRateLimited("github", res);
+ throw new Error(`${res.status} listing tree`);
+ }
+ let { tree, truncated } = (await res.json()) as { tree: GitHubNode[]; truncated: boolean };
+
+ // `?recursive=1` truncates mid-walk in *sorted* order, so a huge repo loses
+ // whatever sorts last β for JetBrains/kotlin that was gradlew, LICENSE,
+ // CONTRIBUTING.md and tests/, worth 26.8 points. Re-walk one subtree at a
+ // time; each comes back whole. A subtree big enough to truncate on its own
+ // would still lose entries; none of the fixtures reach that, so it is not
+ // recursed further.
+ if (truncated) {
+ const rootRes = await fetch(`${api}/${ref}`, init);
+ if (rootRes.ok) {
+ const root = (await rootRes.json()) as { tree: GitHubNode[] };
+ const merged = new Map(root.tree.map((e) => [e.path, e]));
+
+ await Promise.all(
+ root.tree
+ .filter((e) => e.type === "tree")
+ .map(async (dir) => {
+ const sub = await fetch(`${api}/${dir.sha}?recursive=1`, init);
+ if (!sub.ok) return;
+ for (const e of ((await sub.json()) as { tree: GitHubNode[] }).tree) {
+ const path = `${dir.path}/${e.path}`;
+ merged.set(path, { ...e, path });
+ }
+ }),
+ );
+
+ tree = [...merged.values()];
+ }
+ }
+
+ if (tree.length > MAX_ENTRIES) throw new TooLargeError("github");
+
+ return tree.map((e) => ({
+ path: e.path,
+ sha: e.sha,
+ // Submodules arrive as type "commit"; a --depth 1 clone leaves them as empty
+ // directories, so counting them as files inflates `size`.
+ kind: e.type === "tree" || e.type === "commit" ? "dir" : e.mode === SYMLINK_MODE ? "symlink" : "file",
+ }));
+}
+
+type GitLabNode = { path: string; type: string; mode: string; id: string };
+
+async function listGitLab(owner: string, name: string, ref: string, token?: string): Promise {
+ const base = `https://gitlab.com/api/v4/projects/${gitlabProjectId(owner, name)}/repository/tree?recursive=true&per_page=100&ref=${ref}`;
+
+ const bodies = await paginate("gitlab", base, { headers: requestHeaders("gitlab", token) }, (_body, res) => {
+ const page = res.headers.get("x-next-page");
+ return page ? `${base}&page=${page}` : null;
+ });
+
+ return (bodies as GitLabNode[][]).flat().map((e) => ({
+ path: e.path,
+ sha: e.id,
+ kind: e.type === "tree" || e.type === "commit" ? "dir" : e.mode === SYMLINK_MODE ? "symlink" : "file",
+ }));
+}
+
+type BitbucketNode = { path: string; type: string; attributes?: string[] };
+
+async function listBitbucket(owner: string, name: string, ref: string): Promise {
+ const base = `https://api.bitbucket.org/2.0/repositories/${owner}/${name}/src/${ref}/?max_depth=${BITBUCKET_MAX_DEPTH}&pagelen=100`;
+
+ const bodies = await paginate("bitbucket", base, { headers: requestHeaders("bitbucket") }, (body) => {
+ return (body as { next?: string }).next ?? null;
+ });
+
+ return (bodies as { values: BitbucketNode[] }[])
+ .flatMap((b) => b.values)
+ .map((v) => {
+ const attributes = v.attributes ?? [];
+ const kind: EntryKind =
+ v.type === "commit_directory" || attributes.includes("subrepository")
+ ? "dir"
+ : attributes.includes("link")
+ ? "symlink"
+ : "file";
+ return { path: v.path, kind };
+ });
+}
+
+export function listTree(
+ host: RepoHost,
+ owner: string,
+ name: string,
+ ref: string,
+ token?: string,
+): Promise {
+ if (host === "gitlab") return listGitLab(owner, name, ref, token);
+ if (host === "bitbucket") return listBitbucket(owner, name, ref);
+ return listGitHub(owner, name, ref, token);
+}
+
+export function rawUrl(host: RepoHost, owner: string, name: string, ref: string, path: string): string {
+ if (host === "gitlab") {
+ return `https://gitlab.com/api/v4/projects/${gitlabProjectId(owner, name)}/repository/files/${encodeURIComponent(path)}/raw?ref=${ref}`;
+ }
+ if (host === "bitbucket") {
+ return `https://api.bitbucket.org/2.0/repositories/${owner}/${name}/src/${ref}/${path}`;
+ }
+ return `https://raw.githubusercontent.com/${owner}/${name}/${ref}/${path}`;
+}
+
+// A dangling symlink 404s on the raw endpoint because the target doesn't
+// resolve, but the blob still holds the stored target path.
+export function blobUrl(host: RepoHost, owner: string, name: string, sha: string): string | null {
+ if (host === "github") return `https://api.github.com/repos/${owner}/${name}/git/blobs/${sha}`;
+ if (host === "gitlab") {
+ return `https://gitlab.com/api/v4/projects/${gitlabProjectId(owner, name)}/repository/blobs/${sha}/raw`;
+ }
+ return null;
+}
+
+/**
+ * Resolve a ref to its commit SHA. Pins the score to one commit β otherwise a
+ * push between the tree listing and the content fetches would mix two revisions
+ * into one result β and gives the page an honest freshness marker.
+ */
+export async function resolveCommit(
+ host: RepoHost,
+ owner: string,
+ name: string,
+ ref: string,
+ token?: string,
+): Promise {
+ const init = { headers: requestHeaders(host, token) };
+
+ if (host === "github") {
+ const res = await fetch(`https://api.github.com/repos/${owner}/${name}/commits/${ref}`, init);
+ if (!res.ok) {
+ assertNotRateLimited(host, res);
+ return null;
+ }
+ return ((await res.json()) as { sha?: string }).sha ?? null;
+ }
+
+ if (host === "gitlab") {
+ const res = await fetch(
+ `https://gitlab.com/api/v4/projects/${gitlabProjectId(owner, name)}/repository/commits/${ref}`,
+ init,
+ );
+ if (!res.ok) {
+ assertNotRateLimited(host, res);
+ return null;
+ }
+ return ((await res.json()) as { id?: string }).id ?? null;
+ }
+
+ const res = await fetch(`https://api.bitbucket.org/2.0/repositories/${owner}/${name}/commit/${ref}`, init);
+ if (!res.ok) {
+ assertNotRateLimited(host, res);
+ return null;
+ }
+ return ((await res.json()) as { hash?: string }).hash ?? null;
+}
+
+export function hostToken(host: RepoHost): string | undefined {
+ if (host === "gitlab") return process.env.GITLAB_TOKEN;
+ if (host === "github") return process.env.GITHUB_TOKEN;
+ return undefined;
+}
diff --git a/lib/live-score/materialize.ts b/lib/live-score/materialize.ts
new file mode 100644
index 0000000..08fbd33
--- /dev/null
+++ b/lib/live-score/materialize.ts
@@ -0,0 +1,148 @@
+import { mkdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
+import { dirname, join, relative, resolve, sep } from "node:path";
+
+import type { RepoHost } from "../clients/github";
+import { resolveRelative } from "../scoring/signals/helpers";
+import { CONTENT_CANDIDATES } from "./content-files";
+import { blobUrl, hostToken, listTree, rawUrl, requestHeaders, type TreeEntry } from "./hosts";
+
+const CONCURRENCY = 8;
+
+export type Materialized = { sha: string; entries: number };
+
+async function pooled(items: T[], fn: (item: T) => Promise): Promise {
+ const queue = [...items];
+ await Promise.all(
+ Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) await fn(next);
+ }),
+ );
+}
+
+// The only thing standing between an attacker-chosen path and the filesystem.
+export function safeAbsolute(dest: string, path: string): string | null {
+ if (!path || path.startsWith("/") || path.split("/").includes("..")) return null;
+ const abs = join(dest, path);
+ return resolve(abs).startsWith(resolve(dest) + sep) ? abs : null;
+}
+
+async function linkTargets(
+ host: RepoHost,
+ owner: string,
+ name: string,
+ ref: string,
+ dest: string,
+ symlinks: TreeEntry[],
+ headers: Record,
+): Promise {
+ await pooled(symlinks, async (entry) => {
+ // Never trim the target: vercel/ai commits "packages/ai/README.md\n", so git
+ // leaves the link dangling. Trimming would repair it and invent a README the
+ // real pipeline never sees.
+ let target: string | null = null;
+
+ const res = await fetch(rawUrl(host, owner, name, ref, entry.path), { headers });
+ if (res.ok) {
+ target = await res.text();
+ } else if (entry.sha) {
+ const blob = blobUrl(host, owner, name, entry.sha);
+ if (blob) {
+ const blobRes = await fetch(blob, { headers: { ...headers, Accept: "application/vnd.github.raw+json" } });
+ if (blobRes.ok) target = await blobRes.text();
+ }
+ }
+
+ // No target, or an empty one: keep the placeholder. Removing it would drop
+ // the entry from `size`'s file count, which a clone still counts.
+ if (!target) return;
+
+ const abs = safeAbsolute(dest, entry.path);
+ if (!abs) return;
+
+ try {
+ rmSync(abs, { force: true });
+ symlinkSync(target, abs);
+ } catch {
+ try {
+ writeFileSync(abs, "");
+ } catch {}
+ }
+ });
+}
+
+async function fetchContent(
+ host: RepoHost,
+ owner: string,
+ name: string,
+ ref: string,
+ dest: string,
+ headers: Record,
+): Promise {
+ // realpathSync resolves symlinks in the *base* path too, so a /tmp dest comes
+ // back as /private/tmp on macOS; relativising against `dest` would emit
+ // ../../private/tmp/β¦ and every fetch would 404 into an empty file.
+ const root = realpathSync(dest);
+
+ const wanted = new Set();
+ for (const candidate of CONTENT_CANDIDATES) {
+ // The scorer's own case-insensitive lookup, then the OS follows any symlink:
+ // whichever file scoring will actually read is the one we fetch.
+ const hit = resolveRelative(dest, candidate);
+ if (!hit) continue;
+ try {
+ const real = realpathSync(join(dest, hit));
+ if (statSync(real).isDirectory()) continue;
+ wanted.add(relative(root, real));
+ } catch {}
+ }
+
+ await pooled([...wanted], async (path) => {
+ const res = await fetch(rawUrl(host, owner, name, ref, path), { headers });
+ if (!res.ok) return;
+ writeFileSync(join(root, path), await res.text());
+ });
+}
+
+/**
+ * Build a directory `scoreRepo()` reads identically to a `git clone` of `ref`.
+ *
+ * Every path exists; only the files a signal reads carry real bytes. Callers own
+ * cleanup β wrap in try/finally with rmSync.
+ */
+export async function materialize(
+ host: RepoHost,
+ owner: string,
+ name: string,
+ ref: string,
+ dest: string,
+): Promise {
+ const token = hostToken(host);
+ const headers = requestHeaders(host, token);
+ const entries = await listTree(host, owner, name, ref, token);
+
+ mkdirSync(dest, { recursive: true });
+
+ const symlinks: TreeEntry[] = [];
+
+ for (const entry of entries) {
+ const abs = safeAbsolute(dest, entry.path);
+ if (!abs) continue;
+
+ if (entry.kind === "dir") {
+ mkdirSync(abs, { recursive: true });
+ continue;
+ }
+
+ mkdirSync(dirname(abs), { recursive: true });
+ // Placeholder first, symlinks included: a link to a file counts as one file
+ // either way, so a failed target lookup degrades instead of dropping the
+ // entry from `size`'s count.
+ writeFileSync(abs, "");
+ if (entry.kind === "symlink") symlinks.push(entry);
+ }
+
+ await linkTargets(host, owner, name, ref, dest, symlinks, headers);
+ await fetchContent(host, owner, name, ref, dest, headers);
+
+ return { sha: ref, entries: entries.length };
+}
diff --git a/lib/live-score/recents.ts b/lib/live-score/recents.ts
new file mode 100644
index 0000000..464364a
--- /dev/null
+++ b/lib/live-score/recents.ts
@@ -0,0 +1,45 @@
+export type RecentScore = {
+ host: string;
+ name: string;
+ owner: string;
+ score: number;
+};
+
+const KEY = "afc:recent-scores";
+
+export const RECENTS_LIMIT = 10;
+
+// Per-visitor and client-only on purpose. A shared list would be the one
+// uncacheable read *and* write on the hot path, and it would publicly broadcast
+// what repos strangers are scoring.
+export function readRecents(): RecentScore[] {
+ try {
+ const raw = window.localStorage.getItem(KEY);
+ if (!raw) return [];
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+
+ return parsed
+ .filter((r): r is RecentScore => {
+ const v = r as Partial;
+ return (
+ typeof v?.host === "string" &&
+ typeof v?.owner === "string" &&
+ typeof v?.name === "string" &&
+ typeof v?.score === "number"
+ );
+ })
+ .slice(0, RECENTS_LIMIT);
+ } catch {
+ return [];
+ }
+}
+
+export function writeRecent(entry: RecentScore): void {
+ try {
+ const existing = readRecents().filter(
+ (r) => !(r.host === entry.host && r.owner === entry.owner && r.name === entry.name),
+ );
+ window.localStorage.setItem(KEY, JSON.stringify([entry, ...existing].slice(0, RECENTS_LIMIT)));
+ } catch {}
+}
diff --git a/lib/live-score/score.ts b/lib/live-score/score.ts
new file mode 100644
index 0000000..fe772ad
--- /dev/null
+++ b/lib/live-score/score.ts
@@ -0,0 +1,62 @@
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { detectBadgeEmbed } from "../badge-adoption";
+import { fetchRepoMeta, type ParsedRepo } from "../clients/github";
+import type { ModelScore, RepoScore } from "../scoring/scorer";
+import { scoreRepo } from "../scoring/scorer";
+import { hostToken, resolveCommit } from "./hosts";
+import { materialize } from "./materialize";
+
+export type LiveScore = {
+ sha: string;
+ overall: number;
+ language: string | null;
+ stars: number | null;
+ defaultBranch: string | null;
+ badgeEmbedded: boolean;
+ signals: RepoScore["signals"];
+ modelScores: ModelScore[];
+};
+
+/**
+ * Score a repo from its host tree API. Nothing is persisted β `lib/db.ts` copies
+ * the bundled SQLite to /tmp per lambda instance, so a write here would land on
+ * one instance and vanish.
+ *
+ * Returns null only when the host says the ref does not exist. Everything else
+ * throws: the caller renders into an ISR cache, so a swallowed rate limit or
+ * network blip would pin "this repo doesn't exist" on a real repo for an hour.
+ */
+export async function liveScore(parsed: ParsedRepo): Promise {
+ const dir = mkdtempSync(join(tmpdir(), "afc-live-"));
+
+ try {
+ // Metadata runs alongside the commit lookup, so it costs no extra latency β
+ // and on an ISR cache hit it costs no request at all.
+ const [meta, sha] = await Promise.all([
+ fetchRepoMeta(parsed),
+ resolveCommit(parsed.host, parsed.owner, parsed.name, "HEAD", hostToken(parsed.host)),
+ ]);
+
+ if (!sha) return null;
+
+ const resolved = await materialize(parsed.host, parsed.owner, parsed.name, sha, dir);
+
+ const result = scoreRepo(dir);
+
+ return {
+ sha: resolved.sha,
+ overall: result.overall,
+ signals: result.signals,
+ stars: meta?.stars ?? null,
+ modelScores: result.modelScores,
+ language: meta?.language ?? null,
+ defaultBranch: meta?.defaultBranch ?? null,
+ badgeEmbedded: detectBadgeEmbed(dir, `${parsed.host}/${parsed.owner}/${parsed.name}`),
+ };
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
diff --git a/lib/live-score/supported.ts b/lib/live-score/supported.ts
new file mode 100644
index 0000000..5885a7c
--- /dev/null
+++ b/lib/live-score/supported.ts
@@ -0,0 +1,10 @@
+import type { RepoHost } from "../clients/github";
+
+// Its own module so client components can import it without pulling in the
+// materializer's node:fs dependencies.
+//
+// GitLab paginates its tree at 100 entries β gitlab-org/gitlab needs 1,000+
+// sequential calls β and Bitbucket allows 60 API requests/hour unauthenticated.
+// Both are implemented and score identically to a clone; neither survives public
+// traffic yet. See tasks/0.7.0/03-live-score-pages.md.
+export const SUPPORTED_HOSTS: RepoHost[] = ["github"];
diff --git a/lib/roadmap.ts b/lib/roadmap.ts
index ebd9946..b965bac 100644
--- a/lib/roadmap.ts
+++ b/lib/roadmap.ts
@@ -13,18 +13,18 @@ export type RoadmapVersion = {
export const ROADMAP: RoadmapVersion[] = [
{
- version: "0.7.0",
+ version: "0.8.0",
status: "planned",
theme: "Maintainer ownership + at-scale discovery",
items: [
{
title: "Opt-out / claim flow",
- taskFile: "tasks/0.7.0/01-opt-out-claim-flow.md",
+ taskFile: "tasks/0.8.0/01-opt-out-claim-flow.md",
summary: "OAuth so maintainers control their listing.",
},
{
title: "Package-registry overlay (at scale)",
- taskFile: "tasks/0.7.0/02-package-registry-overlay.md",
+ taskFile: "tasks/0.8.0/02-package-registry-overlay.md",
summary: "Per-registry leaderboards + browser userscript for inline badges on npmjs.com / PyPI / crates.io.",
},
],
diff --git a/lib/version.ts b/lib/version.ts
index 92fa241..2b776b8 100644
--- a/lib/version.ts
+++ b/lib/version.ts
@@ -1,4 +1,4 @@
-export const APP_VERSION = "0.6.0";
+export const APP_VERSION = "0.7.0";
export const APP_NAME = "Agent Friendly Code";
export const IS_PRE_RELEASE = APP_VERSION.startsWith("0.0.");
diff --git a/package.json b/package.json
index b4fe725..2962c60 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "agent-friendly-code",
- "version": "0.6.0",
+ "version": "0.7.0",
"private": true,
"license": "MIT",
"author": "Himanshu Singh (https://github.com/hsnice16)",
@@ -12,6 +12,7 @@
"build": "next build",
"start": "next start -p 3000",
"score": "tsx scripts/score.ts score",
+ "parity-check": "tsx scripts/parity-check.ts",
"seed": "tsx scripts/seed.ts",
"seed-packages": "tsx scripts/seed-packages.ts",
"audit-seeds": "tsx scripts/audit-seeds.ts",
diff --git a/scripts/parity-check.ts b/scripts/parity-check.ts
new file mode 100644
index 0000000..257c1a8
--- /dev/null
+++ b/scripts/parity-check.ts
@@ -0,0 +1,137 @@
+// Asserts the live-score path scores identically to `bun run score`.
+//
+// Both paths run fresh from a repo URL, with the materializer pinned to the SHA
+// the clone fetched, so a push mid-run cannot fake a difference. `detail` is
+// compared as well as `overall`: past defects moved only a file count, leaving
+// the overall score untouched one bucket boundary away from mattering.
+
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { shallowClone } from "../lib/clients/git";
+import { parseRepoUrl } from "../lib/clients/github";
+import { materialize } from "../lib/live-score/materialize";
+import { scoreRepo } from "../lib/scoring/scorer";
+
+try {
+ process.loadEnvFile();
+} catch {}
+
+// Chosen for the failure classes they reproduce, not for popularity. `pr` marks
+// the subset a pull request runs (`--pr`): the classes most likely to regress
+// from a scoring change, without the full sweep's clone time. Keeping the
+// selection here rather than in the workflow YAML stops the two from drifting.
+const FIXTURES = [
+ { url: "https://github.com/expressjs/express", why: "README spelled `Readme.md`", pr: true },
+ { url: "https://github.com/cloudflare/vinext", why: "dangling symlink (CLAUDE.md -> missing AGENTS.md)", pr: true },
+ { url: "https://github.com/vercel/ai", why: "link target with a trailing newline", pr: true },
+ { url: "https://github.com/honojs/hono", why: "small baseline", pr: true },
+ { url: "https://github.com/zed-industries/zed", why: "251 symlinks" },
+ { url: "https://github.com/ClickHouse/ClickHouse", why: "AGENTS.md symlinked into .claude/" },
+ { url: "https://github.com/JetBrains/kotlin", why: "tree API truncates" },
+ { url: "https://gitlab.com/graphviz/graphviz", why: "submodules, GitLab pagination" },
+ { url: "https://bitbucket.org/snakeyaml/snakeyaml", why: "Bitbucket" },
+];
+
+type Diff = { slug: string; lines: string[] };
+
+function signalDiffs(clone: ReturnType, live: ReturnType): string[] {
+ const lines: string[] = [];
+
+ for (const [i, s] of clone.signals.entries()) {
+ const t = live.signals[i];
+ if (!t || s.id !== t.id) {
+ lines.push(` signal order differs at ${i}`);
+ continue;
+ }
+ if (Math.abs(s.pass - t.pass) > 0.0001 || s.detail !== t.detail || s.matchedPath !== t.matchedPath) {
+ lines.push(` ${s.id}: clone=${s.pass} "${s.detail}" | live=${t.pass} "${t.detail}"`);
+ }
+ }
+
+ for (const [i, m] of clone.modelScores.entries()) {
+ const t = live.modelScores[i];
+ if (!t || m.score.toFixed(2) !== t.score.toFixed(2)) {
+ lines.push(` model ${m.modelId}: clone=${m.score.toFixed(2)} | live=${t?.score.toFixed(2)}`);
+ }
+ }
+
+ return lines;
+}
+
+async function compare(url: string, work: string): Promise {
+ const parsed = parseRepoUrl(url);
+ if (!parsed) throw new Error(`unparseable: ${url}`);
+
+ const slug = `${parsed.owner}/${parsed.name}`;
+ const cloneDir = join(work, "clone");
+ const liveDir = join(work, "live");
+
+ try {
+ await shallowClone(parsed.cloneUrl, cloneDir);
+ const cloned = scoreRepo(cloneDir);
+ const sha = execFileSync("git", ["-C", cloneDir, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
+
+ await materialize(parsed.host, parsed.owner, parsed.name, sha, liveDir);
+ const live = scoreRepo(liveDir);
+
+ const lines = signalDiffs(cloned, live);
+ if (cloned.overall.toFixed(2) !== live.overall.toFixed(2)) {
+ lines.unshift(` overall: clone=${cloned.overall.toFixed(1)} | live=${live.overall.toFixed(1)}`);
+ }
+
+ if (lines.length === 0) {
+ console.log(` ok ${slug} ${cloned.overall.toFixed(1)}`);
+ return null;
+ }
+
+ console.log(` DIFF ${slug}`);
+ for (const l of lines) console.log(l);
+ return { slug, lines };
+ } finally {
+ // Unguarded, this kills the run: delta-io/delta nests fixtures deep enough
+ // to blow PATH_MAX on macOS during cleanup.
+ try {
+ rmSync(cloneDir, { recursive: true, force: true });
+ rmSync(liveDir, { recursive: true, force: true });
+ } catch {}
+ }
+}
+
+async function main(): Promise {
+ const args = process.argv.slice(2);
+ const urls = args.filter((a) => !a.startsWith("-"));
+ const fixtures = args.includes("--pr") ? FIXTURES.filter((f) => f.pr) : FIXTURES;
+ const targets = urls.length > 0 ? urls : fixtures.map((f) => f.url);
+
+ // Serial on purpose: at four parallel workers, kotlin and pytorch produced
+ // spurious diffs that vanished on isolated re-run.
+ const work = mkdtempSync(join(tmpdir(), "afc-parity-"));
+ const diffs: Diff[] = [];
+ const errors: string[] = [];
+
+ console.log(`Parity check β ${targets.length} repo(s)\n`);
+
+ for (const url of targets) {
+ try {
+ const diff = await compare(url, work);
+ if (diff) diffs.push(diff);
+ } catch (err) {
+ const message = (err as Error).message.slice(0, 160);
+ console.log(` ERR ${url} β ${message}`);
+ errors.push(url);
+ }
+ }
+
+ rmSync(work, { recursive: true, force: true });
+
+ console.log(
+ `\n${targets.length - diffs.length - errors.length} identical | ${diffs.length} differ | ${errors.length} errored`,
+ );
+
+ if (diffs.length > 0 || errors.length > 0) process.exit(1);
+}
+
+void main();
diff --git a/tasks/0.3.0/05-package-registry-overlay.md b/tasks/0.3.0/05-package-registry-overlay.md
index 6c2bb7e..199f75e 100644
--- a/tasks/0.3.0/05-package-registry-overlay.md
+++ b/tasks/0.3.0/05-package-registry-overlay.md
@@ -23,7 +23,7 @@
- β `/package`, `/package/npm/react`, `/package/npm/lodash`, `/package/npm/` all render the expected state.
- β Nav link visible; homepage callout visible.
-## Out of scope (stays in v0.7.0)
+## Out of scope (stays in v0.8.0)
- Per-registry leaderboards ("top 100 npm packages by agent-friendliness").
- Browser userscript for inline badges on registry pages.
@@ -34,7 +34,7 @@
Developers pick dependencies in registry UIs (npmjs.com, PyPI, crates.io) β not on GitHub. This task adds the lookup half of the package-registry overlay: given a package name, resolve it to its source repo and surface the score (or invite scoring via a pre-filled GitHub issue).
-The at-scale side β per-registry leaderboards + browser userscript for inline badges β stays in v0.7.0 as a follow-up.
+The at-scale side β per-registry leaderboards + browser userscript for inline badges β stays in v0.8.0 as a follow-up.
## Public surface
@@ -65,7 +65,7 @@ The at-scale side β per-registry leaderboards + browser userscript for inline
- `lib/db.ts` β `package_alias(registry, name, repo_url, resolved_at)` cache table. Additive migration pattern matching `language`.
- `lib/utils/contact.ts` β builds a `REPO_URL/issues/new?title=...&body=...` link with a pre-filled template naming the package + what we resolved.
-## Out of scope (v0.7.0 follow-up)
+## Out of scope (v0.8.0 follow-up)
- Per-registry leaderboards ("top 100 npm packages").
- Browser userscript for inline badges on registry pages.
diff --git a/tasks/0.3.0/README.md b/tasks/0.3.0/README.md
index 8a60667..422551d 100644
--- a/tasks/0.3.0/README.md
+++ b/tasks/0.3.0/README.md
@@ -10,4 +10,4 @@ Make the current scores usable outside the dashboard (badge SVG for READMEs) and
- [02-expand-agent-coverage.md](./02-expand-agent-coverage.md) β add Gemini CLI and the next tier of active coding agents to `MODELS` on illustrative weights, tagged clearly on `/methodology`. **Done.**
- [03-animate-score-bar.md](./03-animate-score-bar.md) β animate the `ScoreBar` fill width on leaderboard prev/next instead of remounting. **Done.**
- [04-alternatives-v1.md](./04-alternatives-v1.md) β v1 SQL heuristic for "alternative repos" on the repo detail page. **Done.** (A v2 embedding upgrade was considered for 0.6.0 and [deferred to the backlog](../0.6.0/02-alternatives-v2-embeddings.md).)
-- [05-package-registry-overlay.md](./05-package-registry-overlay.md) β npm / PyPI / Cargo lookup: `/package/:registry/:name` resolves to source repo and surfaces its score, with a pre-filled GitHub issue for anything unscored. Per-registry leaderboards + browser userscript stay in 0.7.0. **Done.**
+- [05-package-registry-overlay.md](./05-package-registry-overlay.md) β npm / PyPI / Cargo lookup: `/package/:registry/:name` resolves to source repo and surfaces its score, with a pre-filled GitHub issue for anything unscored. Per-registry leaderboards + browser userscript stay in 0.8.0. **Done.**
diff --git a/tasks/0.5.0/02-score-diff-on-pr.md b/tasks/0.5.0/02-score-diff-on-pr.md
index 545136e..487f4c7 100644
--- a/tasks/0.5.0/02-score-diff-on-pr.md
+++ b/tasks/0.5.0/02-score-diff-on-pr.md
@@ -118,6 +118,6 @@ Lets us ship the action inside starter-repo templates without it firing for fork
## Out of scope (deferred)
- Runtime weights refresh (tiers 1 + 2). Lands when 1.0.0/03 publishes its first weight set.
-- Action POSTing to our DB. DB freshness is handled by the 0.6.0/01 scheduled-rescore cron in this repo; a webhook receiver would only become useful alongside the 0.7.0 claim flow.
+- Action POSTing to our DB. DB freshness is handled by the 0.6.0/01 scheduled-rescore cron in this repo; a webhook receiver would only become useful alongside the 0.8.0 claim flow.
- Per-language or per-agent comment customisation.
- On-demand scoring of unindexed repos via the action.
diff --git a/tasks/0.6.0/01-scheduled-rescoring.md b/tasks/0.6.0/01-scheduled-rescoring.md
index df947e4..156d640 100644
--- a/tasks/0.6.0/01-scheduled-rescoring.md
+++ b/tasks/0.6.0/01-scheduled-rescoring.md
@@ -22,7 +22,7 @@ Instead: a GitHub Actions cron in this repo runs `bun run seed` every 6 hours, c
## What this gives up vs the original spec
- **Sub-minute freshness on push** β up to 6h stale. For signals like README / AGENTS.md / CI files, that's well inside the noise floor.
-- **Per-repo subscription** β no such concept. Webhook subscription only becomes meaningful once `tasks/0.7.0/01-opt-out-claim-flow.md` introduces repo ownership.
+- **Per-repo subscription** β no such concept. Webhook subscription only becomes meaningful once `tasks/0.8.0/01-opt-out-claim-flow.md` introduces repo ownership.
## Cost / footprint
@@ -33,7 +33,7 @@ Instead: a GitHub Actions cron in this repo runs `bun run seed` every 6 hours, c
## Future webhook layer (if we ever want it)
-Add a webhook receiver as `app/api/webhook/github/route.ts` that verifies HMAC-SHA256 against a secret and triggers the same workflow via `workflow_dispatch` (or enqueues into a queue we'll have by then). The cron stays as the floor; webhooks become a latency optimization. Best landed alongside the claim flow in 0.7.0.
+Add a webhook receiver as `app/api/webhook/github/route.ts` that verifies HMAC-SHA256 against a secret and triggers the same workflow via `workflow_dispatch` (or enqueues into a queue we'll have by then). The cron stays as the floor; webhooks become a latency optimization. Best landed alongside the claim flow in 0.8.0.
## Acceptance
diff --git a/tasks/0.6.0/02-alternatives-v2-embeddings.md b/tasks/0.6.0/02-alternatives-v2-embeddings.md
index 6e03be3..0b53e80 100644
--- a/tasks/0.6.0/02-alternatives-v2-embeddings.md
+++ b/tasks/0.6.0/02-alternatives-v2-embeddings.md
@@ -15,7 +15,7 @@ Postponed from 0.6.0 to backlog. Three concrete reasons:
- Hand-curated `alternatives.yml` for the well-known clusters (`axios β requests β got`, `react β vue β svelte`, `vite β webpack β parcel`). High precision, zero deps, an afternoon to seed at this scale.
- GitHub topics overlap (Jaccard on the `topics` array we already fetch). Captures most cross-language cases for free.
-Revisit alongside `tasks/0.7.0/02-package-registry-overlay.md` or `tasks/1.0.0/02-at-scale-indexing.md` when repo volume + user feedback justify the dep.
+Revisit alongside `tasks/0.8.0/02-package-registry-overlay.md` or `tasks/1.0.0/02-at-scale-indexing.md` when repo volume + user feedback justify the dep.
## Goal
diff --git a/tasks/0.6.0/README.md b/tasks/0.6.0/README.md
index bacde36..bfa08c8 100644
--- a/tasks/0.6.0/README.md
+++ b/tasks/0.6.0/README.md
@@ -2,7 +2,7 @@
**Status**: released
-Keeps the dataset fresh: a 6-hourly GitHub Actions cron re-runs the seed, commits the refreshed `data/rank.db`, and the repo page shows the score delta since the previous rescore. Picks the simplest implementation that delivers the user-facing value rather than the full webhook + queue design originally sketched β see task 01 for the rationale and what was deferred to 0.7.0.
+Keeps the dataset fresh: a 6-hourly GitHub Actions cron re-runs the seed, commits the refreshed `data/rank.db`, and the repo page shows the score delta since the previous rescore. Picks the simplest implementation that delivers the user-facing value rather than the full webhook + queue design originally sketched β see task 01 for the rationale and what was deferred to 0.8.0.
## Tasks
diff --git a/tasks/0.7.0/01-tree-materializer.md b/tasks/0.7.0/01-tree-materializer.md
new file mode 100644
index 0000000..2661647
--- /dev/null
+++ b/tasks/0.7.0/01-tree-materializer.md
@@ -0,0 +1,87 @@
+# 01 Β· Tree materializer
+
+**Status**: done
+
+## Goal
+
+Given a public repo URL, produce a directory on disk that `scoreRepo()` scores **identically to `git clone`** β without a git binary, in ~1.5s for a typical repo.
+
+## Approach
+
+Every signal asks one of three questions: does this path exist, what is in this directory, or what are the bytes of this one file. Only the third is expensive, and only ~14 candidate paths ever need it. So reconstruct the repo from the host's tree API with every path present, and fetch real bytes for just those candidates.
+
+```
+1. List every entry from the host tree API
+2. Walk the entries:
+ directory / submodule β mkdirSync
+ regular file β writeFileSync(path, "")
+ symlink β placeholder, remembered for step 3
+3. Per symlink: fetch its blob β the content IS the target path β and symlinkSync it
+4. Per content candidate: resolveRelative() β realpathSync() β fetch that file's bytes
+5. scoreRepo(dir) β unchanged
+```
+
+Step 4 is what keeps this honest: rather than guessing which filenames need bytes, ask the scorer's own `resolveRelative` against the materialized tree, then let the OS follow symlinks. No parallel resolution logic to drift.
+
+**Content candidates** β derived from every `readSafe` / `readFileSync` call site in `lib/scoring/signals/` plus `lib/badge-adoption.ts`: the four `README` spellings, `AGENTS.md`, `CLAUDE.md`, `AGENT.md`, `.cursorrules`, `.cursor/rules`, `GEMINI.md`, `.openhands/setup.sh`, `package.json`, `pyproject.toml`, `.gitignore`.
+
+## The rule: never be smarter than git
+
+Every divergence found in validation came from the materializer improving on what git does. All five were silent β no error, just a wrong number on a public page.
+
+| Rule | What broke it |
+|---|---|
+| A dangling symlink stays dangling | `cloudflare/vinext` ships `CLAUDE.md -> AGENTS.md` with no AGENTS.md. `raw` 404s, so resolution failed and the placeholder was counted as a real file. Fall back to the blob endpoint, which returns the stored target either way. |
+| Never trim a link target | `vercel/ai` commits `"packages/ai/README.md\n"`. Git leaves it dangling and readme scores 0.3; `.trim()` repaired it and invented 7,061 chars β **6.7 points**. |
+| Submodules are empty directories | `type: "commit"` / mode `160000`. A `--depth 1` clone leaves them as empty dirs; counting them as files inflated `size` by 2 on graphviz. |
+| A cap must degrade, not drop | `MAX_SYMLINKS = 200` silently skipped 51 of `zed-industries/zed`'s 251 links. Write the placeholder first so a failed or capped resolution loses no entry. |
+| Relativise against the resolved root | `realpathSync` resolves the base path too, so a `/tmp` dest returns `/private/tmp` on macOS and `relative()` emits `../../private/tmp/...` β every content fetch 404s and every file lands empty. |
+
+## Substrates rejected, with numbers
+
+- **Tarball** (`codeload`, GitLab `archive.tar.gz`) β the original design, and wrong. Those endpoints run `git archive`, which honors **`export-ignore`** in `.gitattributes`, so the archive omits whatever the maintainer excluded from releases. Measured over 231 repos: **17 scored differently, up to 40 points, in both directions**. composer excludes `/.github/`, `/tests/` and `/README.md`; pandas has 55 such rules. Unfixable β the bytes are not in the archive.
+- **`git clone` in a Vercel function** β no git binary in the runtime.
+- **`isomorphic-git`** (pure JS, no binary needed) β works, but inflates and checks out in JavaScript: `next.js` cost 20.1s wall, **21.6s CPU**, 851 MB RSS, 305 MB disk. Against Hobby's 4 CPU-hrs/month that is ~660 large repos. Still the right tool if a future signal needs **history** (commit dates, blame, maintenance activity), which the tree API cannot provide.
+- **Vercel Sandbox** β real git in a Firecracker microVM, free on Hobby (5 CPU-hrs, 5,000 creations/month, repo downloads unbilled). Rejected only because fidelity is its selling point and the materializer already matches a clone; it costs VM-boot latency and caps at 10 concurrent sandboxes. See `tasks/1.0.0/03-benchmark-harness.md`, where it *is* the right primitive.
+- **Always-on VM (EC2 etc.)** β the only option here whose free tier expires, bills for idle, and adds a deploy target that can drift from the site.
+
+## Per-host cost
+
+| Host | Tree listing | Symlink detection | Content | Verdict |
+|---|---|---|---|---|
+| GitHub | **1 call**, `?recursive=1` | mode `120000` | `raw.githubusercontent.com`, no quota | Ship |
+| GitLab | paginated, 100/entry page | mode `120000` | `/repository/files/{path}/raw` | Guard by size |
+| Bitbucket | paginated, 100/entry page | `attributes: ["link"]` | `/src/{sha}/{path}` | Needs auth |
+
+**GitHub truncation**: `?recursive=1` sets `"truncated": true` on huge repos and stops mid-walk *in sorted order*, so root files can vanish β `JetBrains/kotlin` cut off at 46,620 entries and lost `gradlew`, `LICENSE`, `CONTRIBUTING.md` and `tests/`, worth **26.8 points**. Detect the flag and re-walk one top-level subtree at a time.
+
+**GitLab pagination is the hard limit.** graphviz is 3,411 entries = 35 sequential calls / ~20s. `gitlab-org/gitlab` exceeded 20 minutes and never completed β 1,000+ calls for one score. Needs an entry-count guard that refuses rather than hangs.
+
+**Bitbucket is 60 requests/hour unauthenticated** β exhausted immediately under real traffic. An app password raises it to 1,000/hr.
+
+## Security
+
+Materializing from a path list is safer than tar extraction, but not free:
+
+- Reject absolute paths and any `..` segment; verify the resolved path stays under the destination.
+- Only recreate symlinks whose target resolves inside the tree.
+- Cap total entries, and `finally { rmSync }` on every exit path.
+
+## Validation
+
+`clone β scoreRepo` vs `tree materializer β scoreRepo`, both run fresh from the seed URL, the tree pinned to the SHA the clone actually fetched:
+
+```
+GitHub 341 seeds 340 identical 1 error (DefiLlama/defillama-app deleted upstream)
+GitLab 3 tested 3 identical (gitlab-org/cli, fdroidclient, graphviz)
+Bitbucket 2 tested 2 identical (snakeyaml, x265_git)
+```
+
+Untested: GitLab repos large enough to make pagination impractical β the reason task 03 ships GitHub-only.
+
+## Acceptance
+
+- Materializes a scoreable directory for GitHub, GitLab and Bitbucket.
+- Every rule in the table above has a regression test.
+- A tar-style traversal path (`../evil`, `/etc/passwd`) is rejected.
+- Typical repo completes in ~1.5s excluding network.
diff --git a/tasks/0.7.0/02-score-parity-harness.md b/tasks/0.7.0/02-score-parity-harness.md
new file mode 100644
index 0000000..59e1538
--- /dev/null
+++ b/tasks/0.7.0/02-score-parity-harness.md
@@ -0,0 +1,59 @@
+# 02 Β· Score parity harness
+
+**Status**: done
+
+## Goal
+
+A CI gate that fails when the tree materializer scores differently from a real clone of the same commit. Without it, the live path can silently disagree with the Action and the skill.
+
+## Why this is a gate, not polish
+
+The content candidate list is a hand-derived projection of what the signals read *today*. Add a signal tomorrow that reads `Cargo.toml` and the live path scores it 0 while the Action scores it correctly β no error, no log line, just a wrong number on a public page.
+
+That is not hypothetical. Building the materializer produced **five** defects, every one silent:
+
+| Found | Cost |
+|---|---|
+| `export-ignore` invalidating the tarball substrate | 17 of 231 repos, up to 40 pts |
+| GitHub tree truncation dropping root files | kotlin, 26.8 pts |
+| `.trim()` repairing a broken symlink target | vercel/ai, 6.7 pts |
+| Symlink cap dropping entries instead of degrading | zed, 51 files |
+| Submodules counted as files | graphviz, 2 files |
+
+Plus the bug this harness's throwaway ancestor found in ten minutes: case-sensitive `firstExisting`, shipped in #12, which was under-scoring `vercel/next.js` by 18.5 points on the live leaderboard.
+
+## Approach
+
+For each fixture: shallow-clone it, read its `HEAD` SHA, materialize the tree **pinned to that SHA** so staleness cannot fake a diff, score both, and assert the two `RepoScore` objects match β overall, per-model, and every signal's `pass` / `detail` / `matchedPath`.
+
+Comparing `detail` and not just `overall` is what caught the submodule and symlink bugs: both left the overall score untouched and moved only a file count, one bucket boundary away from mattering.
+
+Fixtures must cover the failure classes rather than just popular repos:
+
+- a non-`README.md` spelling (`Readme.md`)
+- `.cursor/rules` as a directory, no AGENTS.md
+- a dangling symlink (`cloudflare/vinext`)
+- a link target with a trailing newline (`vercel/ai`)
+- submodules (`graphviz/graphviz`)
+- a truncating tree (`JetBrains/kotlin`)
+- one repo per host
+
+## Pieces
+
+1. **`scripts/parity-check.ts`** β clone-vs-materializer comparison, per-signal diff table, non-zero exit on any mismatch.
+2. **`.github/workflows/parity.yml`** β fixture subset on PRs touching `lib/scoring/**` or `lib/live-score/**`; full matrix daily.
+3. **Allowlist-drift detection** β removing a content candidate must make the harness *fail*, not silently skip.
+
+## Runner notes
+
+Learned from the validation run, so the CI job does not rediscover them:
+
+- Run with low concurrency. At four parallel workers, kotlin and pytorch both produced spurious diffs that vanished on isolated re-run.
+- Guard the cleanup. `delta-io/delta` nests Hive fixtures deep enough to blow macOS's `PATH_MAX` during `rmSync`, and an unguarded `finally` killed a 321-repo run outright.
+- Keep the work directory path short, for the same reason.
+
+## Acceptance
+
+- Removing a content candidate fails the harness with a readable diff naming the signal.
+- Each of the five defects above has a fixture that reproduces it against a deliberately reverted fix.
+- Green on `main` before task 03 ships.
diff --git a/tasks/0.7.0/03-live-score-pages.md b/tasks/0.7.0/03-live-score-pages.md
new file mode 100644
index 0000000..68f8a76
--- /dev/null
+++ b/tasks/0.7.0/03-live-score-pages.md
@@ -0,0 +1,68 @@
+# 03 Β· Live score pages
+
+**Status**: done
+
+## Goal
+
+Paste a public repo URL, get its score. Same numbers as the leaderboard, for repos the leaderboard has never seen, with nothing stored anywhere.
+
+## Scope: GitHub first
+
+Ship GitHub only. GitLab and Bitbucket work (task 01 validated both), but neither is ready for public traffic:
+
+- **GitLab** paginates its tree at 100 entries. graphviz needs 35 sequential calls; `gitlab-org/gitlab` exceeded 20 minutes and never completed. Needs an entry-count guard before it faces a user.
+- **Bitbucket** allows 60 API requests/hour unauthenticated β exhausted by a handful of scores. Needs an app password.
+
+Both render as "support coming" on the entry page. A missing host beats a wrong score, and a hung request beats neither.
+
+## Approach
+
+**A page, not an API route.** `app/score/[host]/[owner]/[name]/page.tsx` as a server component calling the materializer + `scoreRepo` directly. A route handler with `Cache-Control` caches repeats but gives no request coalescing β a thousand simultaneous hits on one uncached repo become a thousand cold scores. Path segments rather than a query string, so the URL is cacheable, shareable and readable.
+
+*Corrected after shipping:* this was built as segment-level ISR (`export const revalidate = 3600`), which does not work here β the page reads `searchParams` for `?model=`, and that makes the route dynamic, so the segment cache never applies and every visit re-scored. The caching is now explicit: `unstable_cache` around `liveScore`, keyed by repo and not by model, since the score is identical across models. A throw is never cached, which is what keeps a rate limit or host blip from being pinned on a repo for the hour β transient failures go to `error.tsx` instead of rendering an apology, while the stable outcomes (too large, no such repo) render and cache.
+
+**No JSON API in v1.** `/api/score` already means the indexed lookup and is a documented contract for external integrators.
+
+**Metadata is cached with the page.** `fetchRepoMeta` runs in `Promise.all` with the tree listing, so it adds no latency, and a cache hit costs no call. This makes `GITHUB_TOKEN` **required** in the Vercel environment: `api.github.com` is 60/hr unauthenticated per IP and serverless egress IPs are shared. Tokenized it is 5,000/hr, far above what the CPU budget allows anyway. `fetchRepoMeta` already returns `null` on failure and callers treat the fields as optional, so a throttled call costs the stars line, never the score.
+
+## Pages
+
+**`/score`** β hero, URL input, submit. Fully static.
+
+*Recents*, max 10: seeded at build time from the leaderboard DB so the list is never empty for a first-time visitor, then replaced by the visitor's own successful scores from `localStorage`. Deliberately not a shared list β that would be the only uncacheable read *and* write on the hot path, and it would publicly broadcast what strangers are scoring.
+
+**`/score/[host]/[owner]/[name]`** β reuses the existing repo-detail components.
+
+| Keep | Drop |
+|---|---|
+| Slug + host pill | "Use on your repo" band β no badge to embed for an unindexed repo |
+| Badge pill β free, `detectBadgeEmbed` only reads the README we already fetch | "Last scored" β meaningless when the page *is* the scoring |
+| Stars + default branch β from `fetchRepoMeta` | "Featured" section on the entry page |
+| Strengths / Gaps / per-model suggestions / per-model scores | |
+
+"Last scored" becomes `commit ` β the honest freshness fact for a cached page.
+
+**Already-indexed repos** redirect to `/repo/[id]`: canonical, better SEO, and it absorbs the popular repos that are also the most expensive to score cold.
+
+## Cost
+
+Vercel Hobby's binding resource is **Active CPU, 4 CPU-hours/month**. A cold score is one tree call plus ~4 raw fetches, ~1.5s, almost all of it network wait β which Fluid does not bill. Transfer is not a constraint: responses are a few KB, and the raw fetches count against neither Fast Data Transfer nor Fast Origin Transfer.
+
+Set `maxDuration` explicitly β Hobby defaults to 10s.
+
+Cache hits are served from the edge and never invoke a function, so the ceiling applies only to distinct, uncached repos. Under a stampede of *distinct* repos the cold path must shed load politely rather than melt: reject unparseable URLs before fetching, cap entry counts, and return a plain "at capacity" page rather than a timeout. Per-IP rate limiting needs shared state and is the first thing here that would cost money β add it only if abuse appears.
+
+## SEO
+
+`/score/*` is an unbounded URL space and a crawl trap. `robots.ts` disallows it, the result page sets `robots: { index: false }`, `sitemap.ts` stays limited to `/repo/[id]`, and the redirect above concentrates authority on canonical pages.
+
+`/score` itself is the opposite β a free-tool landing page and the strongest new surface in this release. It carries the full treatment the other tool pages get: keyword-bearing title/description, page keywords, and a JSON-LD `@graph` (BreadcrumbList + WebApplication + FAQPage) backed by a *visible* FAQ, since schema without on-page content is a violation.
+
+## Acceptance
+
+- A GitHub repo not in the leaderboard renders a full score page in one request.
+- An indexed repo redirects to `/repo/[id]`.
+- Second request for the same repo reuses the cached score without re-listing the tree.
+- A GitLab or Bitbucket URL renders a "support coming" state, not an error and not a partial score.
+- Score for a given commit matches what `bun run score ` produces locally.
+- `robots.txt` disallows `/score/`.
diff --git a/tasks/0.7.0/README.md b/tasks/0.7.0/README.md
index 372a1c7..cfdb156 100644
--- a/tasks/0.7.0/README.md
+++ b/tasks/0.7.0/README.md
@@ -1,10 +1,20 @@
-# 0.7.0 β maintainer ownership + at-scale discovery
+# 0.7.0 β score any repo on the fly
-**Status**: planned
+**Status**: released
-Two heavier items that depend on real surface-area additions: an OAuth flow with per-user DB writes, and a registry-side discovery surface (per-registry leaderboards + a browser userscript). Bundled because both require new external touchpoints β auth provider sessions, browser extension distribution, registry-page DOM probes β that warrant a single release cut.
+Today the dashboard only answers "how agent-friendly is a repo we already indexed?". This version answers it for any public GitHub repo, on demand, in about a second β paste a URL, get the same score the GitHub Action and the local skill would produce.
+
+The whole version is built around one constraint: it must stay free, and it must survive a traffic spike without a queue, a database, or a second deploy target. That rules out cloning (no `git` binary in a Vercel function) and rules out persistence (`lib/db.ts` copies the bundled SQLite to `/tmp` per instance, so a write lands on one lambda and vanishes). What's left is a directory materialized from the host's tree API, scored by the untouched `scoreRepo()`, served from an ISR page so repeat traffic never reaches a function.
+
+Nothing here writes to `data/rank.db`. The live path and the leaderboard share `lib/scoring/` and nothing else.
+
+GitLab and Bitbucket are implemented and validated but ship as "support coming" β see task 03 for why.
## Tasks
-- [01-opt-out-claim-flow.md](./01-opt-out-claim-flow.md) β OAuth so maintainers can claim or opt out of their listing. First touchpoint that writes to the DB on behalf of a user.
-- [02-package-registry-overlay.md](./02-package-registry-overlay.md) β at-scale package overlay: per-registry leaderboards on the dashboard + a browser userscript that renders the badge inline on npmjs.com / PyPI / crates.io. Builds on the v0.3.0 lookup endpoint.
+- [01-tree-materializer.md](./01-tree-materializer.md) β build a scoreable directory from a host tree API: every path present, real bytes only where a signal reads them. Records the substrates tested and rejected.
+- [02-score-parity-harness.md](./02-score-parity-harness.md) β CI gate asserting the materializer scores identically to a real clone. The mechanism that keeps the live path honest as signals change.
+- [03-live-score-pages.md](./03-live-score-pages.md) β `/score` entry page and the `/score/[host]/[owner]/[name]` result page.
+- [04-release-announcement.md](./04-release-announcement.md) β once-per-release notice on the home page, driven by `lib/changelog.ts`.
+
+Sequencing matters: 02 lands before 03. Every defect found while building 01 was silent β a plausible wrong score, never an error β so the gate has to exist before the page is public.
diff --git a/tasks/0.7.0/01-opt-out-claim-flow.md b/tasks/0.8.0/01-opt-out-claim-flow.md
similarity index 100%
rename from tasks/0.7.0/01-opt-out-claim-flow.md
rename to tasks/0.8.0/01-opt-out-claim-flow.md
diff --git a/tasks/0.7.0/02-package-registry-overlay.md b/tasks/0.8.0/02-package-registry-overlay.md
similarity index 96%
rename from tasks/0.7.0/02-package-registry-overlay.md
rename to tasks/0.8.0/02-package-registry-overlay.md
index e56f379..7405961 100644
--- a/tasks/0.7.0/02-package-registry-overlay.md
+++ b/tasks/0.8.0/02-package-registry-overlay.md
@@ -4,7 +4,7 @@
## Goal
-The v0.3.0 lookup (`tasks/0.3.0/05-package-registry-overlay.md`) answers "is this specific package scored?" on demand. v0.7.0 turns that into a proactive dependency-choice signal: per-registry leaderboards and a browser-side overlay that renders our badge inline on npmjs.com / PyPI / crates.io.
+The v0.3.0 lookup (`tasks/0.3.0/05-package-registry-overlay.md`) answers "is this specific package scored?" on demand. v0.8.0 turns that into a proactive dependency-choice signal: per-registry leaderboards and a browser-side overlay that renders our badge inline on npmjs.com / PyPI / crates.io.
## Scope
diff --git a/tasks/0.8.0/README.md b/tasks/0.8.0/README.md
new file mode 100644
index 0000000..43c7808
--- /dev/null
+++ b/tasks/0.8.0/README.md
@@ -0,0 +1,10 @@
+# 0.8.0 β maintainer ownership + at-scale discovery
+
+**Status**: planned
+
+Two heavier items that depend on real surface-area additions: an OAuth flow with per-user DB writes, and a registry-side discovery surface (per-registry leaderboards + a browser userscript). Bundled because both require new external touchpoints β auth provider sessions, browser extension distribution, registry-page DOM probes β that warrant a single release cut.
+
+## Tasks
+
+- [01-opt-out-claim-flow.md](./01-opt-out-claim-flow.md) β OAuth so maintainers can claim or opt out of their listing. First touchpoint that writes to the DB on behalf of a user.
+- [02-package-registry-overlay.md](./02-package-registry-overlay.md) β at-scale package overlay: per-registry leaderboards on the dashboard + a browser userscript that renders the badge inline on npmjs.com / PyPI / crates.io. Builds on the v0.3.0 lookup endpoint.
diff --git a/tasks/1.0.0/03-benchmark-harness.md b/tasks/1.0.0/03-benchmark-harness.md
index 25e2dd6..0e8124a 100644
--- a/tasks/1.0.0/03-benchmark-harness.md
+++ b/tasks/1.0.0/03-benchmark-harness.md
@@ -13,6 +13,10 @@ Actually run agents on scoped tasks derived from each repo's own git history, so
- Regress signal presence Γ agent-success to derive weights.
- Publish the harness open-source; publish the derived weights.
+## Execution environment
+
+This is the one task in the roadmap that has to actually *run* repos β install dependencies, execute tests, invoke an agent β rather than read them. [Vercel Sandbox](https://vercel.com/docs/sandbox) fits that shape: Firecracker microVMs with root, real `git`, 45-minute sessions, 32 GB disk, and a Hobby allowance of 5 CPU-hours / 5,000 creations / month with repo downloads unbilled. Evaluated and rejected for the 0.7.0 live scorer (see `tasks/0.7.0/01-tree-materializer.md`) because that path needs no execution, so VM-boot latency and a 10-sandbox concurrency cap bought nothing there. Both constraints are irrelevant to a batch harness.
+
## Risks
- Cost at scale β sample small first.
diff --git a/tests/live-score.test.ts b/tests/live-score.test.ts
new file mode 100644
index 0000000..bfa469e
--- /dev/null
+++ b/tests/live-score.test.ts
@@ -0,0 +1,85 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, it } from "node:test";
+
+import { CONTENT_CANDIDATES } from "../lib/live-score/content-files";
+import { blobUrl, rawUrl } from "../lib/live-score/hosts";
+import { safeAbsolute } from "../lib/live-score/materialize";
+import { SUPPORTED_HOSTS } from "../lib/live-score/supported";
+
+const SIGNALS_DIR = join(process.cwd(), "lib", "scoring", "signals");
+
+describe("content candidates", () => {
+ // The live path fetches bytes only for these paths. A signal that reads a file
+ // absent from the list scores it as empty β silently, with no error β so the
+ // list has to stay ahead of the signals rather than behind them.
+ it("covers every candidate list belonging to a content-reading signal", () => {
+ const lower = new Set(CONTENT_CANDIDATES.map((c) => c.toLowerCase()));
+
+ const readers: Record = {
+ "readme.ts": ["README.md", "README.rst", "README.txt", "README"],
+ "agents-md.ts": ["AGENTS.md", "CLAUDE.md", "AGENT.md", ".cursor/rules", ".cursorrules"],
+ "gemini-md.ts": ["GEMINI.md"],
+ "openhands-setup.ts": [".openhands/setup.sh"],
+ "dev-env.ts": ["package.json"],
+ "linter.ts": ["pyproject.toml"],
+ "type-config.ts": ["pyproject.toml"],
+ "size.ts": [".gitignore"],
+ };
+
+ for (const [file, paths] of Object.entries(readers)) {
+ const source = readFileSync(join(SIGNALS_DIR, file), "utf8");
+ assert.match(source, /readSafe|readFileSync/, `${file} is expected to read file contents`);
+
+ for (const path of paths) {
+ assert.ok(lower.has(path.toLowerCase()), `${file} reads ${path}, missing from CONTENT_CANDIDATES`);
+ }
+ }
+ });
+
+ it("matches case-insensitively, since firstExisting does", () => {
+ // expressjs/express spells it Readme.md; an exact-match allowlist scored it 0.
+ const lower = CONTENT_CANDIDATES.map((c) => c.toLowerCase());
+ assert.ok(lower.includes("readme.md"));
+ assert.equal(new Set(lower).size, lower.length, "duplicate candidates differing only by case");
+ });
+});
+
+describe("host URLs", () => {
+ it("encodes GitLab subgroups into the project id", () => {
+ const url = rawUrl("gitlab", "group/sub", "project", "abc123", "README.md");
+ assert.ok(url.includes("group%2Fsub%2Fproject"), url);
+ });
+
+ it("offers a blob fallback only where the host has one", () => {
+ assert.ok(blobUrl("github", "o", "n", "sha"));
+ assert.ok(blobUrl("gitlab", "o", "n", "sha"));
+ assert.equal(blobUrl("bitbucket", "o", "n", "sha"), null);
+ });
+});
+
+describe("supported hosts", () => {
+ it("ships GitHub only until GitLab pagination and Bitbucket rate limits are guarded", () => {
+ assert.deepEqual(SUPPORTED_HOSTS, ["github"]);
+ });
+});
+
+describe("path safety", () => {
+ const dest = "/tmp/afc-dest";
+
+ it("accepts ordinary repo paths", () => {
+ assert.equal(safeAbsolute(dest, "src/index.ts"), `${dest}/src/index.ts`);
+ assert.equal(safeAbsolute(dest, ".github/workflows/ci.yml"), `${dest}/.github/workflows/ci.yml`);
+ });
+
+ it("rejects traversal and absolute paths", () => {
+ for (const hostile of ["../evil", "a/../../evil", "/etc/passwd", ".."]) {
+ assert.equal(safeAbsolute(dest, hostile), null, `${hostile} should be rejected`);
+ }
+ });
+
+ it("rejects an empty path", () => {
+ assert.equal(safeAbsolute(dest, ""), null);
+ });
+});
From 482e0aa0b85763e235c3b958e9db2704c6d8c207 Mon Sep 17 00:00:00 2001
From: Himanshu Singh
Date: Tue, 25 Aug 2026 16:36:41 +0530
Subject: [PATCH 2/6] Announce each release once on the home page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/changelog has always carried this and nobody goes looking for it; the
version pill is a fact with no story attached. The notice renders
CHANGELOG[0].title and nothing else, so there is no second copy of the
release description to drift.
The seen marker stores the version rather than a boolean, which is what
makes the next release show again with no reset step or expiry logic. It
is written at the moment of display, not on dismiss, so "exactly once per
release" holds for a visitor who leaves after two seconds.
Anchored under the nav link for the page the release is about, measured
from that link's own rect β the nav's contents decide where it lands and
they change. Below md the header collapses to a hamburger, so there is
nothing to point at and nothing renders; the marker is deliberately not
written in that case, or the one announcement would be spent on a screen
that never showed it.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/globals.css | 13 +++
app/page.tsx | 22 ++++-
components/ReleaseAnnouncement.tsx | 132 +++++++++++++++++++++++++
lib/release-notice.ts | 23 +++++
tasks/0.7.0/04-release-announcement.md | 44 +++++++++
5 files changed, 233 insertions(+), 1 deletion(-)
create mode 100644 components/ReleaseAnnouncement.tsx
create mode 100644 lib/release-notice.ts
create mode 100644 tasks/0.7.0/04-release-announcement.md
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/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.
+
(`header a[href="${href}"]`);
+ if (!link) return null;
+
+ const rect = link.getBoundingClientRect();
+ if (rect.width === 0) return null;
+
+ const center = rect.left + rect.width / 2;
+ const rightMost = Math.max(window.innerWidth - WIDTH - EDGE, EDGE);
+ const left = Math.min(Math.max(center - WIDTH / 2, EDGE), rightMost);
+
+ return { left, top: rect.bottom + GAP, pointer: center - left };
+}
+
+export function ReleaseAnnouncement({ version, title, anchorHref }: Props) {
+ // One state, not an `open` flag beside it: anchored *is* open, so the
+ // "showing but unpositioned" combination cannot be represented. Never set
+ // during the server render β localStorage is unreadable there, and deciding
+ // at render time would hydrate a mismatch.
+ const [anchor, setAnchor] = useState(null);
+ const isOpen = anchor !== null;
+
+ const dismiss = useCallback(() => setAnchor(null), []);
+
+ useEffect(() => {
+ if (hasSeenRelease(version)) return;
+
+ // Measured before anything else: on a hamburger-width screen there is no
+ // anchor, so nothing is shown β and nothing is marked seen either, or the
+ // one announcement would be spent on a screen that never displayed it.
+ const at = anchorTo(anchorHref);
+ if (!at) return;
+
+ // Marked on show, not on hide: a visitor who leaves after two seconds has
+ // still had their one announcement, and a reload should not repeat it.
+ markReleaseSeen(version);
+ setAnchor(at);
+
+ const timer = setTimeout(() => setAnchor(null), AUTO_HIDE_MS);
+ return () => clearTimeout(timer);
+ }, [version, anchorHref]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ // The header is `sticky top-0`, so the link never moves on scroll β only a
+ // resize can invalidate the measurement. Narrowing into the hamburger
+ // breakpoint returns null, which closes it.
+ const measure = () => setAnchor(anchorTo(anchorHref));
+
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") dismiss();
+ };
+
+ window.addEventListener("resize", measure);
+ document.addEventListener("keydown", onKey);
+
+ return () => {
+ window.removeEventListener("resize", measure);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [isOpen, dismiss, anchorHref]);
+
+ if (!anchor) return null;
+
+ return (
+
+
+
+
+
New in v{version}
+
+
+
+
+
+
+ {title}
+
+
+ See what shipped β
+
+
+ );
+}
diff --git a/lib/release-notice.ts b/lib/release-notice.ts
new file mode 100644
index 0000000..129d634
--- /dev/null
+++ b/lib/release-notice.ts
@@ -0,0 +1,23 @@
+const KEY = "afc:release-seen";
+
+// Per-visitor and client-only: there are no accounts to hang a "seen" flag on,
+// and a server-side one would make the home page uncacheable for everyone to
+// personalise a single line of chrome.
+//
+// The stored value is the version that was announced, not a boolean β that is
+// what makes the next release show again without any reset step.
+export function hasSeenRelease(version: string): boolean {
+ try {
+ return window.localStorage.getItem(KEY) === version;
+ } catch {
+ // Private mode / storage disabled. Announcing every visit is a worse
+ // failure than announcing none, so treat it as already seen.
+ return true;
+ }
+}
+
+export function markReleaseSeen(version: string): void {
+ try {
+ window.localStorage.setItem(KEY, version);
+ } catch {}
+}
diff --git a/tasks/0.7.0/04-release-announcement.md b/tasks/0.7.0/04-release-announcement.md
new file mode 100644
index 0000000..a0ba622
--- /dev/null
+++ b/tasks/0.7.0/04-release-announcement.md
@@ -0,0 +1,44 @@
+# 04 Β· Release announcement
+
+**Status**: done
+
+## Goal
+
+Tell a returning visitor, once, that a release shipped β then get out of the way and stay quiet until the next one.
+
+`/changelog` has always carried this information and nobody goes looking for it. The release number in the header is a fact with no story attached. This is the one place the two meet.
+
+## Approach
+
+**Driven by `lib/changelog.ts`, not by its own copy.** The notice renders `CHANGELOG[0].title` and nothing else. A dedicated blurb would be a second description of the same release, and the second copy is the one that goes stale. The cost is that release titles now have to read well in isolation β an acceptable constraint, since they already appear as `/changelog` headings.
+
+**Gated on `CHANGELOG[0].label === APP_VERSION`.** The two are bumped together by convention but not by the type system, and a mismatch means one of them is mid-edit. Announcing nothing beats announcing the wrong release.
+
+**The "seen" marker stores the version, not a boolean.** `localStorage["afc:release-seen"] = "0.7.0"`. That is what makes the next release show again with no reset step, no migration, and no expiry logic. A boolean would need one of those three.
+
+**Marked seen on show, not on dismiss.** A visitor who leaves after two seconds has had their one announcement; a reload should not repeat it. "Exactly once per release" is the property worth having, and it is only achievable by writing at the moment of display.
+
+**Client-only, home page only.** There are no accounts to hang the flag on, and a server-side one would make the home page uncacheable for everyone in order to personalise a single line of chrome. Storage throwing (private mode, storage disabled) is treated as *already seen* β announcing on every single visit is a worse failure than announcing on none.
+
+## Placement
+
+Anchored under the nav link for the page the release is about (`anchorHref`), so the pointer means something: *this* is the new thing, go here. The first cut pointed at the version pill β the same fact in shorter form, but it made the notice about a number rather than about a feature, and the pointer landed beside the pill rather than under it.
+
+The position is measured from the link's own bounding rect, not offset from the container edge. The nav's contents decide where the link lands and those contents change β a link was added mid-development, which would have silently broken any hardcoded offset. The measurement re-runs on resize but not on scroll: the header is `sticky top-0`, so the link cannot move under the page.
+
+**Full-nav screens only.** When the link has zero width the header has collapsed to a hamburger (below `md`), and the notice does not render at all β a pointer with nothing to point at is worse than silence, and the small viewport is where an unbidden card costs the most.
+
+That makes the *order* inside the effect load-bearing: measure first, and return before `markReleaseSeen` when there is no anchor. Marking first would spend the one announcement on a screen that never showed it, and the visitor would never see it on that browser again. A resize that narrows past the breakpoint closes an open notice for the same reason it was never opened.
+
+`role="status"` with `aria-live="polite"`, not `dialog`: it steals no focus and blocks nothing, so it must not announce itself as modal. Dismissible by button or Escape, and auto-hides after 12 seconds. The entrance animation needs no reduced-motion guard β `globals.css` already disables all animation under `prefers-reduced-motion: reduce`.
+
+## Acceptance
+
+- First home-page visit after a release shows the notice; a reload does not.
+- Bumping `APP_VERSION` with a matching changelog entry shows it again.
+- A version/changelog mismatch shows nothing.
+- Dismiss button and Escape both close it; it never traps focus.
+- Storage unavailable β no notice, no error.
+- Pointer centres under the anchor link at 1440 and 800 wide, and the card stays inside the viewport at both.
+- At hamburger width nothing renders **and** nothing is marked seen β widening and reloading still shows it.
+- Narrowing past the breakpoint while it is open closes it.
From 8990f2c984b47b488c27858c37eca4c9928aa3b2 Mon Sep 17 00:00:00 2001
From: Himanshu Singh
Date: Tue, 25 Aug 2026 16:36:54 +0530
Subject: [PATCH 3/6] Delete each clone after scoring and move the workspace
out of the project
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`bun run seed` scores 350+ repos and kept every tree, which had grown to
36 GB locally. Nothing downstream reads the tree again β the score is
already in memory by then.
The workspace also moves from ./tmp-clones to the OS temp dir, because a
clone inside the project is part of the Next.js module graph: one repo
with a symlink loop (stripe/ai has one) fails `next build` with a
Turbopack panic that names none of this.
The dynamic join/readdirSync calls in the scorer make the tracer give up
and pull the whole project into every function bundle. The scorer can't
be annotated away β it is vendored verbatim into both sibling repos β so
the weight is trimmed with outputFileTracingExcludes instead.
Co-Authored-By: Claude Opus 5 (1M context)
---
next.config.ts | 8 ++++++++
scripts/score.ts | 28 ++++++++++++++++++++++++----
2 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/next.config.ts b/next.config.ts
index 8c72b47..74670c8 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -3,10 +3,18 @@ import type { NextConfig } from "next";
// lib/db.ts opens data/rank.db via `join(process.cwd(), ...)`, which Next.js's
// static file tracer can't follow β without this, the DB is missing from the
// serverless function bundle on Vercel and /api/repos returns stale data.
+//
+// The same dynamic `join` / `readdirSync` calls in the scorer and the tree
+// materializer make the tracer give up and pull the *entire* project into every
+// function bundle. The scorer can't be annotated away β it is vendored verbatim
+// into the sibling action and skill repos β so the weight is trimmed here.
const config: NextConfig = {
outputFileTracingIncludes: {
"/*": ["./data/rank.db"],
},
+ outputFileTracingExcludes: {
+ "/*": ["./tasks/**", "./tests/**", "./public/**", "./.claude/**", "./.next/cache/**"],
+ },
};
export default config;
diff --git a/scripts/score.ts b/scripts/score.ts
index 867de86..d01fd71 100644
--- a/scripts/score.ts
+++ b/scripts/score.ts
@@ -1,4 +1,5 @@
-import { existsSync, mkdirSync, statSync } from "node:fs";
+import { existsSync, mkdirSync, rmSync, statSync } from "node:fs";
+import { tmpdir } from "node:os";
import { join } from "node:path";
import { detectBadgeEmbed } from "../lib/badge-adoption";
@@ -11,11 +12,18 @@ try {
process.loadEnvFile();
} catch {}
-const CLONE_ROOT = join(process.cwd(), "tmp-clones");
+// Outside the project on purpose. A clone inside it is part of the Next.js
+// module graph: one repo with a symlink loop (stripe/ai has one) fails
+// `next build` with a Turbopack panic that names none of this.
+const CLONE_ROOT = join(tmpdir(), "afc-clones");
async function scoreCommand(target: string): Promise {
const startedAt = Date.now();
+ // Set only for a clone we made. A local-path target is the caller's own
+ // directory and must never be swept.
+ let cloned: string | null = null;
+
let url = "";
let name = "";
let owner = "";
@@ -50,6 +58,7 @@ async function scoreCommand(target: string): Promise {
console.log(`[clone] ${parsed.cloneUrl} β ${repoPath}`);
await shallowClone(parsed.cloneUrl, repoPath);
+ cloned = repoPath;
const meta = await fetchRepoMeta(parsed);
@@ -61,8 +70,19 @@ async function scoreCommand(target: string): Promise {
}
console.log(`[score] scanning ${repoPath}`);
- const result = scoreRepo(repoPath);
- const badgeEmbedded = detectBadgeEmbed(repoPath, `${host}/${owner}/${name}`);
+
+ let result: ReturnType;
+ let badgeEmbedded: boolean;
+
+ try {
+ result = scoreRepo(repoPath);
+ badgeEmbedded = detectBadgeEmbed(repoPath, `${host}/${owner}/${name}`);
+ } finally {
+ // `bun run seed` scores 350+ repos in one pass; keeping every tree would
+ // put tens of gigabytes under CLONE_ROOT and eventually fill the disk.
+ // Nothing downstream reads the tree again β the score is already in memory.
+ if (cloned) rmSync(cloned, { recursive: true, force: true });
+ }
saveScoredRepo({
url,
From c5348f1149aa44149000669065043ed112e9e5f1 Mon Sep 17 00:00:00 2001
From: Himanshu Singh
Date: Tue, 25 Aug 2026 16:37:10 +0530
Subject: [PATCH 4/6] Run next build in CI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
Co-Authored-By: Claude Opus 5 (1M context)
---
.github/workflows/ci.yml | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
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
From 6b9bfd2c577a0e1ff2301dcfd22fbe71a0b1ce10 Mon Sep 17 00:00:00 2001
From: Himanshu Singh
Date: Tue, 25 Aug 2026 16:37:10 +0530
Subject: [PATCH 5/6] Cache DB-backed API responses at the edge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
data/rank.db ships inside the deployment, so these responses cannot change
until the next deploy. Uncached, every caller re-serialised the table β
/api/repos returns the whole leaderboard. Matches the headers the badge
and package routes already set.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/api/repo/[id]/route.ts | 15 ++++++++++-----
app/api/repos/route.ts | 6 +++++-
app/api/score/route.ts | 15 ++++++++++-----
3 files changed, 25 insertions(+), 11 deletions(-)
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 },
+ );
}
From 64d15f2ba8abb959c962372a3e7327fb1e6e6b3d Mon Sep 17 00:00:00 2001
From: Himanshu Singh
Date: Tue, 25 Aug 2026 16:37:13 +0530
Subject: [PATCH 6/6] Expand curated seed set by 18 repos
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Weighted toward the thin sections rather than JS/TS: Swift and Dart gain
two each, PHP two, GitLab two. gitlab.com/kicad/code/kicad is a subgroup
path, which exercises the nested-namespace branch of parseRepoUrl that
only gitlab-org/* covered.
Each verified against its host API as public, non-fork, non-archived and
not renamed β the criteria audit-seeds.ts enforces.
Co-Authored-By: Claude Opus 5 (1M context)
---
scripts/seed-list.ts | 22 ++++++++++++++++++----
1 file changed, 18 insertions(+), 4 deletions(-)
diff --git a/scripts/seed-list.ts b/scripts/seed-list.ts
index 45971d0..c816f40 100644
--- a/scripts/seed-list.ts
+++ b/scripts/seed-list.ts
@@ -69,10 +69,6 @@ export const SEEDS: Seed[] = [
{ url: "https://github.com/vercel/swr", note: "SWR data fetching" },
{ url: "https://github.com/microsoft/vscode", note: "VS Code editor" },
{ url: "https://github.com/Uniswap/interface", note: "Uniswap web app" },
- {
- note: "DefiLlama dashboard",
- url: "https://github.com/DefiLlama/defillama-app",
- },
{
note: "Wormhole Connect β cross-chain widget",
url: "https://github.com/wormhole-foundation/wormhole-connect",
@@ -153,6 +149,9 @@ export const SEEDS: Seed[] = [
{ url: "https://github.com/colinhacks/zod", note: "Zod β TypeScript-first schema validation" },
{ url: "https://github.com/statelyai/xstate", note: "XState β state machines / statecharts" },
{ url: "https://github.com/date-fns/date-fns", note: "date-fns β modern JS date utility library" },
+ { url: "https://github.com/pnpm/pnpm", note: "pnpm β disk-efficient package manager" },
+ { url: "https://github.com/immerjs/immer", note: "Immer β immutable state updates" },
+ { url: "https://github.com/mermaid-js/mermaid", note: "Mermaid β diagrams from text" },
// --- GitHub, Python ---
{
@@ -229,6 +228,7 @@ export const SEEDS: Seed[] = [
{ url: "https://github.com/Textualize/textual", note: "Textual β Python TUI framework" },
{ url: "https://github.com/aio-libs/aiohttp", note: "aiohttp β async HTTP client / server" },
{ url: "https://github.com/tqdm/tqdm", note: "tqdm β fast, extensible progress bar" },
+ { url: "https://github.com/astral-sh/uv", note: "uv β Python package + project manager" },
// --- GitHub, Rust ---
{
@@ -350,6 +350,7 @@ export const SEEDS: Seed[] = [
{ url: "https://github.com/uber-go/zap", note: "zap β blazing-fast structured logging for Go" },
{ url: "https://github.com/stretchr/testify", note: "testify β Go assertions + mocks toolkit" },
{ url: "https://github.com/moby/moby", note: "Moby β the upstream container engine behind Docker" },
+ { url: "https://github.com/go-gitea/gitea", note: "Gitea β self-hosted Git service" },
// --- GitHub, C / C++ / systems ---
{
@@ -386,6 +387,7 @@ export const SEEDS: Seed[] = [
{ url: "https://github.com/llvm/llvm-project", note: "LLVM β compiler infrastructure + Clang" },
{ url: "https://github.com/openssl/openssl", note: "OpenSSL β TLS / cryptography library" },
{ url: "https://github.com/git/git", note: "Git β the version control system itself" },
+ { url: "https://github.com/ocornut/imgui", note: "Dear ImGui β immediate-mode GUI" },
// --- GitHub, JVM (Java / Kotlin) ---
{
@@ -415,6 +417,7 @@ export const SEEDS: Seed[] = [
},
{ url: "https://github.com/ReactiveX/RxJava", note: "RxJava β reactive extensions for the JVM" },
{ url: "https://github.com/square/retrofit", note: "Retrofit β type-safe HTTP client for Java / Android" },
+ { url: "https://github.com/quarkusio/quarkus", note: "Quarkus β Kubernetes-native Java" },
// --- GitHub, Swift ---
{ url: "https://github.com/apple/swift", note: "Swift language" },
@@ -428,6 +431,8 @@ export const SEEDS: Seed[] = [
url: "https://github.com/pointfreeco/swift-composable-architecture",
},
{ url: "https://github.com/apple/swift-nio", note: "SwiftNIO β async event-driven network framework" },
+ { url: "https://github.com/ReactiveX/RxSwift", note: "RxSwift β reactive extensions" },
+ { url: "https://github.com/apple/swift-argument-parser", note: "swift-argument-parser β CLI parsing" },
// --- GitHub, Ruby ---
{
@@ -456,6 +461,8 @@ export const SEEDS: Seed[] = [
},
{ url: "https://github.com/flutter/flutter", note: "Flutter SDK β cross-platform UI toolkit" },
{ url: "https://github.com/serverpod/serverpod", note: "Serverpod β Dart backend framework" },
+ { url: "https://github.com/localsend/localsend", note: "LocalSend β cross-platform file sharing" },
+ { url: "https://github.com/rrousselGit/riverpod", note: "Riverpod β Flutter state management" },
// --- GitHub, .NET / C# ---
{
@@ -498,6 +505,8 @@ export const SEEDS: Seed[] = [
},
{ url: "https://github.com/guzzle/guzzle", note: "Guzzle β PHP HTTP client" },
{ url: "https://github.com/nikic/PHP-Parser", note: "PHP-Parser β PHP parser written in PHP" },
+ { url: "https://github.com/api-platform/core", note: "API Platform β PHP API framework" },
+ { url: "https://github.com/phpstan/phpstan", note: "PHPStan β static analysis" },
// --- GitHub, Elixir / Phoenix ---
{
@@ -519,6 +528,7 @@ export const SEEDS: Seed[] = [
note: "Plausible β privacy-first web analytics (Elixir + Phoenix)",
},
{ url: "https://github.com/oban-bg/oban", note: "Oban β background job processing for Elixir" },
+ { url: "https://github.com/ash-project/ash", note: "Ash β declarative Elixir framework" },
// --- GitHub, language runtimes ---
{ url: "https://github.com/denoland/deno", note: "Deno runtime (Rust)" },
@@ -529,6 +539,7 @@ export const SEEDS: Seed[] = [
{ url: "https://github.com/nodejs/node", note: "Node.js runtime" },
{ url: "https://github.com/ruby/ruby", note: "Ruby language" },
{ url: "https://github.com/ziglang/zig", note: "Zig language + compiler" },
+ { url: "https://github.com/dart-lang/sdk", note: "Dart SDK" },
// --- GitHub, functional + niche languages ---
{ url: "https://github.com/jgm/pandoc", note: "Pandoc β universal document converter (Haskell)" },
@@ -541,6 +552,7 @@ export const SEEDS: Seed[] = [
{ url: "https://github.com/erlang/otp", note: "Erlang/OTP β the Erlang runtime and libraries" },
{ url: "https://github.com/nim-lang/Nim", note: "Nim β statically typed compiled systems language" },
{ url: "https://github.com/JuliaLang/julia", note: "Julia β scientific computing language" },
+ { url: "https://github.com/gleam-lang/gleam", note: "Gleam β typed language on the BEAM" },
// --- AI-native: coding agents ---
{
@@ -827,6 +839,8 @@ export const SEEDS: Seed[] = [
{ url: "https://gitlab.com/fdroid/fdroidclient", note: "F-Droid client β FOSS Android app store (Java)" },
{ url: "https://gitlab.com/graphviz/graphviz", note: "Graphviz β graph visualization software (C)" },
{ url: "https://gitlab.com/libeigen/eigen", note: "Eigen β C++ template library for linear algebra" },
+ { url: "https://gitlab.com/gnachman/iterm2", note: "iTerm2 β macOS terminal" },
+ { url: "https://gitlab.com/kicad/code/kicad", note: "KiCad β EDA suite (subgroup path)" },
// --- Bitbucket ---
{