diff --git a/.github/workflows/scheduled-rescore.yml b/.github/workflows/scheduled-rescore.yml index b6cdef3..b04e7ff 100644 --- a/.github/workflows/scheduled-rescore.yml +++ b/.github/workflows/scheduled-rescore.yml @@ -39,12 +39,17 @@ jobs: - name: Snapshot pre-rescore state run: sqlite3 data/rank.db "SELECT url, overall_score, badge_embedded FROM repo ORDER BY url;" > /tmp/state-before.txt + - name: Note when the rescore started + id: started + run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - name: Rescore curated seeds env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: bun run seed - name: Commit and push if the repo set, a score or badge adoption changed + id: publish run: | sqlite3 data/rank.db "SELECT url, overall_score, badge_embedded FROM repo ORDER BY url;" > /tmp/state-after.txt if diff -q /tmp/state-before.txt /tmp/state-after.txt > /dev/null; then @@ -57,3 +62,13 @@ jobs: git add data/rank.db git commit -m "chore: scheduled rescore $(date -u +%Y-%m-%dT%H:%MZ)" git push + echo "pushed=true" >> "$GITHUB_OUTPUT" + + # The push only starts the deploy. A crawler told about a page before the + # new build serves it re-reads the old one and has no reason to come back. + - name: Tell IndexNow which pages changed + if: steps.publish.outputs.pushed == 'true' + continue-on-error: true + run: | + sleep 300 + bun run indexnow --since "${{ steps.started.outputs.at }}" diff --git a/AGENTS.md b/AGENTS.md index 46bd3e4..902febc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ bun run score # score a single repo bun run audit-seeds # check every seed is public, original, still there, and still being rescored 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 ≥22 +bun run indexnow # submit sitemap URLs to IndexNow (--since for changed pages only, --dry-run to list them) ``` ## Layout @@ -52,6 +53,8 @@ app/ methodology/page.tsx # how the static scoring works language/page.tsx # every language: hub cards, plus the repos of languages too small for a hub language/[slug]/page.tsx # language hub — that language's ranked repos and its most-missed cross-agent checks + check/page.tsx # every check with its pass rate across the leaderboard + check/[slug]/page.tsx # one check — prose from lib/check-content, pass rates and example repos from lib/check-stats about/page.tsx # who built this and why (footer-linked, E-E-A-T) roadmap/page.tsx # upcoming versions (from lib/roadmap.ts) changelog/page.tsx # what's in this build (from lib/changelog.ts) @@ -60,7 +63,7 @@ app/ robots.ts # /robots.txt — wildcard + explicit AI-crawler allows. Filtered leaderboard views are deduped # with noindex+follow in page.tsx, not a Disallow — a blocked URL hides that directive. # Next.js `_rsc` prefetch requests are disallowed: they were most of Googlebot's crawl - sitemap.ts # /sitemap.xml — static routes, agent boards, language hubs, every repo (priority scaled by score), top packages + sitemap.ts # /sitemap.xml — static routes, check pages, agent boards, language hubs, every repo (priority scaled by score), top packages llms.txt/route.ts # /llms.txt — markdown manifest for LLM crawlers (Perplexity, Claude, ChatGPT search) api/repos/route.ts api/repo/[id]/route.ts @@ -89,6 +92,7 @@ components/ # Tailwind-styled React components SignalRow.tsx, SuggestionItem.tsx, VersionPill.tsx, RepoHero.tsx, RepoSummary.tsx, ScoreDeltaPopover.tsx, SignalListCard.tsx, ModelSuggestions.tsx, PerModelScores.tsx, AlternativesStrip.tsx, BreadcrumbJsonLd.tsx, HomeJsonLd.tsx, ExternalLink.tsx, + CheckRepoList.tsx, CodeText.tsx, BadgeEmbed.tsx, ActionEmbed.tsx, PeerlistBadge.tsx, ProductHuntBadge.tsx, CopySnippet.tsx, PackageLookupForm.tsx, BadgeAdoptedTag.tsx, BackToTop.tsx, GoogleAnalytics.tsx, @@ -100,7 +104,8 @@ lib/ hosts.ts # host label map sort.ts # leaderboard sort keys utils/ - format.ts # compactStars, relativeTime, hostLabel + format.ts # compactStars, relativeTime, hostLabel, percent + check-path.ts # checkSlug / checkPath / signalIdFromSlug — the one place a check URL is built or parsed score.ts # scoreTier + Tailwind class maps badge.ts # SVG badge renderer (used by /api/badge) contact.ts # packageRequestIssueUrl — pre-filled GitHub issue link for unscored packages @@ -125,6 +130,8 @@ lib/ score.ts # liveScore(): commit resolve + metadata + materialize + scoreRepo package-lookup.ts # shared registry → repo lookup (used by /api/package + /package page) language-hubs.ts # language groups + hub stats (used by /language pages, sitemap) + check-content/ # hand-written prose for each /check/ page, keyed by signal id + check-stats.ts # per-check pass counts, by-language rates, example repos (used by /check pages) leaderboard-view.ts # search params → the rows, page and filters one board shows (used by / and /model/) 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 connection + queries (RANK_DB_PATH overrides the file) @@ -141,12 +148,14 @@ scripts/ seed.ts # reconciles the DB with seed-list.ts (rename in place, then prune), then scores every seed check-db-leaks.ts # findLeaks gate — lefthook, ci.yml, and the rescore workflow before it pushes parity-check.ts # asserts the live-score path equals `bun run score`. CI gate: .github/workflows/parity.yml + indexnow.ts # submits sitemap URLs to IndexNow; the key is public/.txt audit-seeds.ts # flags seeds that are forks / mirrors / archived / renamed / gone / no longer rescored. # Needs a valid GITHUB_TOKEN — unauthenticated it covers ~60 repos/hr. # Weekly via .github/workflows/seed-audit.yml, which files the findings as an issue. tests/ _helpers.ts # makeFixture / removeFixture build synthetic trees under os.tmpdir() format.test.ts # compactStars, relativeTime, hostLabel + check-content.test.ts # every signal has check-page prose; check slugs round-trip parse-repo-url.test.ts # GH / GL / BB parsing + edge cases scorer.test.ts # scoreRepo, topImprovements badge-adoption.test.ts # detectBadgeEmbed — README badge-embed detection @@ -230,6 +239,7 @@ If either sibling isn't present locally, flag it; never silently skip the propag 1. New file at `lib/scoring/signals/.ts` implementing `Signal` (including `improveSuggestion`). Use the `helpers.ts` resolvers for every path lookup (see Conventions). 2. Import and add to the `SIGNALS` array in `lib/scoring/signals/index.ts`. 3. Add a weight entry to **every** model in `lib/scoring/weights.ts` — missing weights default to 0, decide deliberately. + Then write its `/check/` page in `lib/check-content/`. The `scoring` lines there restate the signal's `check` outcomes by hand, so change them whenever a threshold changes. 4. Re-score: `bun run seed` is idempotent. 5. **Mirror to both siblings** (per "Sibling repos" above): for each of `../agent-friendly-action/` and `../agent-friendly-skill/`, copy the new signal file into `src/scoring/signals/`, add the import + array entry to that repo's `signals/index.ts`, copy the weights changes to its `weights.ts`, and log the addition in its `CHANGELOG.md` under "Unreleased". @@ -284,7 +294,7 @@ Hooks docs: . - `/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. - `data/rank.db` is committed to a public repo, freed pages included. `bun run check-db-leaks` must pass before any commit of it; the rescore workflow runs it before pushing. -- HTML: React auto-escapes. The only `dangerouslySetInnerHTML` is server-built JSON-LD with `<` escaped to `\u003c` (`app/layout.tsx`, `app/about/page.tsx`, `app/action/page.tsx`, `app/skill/page.tsx`, `app/score/page.tsx`, `app/methodology/page.tsx`, `app/repo/[...slug]/page.tsx`, plus the `HomeJsonLd` component on the leaderboard and the `BreadcrumbJsonLd` component used by About / Agent boards / Changelog / Languages / Methodology / Packages (index and result) / 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 `\u003c` (`app/layout.tsx`, `app/about/page.tsx`, `app/action/page.tsx`, `app/skill/page.tsx`, `app/score/page.tsx`, `app/methodology/page.tsx`, `app/repo/[...slug]/page.tsx`, plus the `HomeJsonLd` component on the leaderboard and the `BreadcrumbJsonLd` component used by About / Agent boards / Changelog / Checks / Languages / Methodology / Packages (index and result) / 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. The only write a request can trigger is a package lookup caching its package → repo alias. When auth lands (`tasks/0.9.0/01-opt-out-claim-flow.md`), do it via OAuth and gate DB writes per user. diff --git a/README.md b/README.md index 9a8e953..3df1407 100644 --- a/README.md +++ b/README.md @@ -155,10 +155,11 @@ app/ Next.js App Router — pages + API + SEO skill/ agent-skill explainer + install command package/ registry → repo lookup (form + per-package state pages) language/ per-language hubs — /language, /language/ + check/ one page per check — /check, /check/ model/ per-agent leaderboards — /model/ api/ /repos, /repo/[id], /score, /badge///, /package// robots.ts /robots.txt — allows "/", blocks "/api/", "/score/" (unbounded URL space) and Next.js `_rsc` prefetch requests - sitemap.ts /sitemap.xml — static routes, agent boards, language hubs, every repo, top packages + sitemap.ts /sitemap.xml — static routes, check pages, agent boards, language hubs, every repo, top packages llms.txt/ markdown manifest for LLM crawlers globals.css Tailwind import + @theme tokens components/ React components (Tailwind-styled) @@ -174,11 +175,13 @@ lib/ db-leaks.ts local-path / token scan of the raw DB bytes before it is committed package-lookup.ts shared registry → repo lookup (used by /api/package + /package page) language-hubs.ts language groups + hub stats (used by /language pages, sitemap) + check-content/ prose for the /check pages, keyed by signal id + check-stats.ts per-check pass rates and example repos (used by /check pages) version.ts app + sibling URLs, install snippets (ACTION_USES, SKILL_INSTALL_CMD), SIBLING_VERSION pin badge-adoption.ts detectBadgeEmbed — dashboard metadata, not a scored signal release-notice.ts localStorage "seen" marker for the home-page release announcement changelog.ts / roadmap.ts / skill-content.ts -scripts/ CLI entries run via `tsx` (Node) — score, seed, init-db, audit-seeds, parity-check, check-db-leaks +scripts/ CLI entries run via `tsx` (Node) — score, seed, init-db, audit-seeds, parity-check, check-db-leaks, indexnow 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, IndexNow key (.txt) diff --git a/app/check/[slug]/page.tsx b/app/check/[slug]/page.tsx new file mode 100644 index 0000000..352fc56 --- /dev/null +++ b/app/check/[slug]/page.tsx @@ -0,0 +1,190 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { BreadcrumbJsonLd } from "@/components/BreadcrumbJsonLd"; +import { CheckRepoList } from "@/components/CheckRepoList"; +import { CodeText } from "@/components/CodeText"; +import { Panel, PanelHeading } from "@/components/Panel"; +import { CHECK_CONTENT } from "@/lib/check-content"; +import { fullRate, getCheckStats } from "@/lib/check-stats"; +import { SIGNAL_BY_ID, SIGNALS } from "@/lib/scoring/signals"; +import { checkPath, checkSlug, signalIdFromSlug } from "@/lib/utils/check-path"; +import { percent } from "@/lib/utils/format"; +import { hubPath } from "@/lib/utils/language"; +import { modelPath } from "@/lib/utils/leaderboard-href"; +import { DEFAULT_OG_IMAGE, OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; + +type Params = { slug: string }; + +const PROSE = "m-0 max-w-[76ch] text-[14.5px] leading-relaxed text-ink-dim"; +const LINK = "text-ink underline-offset-4 hover:text-ink-soft hover:underline"; + +export function generateStaticParams(): Params[] { + return SIGNALS.map((s) => ({ slug: checkSlug(s.id) })); +} + +export async function generateMetadata({ params }: { params: Promise }): Promise { + const id = signalIdFromSlug((await params).slug); + if (!id) { + return {}; + } + + const { title, description } = CHECK_CONTENT[id]; + const path = checkPath(id); + + return { + title, + description, + alternates: { canonical: path }, + twitter: { ...TWITTER_DEFAULTS, images: [DEFAULT_OG_IMAGE], title, description }, + openGraph: { ...OG_DEFAULTS, images: [DEFAULT_OG_IMAGE], title, description, url: path, type: "article" }, + }; +} + +export default async function CheckPage({ params }: { params: Promise }) { + const id = signalIdFromSlug((await params).slug); + if (!id) { + notFound(); + } + + const signal = SIGNAL_BY_ID[id]; + const content = CHECK_CONTENT[id]; + const stats = getCheckStats(id); + const languages = [...stats.languages].sort((a, b) => fullRate(b.counts) - fullRate(a.counts)); + + return ( + <> + + + + ← all checks + + +
+

{content.title}

+

+ +

+
+ + + How many repos pass +

+ Of the {stats.total} repos on the leaderboard,{" "} + + {stats.full} pass in full ({percent(stats.full, stats.total)}) + + , {stats.partial} pass in part ({percent(stats.partial, stats.total)}) and {stats.none} fail ( + {percent(stats.none, stats.total)}). +

+
+ +
+ + Why agents need it +

+ +

+
+
+ +
+ + How we score it +
    + {content.scoring.map((line) => ( +
  • + +
  • + ))} +
+

+ The{" "} + + methodology + {" "} + has the formula that turns checks into a score. +

+
+ + + How to pass +

+ +

+
+
+ +
+ + Which agents count it most +
    + {stats.agents.map(({ model, share }) => ( +
  • + + {model.label} + + {percent(share, 1)} of its score +
  • + ))} +
+
+ + {languages.length > 0 && ( + + Pass rate by language +
    + {languages.map((l) => ( +
  • + + {l.label} + + + {l.counts.full} of {l.counts.total} ({percent(l.counts.full, l.counts.total)}) + +
  • + ))} +
+
+ )} +
+ + {stats.examples.length > 0 && ( +
+ + Top-scoring repos that pass + + +
+ )} + + {stats.missing.length > 0 && ( +
+ + Popular repos that fail it + + +
+ )} + + + + ); +} diff --git a/app/check/page.tsx b/app/check/page.tsx new file mode 100644 index 0000000..5501475 --- /dev/null +++ b/app/check/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { BreadcrumbJsonLd } from "@/components/BreadcrumbJsonLd"; +import { CodeText } from "@/components/CodeText"; +import { getCheckStats } from "@/lib/check-stats"; +import { SIGNALS } from "@/lib/scoring/signals"; +import { checkPath } from "@/lib/utils/check-path"; +import { percent } from "@/lib/utils/format"; +import { DEFAULT_OG_IMAGE, OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; + +const TITLE = "What makes a repo ready for AI coding agents"; +const DESCRIPTION = + "The sixteen things we check in a repo, from AGENTS.md and Cursor rules to tests, CI and a reproducible dev setup, with how many popular open source repos pass each one."; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: "/check" }, + twitter: { ...TWITTER_DEFAULTS, images: [DEFAULT_OG_IMAGE], title: TITLE, description: DESCRIPTION }, + openGraph: { + ...OG_DEFAULTS, + images: [DEFAULT_OG_IMAGE], + title: TITLE, + description: DESCRIPTION, + url: "/check", + type: "website", + }, +}; + +export default function ChecksPage() { + const total = getCheckStats(SIGNALS[0].id).total; + + return ( + <> + + +
+

{TITLE}

+

+ Every score on this site comes from {SIGNALS.length} checks on the files in a repo. Each one has its own page: + what it looks for, why agents need it, how to pass it, and how many of the {total} repos on the leaderboard + do. The{" "} + + methodology + {" "} + covers how the checks are weighted into a score. +

+
+ +
    + {SIGNALS.map((s) => { + const stats = getCheckStats(s.id); + return ( +
  • +
    + + {s.label} + + + {percent(stats.full, stats.total)} pass + +
    +

    + +

    +
  • + ); + })} +
+ + ); +} diff --git a/app/language/[slug]/page.tsx b/app/language/[slug]/page.tsx index 3bf155c..9ab5117 100644 --- a/app/language/[slug]/page.tsx +++ b/app/language/[slug]/page.tsx @@ -6,6 +6,7 @@ import { BreadcrumbJsonLd } from "@/components/BreadcrumbJsonLd"; import { LeaderboardTable } from "@/components/LeaderboardTable"; import { Panel, PanelHeading } from "@/components/Panel"; import { getHub, getLanguageGroups, type Hub } from "@/lib/language-hubs"; +import { checkPath } from "@/lib/utils/check-path"; import { hubPath, isHub } from "@/lib/utils/language"; import { repoPath } from "@/lib/utils/repo-path"; import { DEFAULT_OG_IMAGE, OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; @@ -85,7 +86,10 @@ export default async function LanguageHubPage({ params }: { params: Promise {hub.gaps.map((g) => (
  • - {g.label}: {Math.round(g.rate * 100)}% pass rate + + {g.label} + + : {Math.round(g.rate * 100)}% pass rate
  • ))} diff --git a/app/layout.tsx b/app/layout.tsx index 5eb8eda..29cd0eb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -115,6 +115,7 @@ const FOOTER_LINKS_PRIMARY = [ { href: "/", label: "Home" }, { href: "/about", label: "About" }, { href: "/methodology", label: "Methodology" }, + { href: "/check", label: "Checks" }, { href: "/language", label: "Languages" }, { href: "/roadmap", label: "Roadmap" }, { href: "/changelog", label: "Changelog" }, diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index adb510d..1813425 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -1,6 +1,9 @@ +import { CHECK_CONTENT } from "@/lib/check-content"; import { REGISTRIES } from "@/lib/clients/registries"; import { getTopPackagesByRegistry } from "@/lib/db"; +import { SIGNALS } from "@/lib/scoring/signals"; import { MODELS } from "@/lib/scoring/weights"; +import { checkPath } from "@/lib/utils/check-path"; import { modelPath } from "@/lib/utils/leaderboard-href"; import { ACTION_REPO_URL, @@ -45,12 +48,17 @@ The scoring engine evaluates sixteen static signals per repository — twelve cr ${MODELS.map((m) => `- [${m.label}](${APP_URL}${modelPath(m.id)}): ${m.rationale}`).join("\n")} +## Checks + +${SIGNALS.map((s) => `- [${s.label}](${APP_URL}${checkPath(s.id)}): ${CHECK_CONTENT[s.id].description}`).join("\n")} + ## Key pages - [Leaderboard](${APP_URL}/): Public repos ranked overall and per agent, across GitHub, GitLab, and Bitbucket - [About](${APP_URL}/about): Who built this, why, and the project's independence statement - [Action](${APP_URL}/action): GitHub Action that comments on each pull request with the score change — install snippet, FAQ - [Changelog](${APP_URL}/changelog): What shipped per release +- [Checks](${APP_URL}/check): Every check, with what it looks for, how to pass it and how many leaderboard repos do - [Languages](${APP_URL}/language): The leaderboard split by programming language, with each language's most-missed checks - [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 diff --git a/app/methodology/page.tsx b/app/methodology/page.tsx index c3ae12f..1d45a8f 100644 --- a/app/methodology/page.tsx +++ b/app/methodology/page.tsx @@ -6,6 +6,7 @@ import { ExternalLink } from "@/components/ExternalLink"; import { Panel, PanelHeading } from "@/components/Panel"; import { SIGNALS } from "@/lib/scoring/signals"; import { MODELS } from "@/lib/scoring/weights"; +import { checkPath } from "@/lib/utils/check-path"; import { DEFAULT_OG_IMAGE, OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; const DESCRIPTION = @@ -154,7 +155,9 @@ improvement = closing a gap unlocks (1 - pass) × weight / Σweight × 100 className="flex flex-col gap-2 border-b border-line py-3 last:border-b-0 sm:grid sm:grid-cols-[160px_1fr] sm:items-start sm:gap-5" >
    -
    {s.label}
    + + {s.label} +
    {s.id}
    diff --git a/app/page.tsx b/app/page.tsx index 021b0ba..d731335 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,6 +8,8 @@ import { CHANGELOG } from "@/lib/changelog"; import { LEADERBOARD_PAGE_SIZE } from "@/lib/constants/scoring"; import { getLeaderboardStats, listLeaderboardOverall } from "@/lib/db"; import { type LeaderboardSearchParams, resolveLeaderboardView } from "@/lib/leaderboard-view"; +import { SIGNALS } from "@/lib/scoring/signals"; +import { checkPath } from "@/lib/utils/check-path"; import { groupByLanguage, hubPath, isHub } from "@/lib/utils/language"; import { isModelId, leaderboardHref } from "@/lib/utils/leaderboard-href"; import { APP_NAME, APP_VERSION, OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; @@ -138,6 +140,21 @@ export default async function Page({ searchParams }: { searchParams: Promise )} + + ); } diff --git a/app/repo/[...slug]/page.tsx b/app/repo/[...slug]/page.tsx index bff5ad1..ea032a4 100644 --- a/app/repo/[...slug]/page.tsx +++ b/app/repo/[...slug]/page.tsx @@ -55,9 +55,12 @@ export async function generateMetadata({ params }: { params: Promise }): const slug = `${repo.owner}/${repo.name}`; const image = { url: `/og${path}`, ...OG_IMAGE_SIZE, alt: `${slug} — agent-friendliness score` }; - const score = repo.overall_score != null ? repo.overall_score.toFixed(1) : "unranked"; - - const title = `${slug} — ${score} / 100`; + // Says what the number measures: a bare "83.8 / 100" earned impressions for + // score-string and " github" searches, and no clicks from either. + const title = + repo.overall_score != null + ? `${slug}: AI agent readiness score ${repo.overall_score.toFixed(1)}/100` + : `${slug}: AI agent readiness`; const summary = loadSummary(repo); const description = summaryDescription(slug, summary, repo.overall_score); diff --git a/app/sitemap.ts b/app/sitemap.ts index faafed6..31ce889 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -3,7 +3,9 @@ import type { MetadataRoute } from "next"; import { REGISTRIES } from "@/lib/clients/registries"; import { getLeaderboardStats, getTopPackagesByRegistry, listLeaderboardOverall } from "@/lib/db"; import { getLanguageGroups } from "@/lib/language-hubs"; +import { SIGNALS } from "@/lib/scoring/signals"; import { MODELS } from "@/lib/scoring/weights"; +import { checkPath } from "@/lib/utils/check-path"; import { hubPath, isHub } from "@/lib/utils/language"; import { modelPath } from "@/lib/utils/leaderboard-href"; import { repoPath } from "@/lib/utils/repo-path"; @@ -58,6 +60,12 @@ export default function sitemap(): MetadataRoute.Sitemap { lastModified: contentChanged, changeFrequency: "weekly", }, + { + priority: 0.8, + url: `${APP_URL}/check`, + lastModified: contentChanged, + changeFrequency: "weekly", + }, { priority: 0.5, url: `${APP_URL}/about`, @@ -119,6 +127,13 @@ export default function sitemap(): MetadataRoute.Sitemap { url: `${APP_URL}${modelPath(m.id)}`, })); + const checkRoutes: MetadataRoute.Sitemap = SIGNALS.map((s) => ({ + priority: 0.8, + changeFrequency: "weekly", + lastModified: contentChanged, + url: `${APP_URL}${checkPath(s.id)}`, + })); + const packageRoutes: MetadataRoute.Sitemap = REGISTRIES.flatMap((registry) => getTopPackagesByRegistry(registry, SITEMAP_PACKAGE_LIMIT_PER_REGISTRY).map((p) => ({ lastModified: p.contentChangedAt != null ? new Date(p.contentChangedAt * 1000) : undefined, @@ -128,5 +143,5 @@ export default function sitemap(): MetadataRoute.Sitemap { })), ); - return [...staticRoutes, ...modelRoutes, ...hubRoutes, ...repoRoutes, ...packageRoutes]; + return [...staticRoutes, ...checkRoutes, ...modelRoutes, ...hubRoutes, ...repoRoutes, ...packageRoutes]; } diff --git a/components/CheckRepoList.tsx b/components/CheckRepoList.tsx new file mode 100644 index 0000000..7820c7d --- /dev/null +++ b/components/CheckRepoList.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; + +import type { LeaderboardRow } from "@/lib/types/db"; +import { compactStars } from "@/lib/utils/format"; +import { repoPath } from "@/lib/utils/repo-path"; + +import { ScoreNumber } from "./ScoreNumber"; + +export function CheckRepoList({ rows }: { rows: LeaderboardRow[] }) { + return ( +
      + {rows.map((r) => ( +
    • +
      + + + {r.owner}/{r.name} + + + + +
      + +
      + {r.language ?? "—"} + {r.stars != null ? ` · ${compactStars(r.stars)}` : ""} +
      +
    • + ))} +
    + ); +} diff --git a/components/CodeText.tsx b/components/CodeText.tsx new file mode 100644 index 0000000..2e5327f --- /dev/null +++ b/components/CodeText.tsx @@ -0,0 +1,13 @@ +/** Renders `backticked` spans of a plain string as inline code. */ +export function CodeText({ text }: { text: string }) { + return text.split("`").map((part, i) => + i % 2 === 1 ? ( + // biome-ignore lint/suspicious/noArrayIndexKey: a static string's parts never reorder + + {part} + + ) : ( + part + ), + ); +} diff --git a/components/SignalListCard.tsx b/components/SignalListCard.tsx index bdddb97..51db55d 100644 --- a/components/SignalListCard.tsx +++ b/components/SignalListCard.tsx @@ -1,3 +1,7 @@ +import Link from "next/link"; + +import { checkPath } from "@/lib/utils/check-path"; + import { Panel, PanelHeading } from "./Panel"; type Variant = "strength" | "gap"; @@ -44,7 +48,9 @@ export function SignalListCard({ variant, items, empty }: Props) { > - {s.label} + + {s.label} + )) )} diff --git a/components/SignalRow.tsx b/components/SignalRow.tsx index 6c28ad8..b0bb59d 100644 --- a/components/SignalRow.tsx +++ b/components/SignalRow.tsx @@ -1,4 +1,7 @@ +import Link from "next/link"; + import type { SignalResult } from "@/lib/scoring/signals/types"; +import { checkPath } from "@/lib/utils/check-path"; const MARK_CLASSES = { ok: { cls: "bg-ok/15 text-ok", label: "Pass" }, @@ -34,7 +37,9 @@ export function SignalRow({ signal }: { signal: SignalResult }) {
    - {signal.label} + + {signal.label} + · {(signal.pass * 100).toFixed(0)}% pass diff --git a/lib/check-content/agent-files.ts b/lib/check-content/agent-files.ts new file mode 100644 index 0000000..c3e05f7 --- /dev/null +++ b/lib/check-content/agent-files.ts @@ -0,0 +1,72 @@ +import type { CheckContent } from "./types"; + +export const AGENT_FILE_CHECKS: Record = { + agents_md: { + title: "AGENTS.md and CLAUDE.md: what they are and how many repos have one", + description: + "What an AGENTS.md or CLAUDE.md file is, which AI coding agents read it, how we score it, and how many popular open source repos already have one.", + what: "AGENTS.md is a markdown file at the top of a repo, written for AI coding agents instead of people. It says what the project is, how it is laid out, which commands build and test it, and which rules to follow. CLAUDE.md is the same idea under the name Claude Code looks for.", + why: "It is the one file most agents load without being asked. Claude Code loads CLAUDE.md at the start of every conversation. GPT-5 Codex reads AGENTS.md before it does any work. Cursor, Kimi CLI, OpenHands and Pi read AGENTS.md too. Without it, an agent has to work out your build steps and conventions by reading code, and it guesses.", + scoring: [ + "Full pass: AGENTS.md, CLAUDE.md or AGENT.md at the repo root with 800 or more characters.", + "80%: the file has 200 to 799 characters.", + "50%: the file has under 200 characters.", + "20%: the file exists but is empty.", + "Fail: none of the three files is at the repo root.", + ], + fix: "Add an AGENTS.md at the repo root. Cover what the project does, the directory layout, the exact setup, test and lint commands, and the conventions a reviewer would otherwise have to point out. If you already keep a CLAUDE.md, that passes as it is. Cursor rule files do not count here; they have their own check.", + }, + cursor_rules: { + title: "Cursor rules: .cursor/rules and .cursorrules in open source repos", + description: + "What Cursor rule files are, the difference between .cursor/rules/*.mdc and the legacy .cursorrules file, how we score them, and which repos ship them.", + what: "Cursor rules are instruction files that Cursor reads from the repo. The current form is a `.cursor/rules/` folder of `.mdc` files, each covering one topic such as architecture or naming. The older form is a single `.cursorrules` file at the repo root.", + why: "Cursor's Rules docs name `.cursor/rules/*.mdc` and AGENTS.md as the places it takes repo instructions from. No other agent we score reads the Cursor files, so this check only affects the Cursor score.", + scoring: [ + "Full pass: a `.cursor/rules/` folder with at least one `.mdc` file.", + "50%: only the legacy `.cursorrules` file. Cursor still reads it, but it is deprecated.", + "Fail: neither is present.", + ], + fix: "Create `.cursor/rules/` and add one `.mdc` file per topic: how the code is organised, naming and style rules, how to run tests. If you have a `.cursorrules` file, move its content into `.mdc` files.", + }, + gemini_md: { + title: "GEMINI.md: the instructions file Gemini CLI reads", + description: + "What GEMINI.md is, how Gemini CLI uses it, how we score it, and how many popular open source repos have one.", + what: "GEMINI.md is Gemini CLI's instructions file. It plays the role AGENTS.md plays for other agents: project goals, layout, setup commands and conventions, in markdown.", + why: "Gemini CLI's docs describe GEMINI.md files as layered, from a global file down to one per component, and loaded on every prompt. Only Gemini CLI reads it, so this check only affects the Gemini CLI score.", + scoring: [ + "Full pass: GEMINI.md at the repo root with 800 or more characters.", + "80%: 200 to 799 characters.", + "50%: under 200 characters.", + "20%: the file exists but is empty.", + "Fail: no GEMINI.md at the repo root.", + ], + fix: "Add a GEMINI.md at the repo root with the same content you would put in AGENTS.md. We only check the root file, though Gemini CLI also reads GEMINI.md files in subdirectories.", + }, + openhands_setup: { + title: ".openhands/setup.sh: the setup script OpenHands runs", + description: + "What .openhands/setup.sh does, when OpenHands runs it, how we score it, and how many popular open source repos have one.", + what: "`.openhands/setup.sh` is a shell script that OpenHands runs when a session starts. It is where a repo installs its dependencies and prepares anything the tests need.", + why: "OpenHands works inside a sandboxed container, and its repo-customization docs say it runs this script at session start. With it, the agent begins with a working environment. Without it, the agent spends its first steps working out how to install the project. Only OpenHands reads it, so this check only affects the OpenHands score.", + scoring: [ + "Full pass: `.openhands/setup.sh` exists and is not empty.", + "20%: the file exists but is empty.", + "Fail: no `.openhands/setup.sh`.", + ], + fix: "Add `.openhands/setup.sh` with the commands a new contributor runs once: install dependencies, build what needs building, set up test fixtures. Keep it non-interactive, since nobody is there to answer a prompt.", + }, + aider_conf: { + title: ".aider.conf.yml: repo-level config for Aider", + description: + "What .aider.conf.yml is, which settings matter for an agent-ready repo, how we score it, and how many popular open source repos have one.", + what: "`.aider.conf.yml` is Aider's config file. Committed at the repo root, it sets Aider's options for everyone who uses it in that repo, including the lint command, the test command and extra files to read.", + why: "Aider lints after every edit by default and runs tests after edits once a test command is set, per its lint and test docs. It does not read AGENTS.md on its own; that file only loads when the config's `read:` setting lists it. So this file is how a repo tells Aider what to run and what to read. Only Aider reads it, so this check only affects the Aider score.", + scoring: [ + "Full pass: `.aider.conf.yml` or `.aider.conf.yaml` at the repo root.", + "Fail: neither file is present. We do not look at what the file contains.", + ], + fix: "Add `.aider.conf.yml` at the repo root. Set `test-cmd` and `lint-cmd` to the commands your CI runs, and list AGENTS.md under `read:` so Aider loads it.", + }, +}; diff --git a/lib/check-content/index.ts b/lib/check-content/index.ts new file mode 100644 index 0000000..00be37c --- /dev/null +++ b/lib/check-content/index.ts @@ -0,0 +1,7 @@ +import { AGENT_FILE_CHECKS } from "./agent-files"; +import { REPO_BASIC_CHECKS } from "./repo-basics"; +import type { CheckContent } from "./types"; + +export type { CheckContent }; + +export const CHECK_CONTENT: Record = { ...AGENT_FILE_CHECKS, ...REPO_BASIC_CHECKS }; diff --git a/lib/check-content/repo-basics.ts b/lib/check-content/repo-basics.ts new file mode 100644 index 0000000..d8fa23b --- /dev/null +++ b/lib/check-content/repo-basics.ts @@ -0,0 +1,143 @@ +import type { CheckContent } from "./types"; + +export const REPO_BASIC_CHECKS: Record = { + readme: { + title: "README: how much a repo needs for AI coding agents", + description: + "Why a detailed README helps AI coding agents, how we score README length, and how popular open source repos compare.", + what: "The README is the first file anyone opens, and that includes an agent. This check looks for one at the repo root and measures how much it says.", + why: "An agent starts every task knowing nothing about the project. A README that covers what the project does, how to install it and how the code is laid out saves it from working that out file by file. Cursor's profile gives the README its top weight.", + scoring: [ + "Full pass: a README (`.md`, `.rst`, `.txt` or no extension) with 1,000 or more characters.", + "70%: 200 to 999 characters.", + "30%: under 200 characters.", + "Fail: no README at the repo root.", + ], + fix: "Cover four things: what the project does, how to install it, the commands people run most, and where the main parts of the code live. That is usually well past 1,000 characters.", + }, + tests: { + title: "Test suites: why AI coding agents need tests they can find", + description: + "Why AI coding agents depend on a test suite, how we detect tests in a repo, and how many popular open source repos pass.", + what: "This check looks for tests an agent could run: a test directory, or files named the way test files are named in each language.", + why: "Tests are how an agent learns whether its change worked. An agent with a test suite can edit, run and correct itself. An agent without one hands back code nobody has run. Claude Code's profile weighs tests as heavily as AGENTS.md.", + scoring: [ + "Full pass: a `tests/`, `test/`, `__tests__/`, `spec/`, `specs/` or `src/test/` directory.", + "70%: no such directory, but test-named files turn up within three folder levels, such as `*.test.ts`, `test_*.py`, `*_test.go` or `*Test.java`.", + "Fail: neither. We check that tests exist, not that they pass.", + ], + fix: "Keep tests in a conventional directory, and write the command that runs them in AGENTS.md. Tests that sit next to the source score 70%, because an agent has to search for them.", + }, + ci: { + title: "CI configuration: what AI coding agents learn from your pipeline", + description: + "Why a CI pipeline helps AI coding agents, which CI systems we detect, and how many popular open source repos have one.", + what: "This check looks for a continuous integration pipeline defined in the repo: GitHub Actions workflows, or a config file for another CI system.", + why: "A CI config is a written record of how the project is built and tested. An agent can read it to learn which commands a change has to pass and run them before opening a pull request.", + scoring: [ + "Full pass: at least one `.yml` or `.yaml` file in `.github/workflows/`.", + "90%: a config for GitLab CI, CircleCI, Azure Pipelines, Travis CI, Jenkins or Buildkite.", + "Fail: none of these. We do not check what the pipeline runs.", + ], + fix: "Add a workflow that runs the tests and the linter on every pull request. Use the same commands you document for local use, so an agent can run them before it pushes.", + }, + linter: { + title: "Linter and formatter config: instant feedback for AI coding agents", + description: + "Why a committed linter or formatter config helps AI coding agents, which tools we detect, and how many popular open source repos have one.", + what: "This check looks for a committed linter or formatter config: ESLint, Prettier, Biome, Ruff, rustfmt, Clippy, golangci-lint, RuboCop, clang-format and about twenty others.", + why: "A linter turns style into something an agent can check. It runs the tool, reads the errors and fixes them, where it would otherwise copy whatever style the nearest file uses. Aider lints after every edit by default, so a repo with no config gives it nothing to lint against.", + scoring: [ + "Full pass: a config file for a supported tool, or a `[tool.ruff]`, `[tool.black]`, `[tool.flake8]`, `[tool.pylint]` or `[tool.mypy]` section in `pyproject.toml`.", + "Fail: no config found. We do not run the linter.", + ], + fix: "Pick the standard tool for your language, commit its config file, and put the lint command in AGENTS.md. A linter that only runs with default settings leaves no file for this check to find.", + }, + deps_manifest: { + title: "Dependency manifests: letting AI coding agents rebuild your environment", + description: + "Why AI coding agents need a dependency manifest, which manifest files we detect, and how many popular open source repos have one.", + what: "This check looks for a file that lists the project's dependencies in a form a package manager can read: `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `pom.xml` and the equivalents for other languages.", + why: "An agent that works in a fresh sandbox has to install everything before it can run a single test. A manifest makes that one command. Without one, the agent infers dependencies from import statements and gets versions wrong.", + scoring: [ + "Full pass: a manifest or lockfile at the repo root.", + "Fail: none found. A lockfile is not required to pass.", + ], + fix: "Commit the manifest your package manager expects at the repo root, and commit the lockfile with it so an agent installs the same versions you do.", + }, + dev_env: { + title: "Reproducible dev environments: one-command setup for AI coding agents", + description: + "Why AI coding agents need a reproducible dev environment, which setup files we detect, and how many popular open source repos have them.", + what: "This check looks for files that set up the project with one command: a Makefile, a devcontainer, a Dockerfile or compose file, Nix files, a justfile, a Taskfile, `tox.ini`, `noxfile.py`, the Maven or Gradle wrapper, or `bin/setup`.", + why: "Agents that run in their own machine have to get the project working there first. Devin starts from a virtual machine with the repo, its tools and its dependencies already set up, per Cognition's environment docs, so its profile weighs a runnable environment above CI. A setup that is written down as a file is one an agent can run.", + scoring: [ + "Full pass: two or more of these files.", + "70%: exactly one.", + "60%: none, but `package.json` defines three or more scripts.", + "Fail: none of the above. We do not check that the setup works.", + ], + fix: "Add a Makefile or justfile with `setup`, `test` and `lint` targets, and a devcontainer or Dockerfile that installs the toolchain. Two of these files earn the full pass.", + }, + type_config: { + title: "Type configuration: static types help AI coding agents read code", + description: + "Why static types help AI coding agents, how we detect type checking in a repo, and how many popular open source repos have it.", + what: "This check asks whether the code is statically typed: a type-checker config for JavaScript, TypeScript or Python, or a language that is typed by design.", + why: "Types tell an agent what a function takes and returns without it running anything. It can follow a call across files and catch a wrong argument before the tests do. Cursor's profile gives type config its top weight.", + scoring: [ + "Full pass: `tsconfig.json`, `jsconfig.json`, `mypy.ini`, `pyrightconfig.json`, or a `[tool.mypy]` or `[tool.pyright]` section in `pyproject.toml`.", + "Full pass: a build file for a typed language, namely Rust, Go, Java, Kotlin, Scala, Swift, C#, OCaml, Haskell or Zig.", + "Fail: neither. A typed language whose build file we do not look for, such as C or C++, also fails.", + ], + fix: "For TypeScript or JavaScript, commit a `tsconfig.json` or `jsconfig.json`. For Python, add mypy or Pyright and commit its config. Other typed languages pass once their build file is at the repo root.", + }, + license: { + title: "License files: what AI coding agents may do with your code", + description: + "Why a license file matters when AI coding agents work in a repo, which files we detect, and how many popular open source repos have one.", + what: "This check looks for a license file at the repo root: `LICENSE` or `COPYING`, with or without a `.md` or `.txt` extension.", + why: "A license tells an agent, and the person running it, what may be done with the code: copied into another project, modified, redistributed. Every agent's profile gives it a low weight, because it does not change how well an agent edits the code.", + scoring: ["Full pass: a license file at the repo root.", "Fail: none. We do not read which license it is."], + fix: "Add a `LICENSE` file at the repo root. A license named only in `package.json` or the README does not pass.", + }, + contributing: { + title: "CONTRIBUTING guides: a workflow AI coding agents can follow", + description: + "Why a CONTRIBUTING guide helps AI coding agents, where we look for one, and how many popular open source repos have it.", + what: "This check looks for a contributing guide: `CONTRIBUTING.md`, or the `.rst` or `.adoc` form, at the repo root, in `.github/` or in `docs/`.", + why: "A contributing guide holds the rules a pull request is judged by: branch names, commit style, which checks must pass. An agent that opens pull requests needs those rules as much as a new contributor does.", + scoring: [ + "Full pass: a contributing guide in one of those three places.", + "Fail: none. We do not read its content.", + ], + fix: "Add a `CONTRIBUTING.md` that covers branch naming, commit message style, the commands to run before pushing, and how review works. Keep the agent-specific parts in AGENTS.md and link between the two.", + }, + pre_commit: { + title: "Pre-commit hooks: catching AI coding agent mistakes before CI", + description: + "Why git hooks help AI coding agents, which hook frameworks we detect, and how many popular open source repos use one.", + what: "This check looks for a git hook framework configured in the repo: pre-commit (`.pre-commit-config.yaml`), Husky (`.husky/`) or Lefthook (`lefthook.yml`).", + why: "A hook runs the formatter and linter at commit time, so an agent hears about a problem in seconds. Without hooks the same problem surfaces minutes later as a failed CI run, and the agent spends another round fixing it.", + scoring: [ + "Full pass: a config for pre-commit, Husky or Lefthook.", + "Fail: none. Hooks set up any other way, such as a script that writes to `.git/hooks`, are not detected.", + ], + fix: "Add one of the three frameworks and have it run the formatter and linter on staged files. Keep the hook fast; slow checks belong in CI.", + }, + size: { + title: "Codebase size: how large repos strain AI coding agents", + description: + "Why very large repos are harder for AI coding agents, how we count files, and how popular open source repos are spread across the size bands.", + what: "This check counts the files in the repo. It skips dotfiles, anything `.gitignore` excludes, and vendored or build folders such as `node_modules`, `vendor`, `target`, `dist` and `build`.", + why: "An agent can only hold part of a large codebase in its context at once. The more files there are, the more of its effort goes into finding the right ones, and the easier it is to miss code that a change affects.", + scoring: [ + "Full pass: 50 to 499 files.", + "90%: under 50 files.", + "80%: 500 to 1,999 files.", + "50%: 2,000 to 4,999 files.", + "20%: 5,000 files or more.", + ], + fix: "Few projects can shrink, and none should for the sake of a score. What helps is telling the agent where to start: name the main entry points in AGENTS.md, and in a monorepo give each package its own instructions file.", + }, +}; diff --git a/lib/check-content/types.ts b/lib/check-content/types.ts new file mode 100644 index 0000000..88c3fbd --- /dev/null +++ b/lib/check-content/types.ts @@ -0,0 +1,10 @@ +export type CheckContent = { + /** Page heading and ``. */ + title: string; + description: string; + what: string; + why: string; + /** One entry per outcome the check can return, best first. Must match the signal's `check`. */ + scoring: string[]; + fix: string; +}; diff --git a/lib/check-stats.ts b/lib/check-stats.ts new file mode 100644 index 0000000..5900608 --- /dev/null +++ b/lib/check-stats.ts @@ -0,0 +1,54 @@ +import { cache } from "react"; + +import { CHECK_EXAMPLES_LIMIT, CHECK_MISSING_LIMIT } from "./constants/scoring"; +import { listLeaderboardOverall, listSignalPasses } from "./db"; +import { MODELS, type ModelProfile } from "./scoring/weights"; +import type { LeaderboardRow } from "./types/db"; +import { groupByLanguage, isHub } from "./utils/language"; + +export type PassCounts = { total: number; full: number; partial: number; none: number }; + +export type CheckStats = PassCounts & { + /** Highest-scoring repos that pass in full. */ + examples: LeaderboardRow[]; + /** Most-starred repos that fail outright. */ + missing: LeaderboardRow[]; + languages: { slug: string; label: string; counts: PassCounts }[]; + agents: { model: ModelProfile; share: number }[]; +}; + +function count(passes: number[]): PassCounts { + const full = passes.filter((p) => p >= 1).length; + const none = passes.filter((p) => p === 0).length; + return { total: passes.length, full, none, partial: passes.length - full - none }; +} + +export function fullRate(c: PassCounts): number { + return c.total === 0 ? 0 : c.full / c.total; +} + +export const getCheckStats = cache((signalId: string): CheckStats => { + const passOf = new Map(listSignalPasses(signalId).map((r) => [r.repoId, r.pass])); + // Leaderboard order is score-descending, which `examples` relies on. + const rows = listLeaderboardOverall().filter((r) => passOf.has(r.id)); + const pass = (r: LeaderboardRow) => passOf.get(r.id) ?? 0; + + return { + ...count(rows.map(pass)), + examples: rows.filter((r) => pass(r) >= 1).slice(0, CHECK_EXAMPLES_LIMIT), + missing: rows + .filter((r) => pass(r) === 0) + .sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0)) + .slice(0, CHECK_MISSING_LIMIT), + languages: groupByLanguage(rows) + .filter(isHub) + .map((g) => ({ slug: g.slug, label: g.label, counts: count(g.rows.map(pass)) })), + // Share of the agent's total weight, since the profiles do not sum to the same number. + agents: MODELS.map((model) => { + const sum = Object.values(model.weights).reduce((a, b) => a + b, 0); + return { model, share: sum === 0 ? 0 : (model.weights[signalId] ?? 0) / sum }; + }) + .filter((a) => a.share > 0) + .sort((a, b) => b.share - a.share), + }; +}); diff --git a/lib/constants/scoring.ts b/lib/constants/scoring.ts index 46df679..589752a 100644 --- a/lib/constants/scoring.ts +++ b/lib/constants/scoring.ts @@ -10,10 +10,13 @@ export const MAX_SEARCH_LENGTH = 120; export const LEADERBOARD_PAGE_SIZE = 32; export const DEFAULT_SUGGESTION_LIMIT = 3; -export const ALTERNATIVES_LIMIT = 3; +export const ALTERNATIVES_LIMIT = 6; // Below this a hub is a near-empty list, which reads to search engines as a // thin page; those languages are listed on /language instead. export const LANGUAGE_HUB_MIN_REPOS = 5; export const HUB_GAPS_LIMIT = 3; export const STRENGTHS_GAPS_VISIBLE_LIMIT = 5; + +export const CHECK_EXAMPLES_LIMIT = 12; +export const CHECK_MISSING_LIMIT = 8; diff --git a/lib/db.ts b/lib/db.ts index 88a64f9..9ef9f5b 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -285,6 +285,11 @@ export function getSignalPassRates(repoIds: number[]): SignalPassRate[] { .all(...repoIds) as SignalPassRate[]; } +export function listSignalPasses(signalId: string): { repoId: number; pass: number }[] { + const sql = "SELECT repo_id AS repoId, pass FROM signal_result WHERE signal_id = ?"; + return db.prepare(sql).all(signalId) as { repoId: number; pass: number }[]; +} + export function getLeaderboardStats(): LeaderboardStats { return db .prepare( diff --git a/lib/utils/check-path.ts b/lib/utils/check-path.ts new file mode 100644 index 0000000..bf11d9c --- /dev/null +++ b/lib/utils/check-path.ts @@ -0,0 +1,15 @@ +import { SIGNAL_BY_ID } from "@/lib/scoring/signals"; + +export function checkSlug(signalId: string): string { + return signalId.replace(/_/g, "-"); +} + +export function checkPath(signalId: string): string { + return `/check/${checkSlug(signalId)}`; +} + +export function signalIdFromSlug(slug: string): string | null { + const id = slug.replace(/-/g, "_"); + // A slug with an underscore would map to the same id and serve a duplicate page. + return checkSlug(id) === slug && Object.hasOwn(SIGNAL_BY_ID, id) ? id : null; +} diff --git a/lib/utils/format.ts b/lib/utils/format.ts index c223ba8..f3aa355 100644 --- a/lib/utils/format.ts +++ b/lib/utils/format.ts @@ -36,3 +36,9 @@ export function relativeTime(epochSec: number): string { export function hostLabel(host: string): string { return HOST_LABELS[host] ?? host; } + +// One decimal under 1%: rounding "1 of 371" to "0%" would read as "none". +export function percent(part: number, total: number): string { + const value = total === 0 ? 0 : (part / total) * 100; + return `${value > 0 && value < 1 ? value.toFixed(1) : Math.round(value)}%`; +} diff --git a/package.json b/package.json index 5b7f08c..470dd05 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "audit-seeds": "tsx scripts/audit-seeds.ts", "init-db": "tsx scripts/init-db.ts", "check-db-leaks": "tsx scripts/check-db-leaks.ts", + "indexnow": "tsx scripts/indexnow.ts", "prepare-hooks": "lefthook install", "test": "node --import tsx --test 'tests/**/*.test.ts'" }, diff --git a/scripts/indexnow.ts b/scripts/indexnow.ts new file mode 100644 index 0000000..9167198 --- /dev/null +++ b/scripts/indexnow.ts @@ -0,0 +1,60 @@ +import sitemap from "../app/sitemap"; +import { APP_URL } from "../lib/version"; + +// IndexNow proves ownership by fetching public/<KEY>.txt, so the two must match. +const KEY = "4dd7fff8f3939ce418407429808eb415"; +const ENDPOINT = "https://api.indexnow.org/indexnow"; +const MAX_URLS_PER_REQUEST = 10000; + +function sinceArg(): Date | null { + const i = process.argv.indexOf("--since"); + if (i === -1) return null; + + const since = new Date(process.argv[i + 1] ?? ""); + if (Number.isNaN(since.getTime())) { + throw new Error("--since needs an ISO 8601 timestamp"); + } + return since; +} + +async function main() { + const since = sinceArg(); + // With --since, an entry that carries no lastmod is one that did not change. + const urls = sitemap() + .filter((e) => !since || (e.lastModified != null && new Date(e.lastModified) >= since)) + .map((e) => e.url); + + if (urls.length === 0) { + console.log("indexnow: nothing changed"); + return; + } + + if (process.argv.includes("--dry-run")) { + console.log(urls.join("\n")); + return; + } + + for (let i = 0; i < urls.length; i += MAX_URLS_PER_REQUEST) { + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + key: KEY, + host: new URL(APP_URL).host, + keyLocation: `${APP_URL}/${KEY}.txt`, + urlList: urls.slice(i, i + MAX_URLS_PER_REQUEST), + }), + }); + + if (!res.ok) { + throw new Error(`indexnow: ${res.status} ${res.statusText} ${await res.text()}`); + } + } + + console.log(`indexnow: submitted ${urls.length} URLs`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/check-content.test.ts b/tests/check-content.test.ts new file mode 100644 index 0000000..847c713 --- /dev/null +++ b/tests/check-content.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { CHECK_CONTENT } from "../lib/check-content"; +import { SIGNALS } from "../lib/scoring/signals"; +import { checkSlug, signalIdFromSlug } from "../lib/utils/check-path"; +import { percent } from "../lib/utils/format"; + +describe("check pages", () => { + it("has content for every signal and for nothing else", () => { + assert.deepEqual(Object.keys(CHECK_CONTENT).sort(), SIGNALS.map((s) => s.id).sort()); + }); + + it("round-trips every signal id through its slug", () => { + for (const s of SIGNALS) { + assert.equal(signalIdFromSlug(checkSlug(s.id)), s.id); + } + }); + + it("gives each signal one address", () => { + assert.equal(signalIdFromSlug("agents_md"), null); + assert.equal(signalIdFromSlug("constructor"), null); + }); +}); + +describe("percent", () => { + it("keeps a decimal for a share that would round to zero", () => { + assert.equal(percent(1, 371), "0.3%"); + assert.equal(percent(0, 371), "0%"); + assert.equal(percent(183, 371), "49%"); + assert.equal(percent(0, 0), "0%"); + }); +});