diff --git a/config/release-versions.json b/config/release-versions.json index 380a0e1..2a24a6e 100644 --- a/config/release-versions.json +++ b/config/release-versions.json @@ -15,7 +15,7 @@ "trackingIssue": null }, "compatibility": { - "publicScoreReadOrder": ["v9", "v8"], + "publicScoreReadOrder": ["v9"], "roastReplay": [ { "score": "v9", diff --git a/docs/releases/v9-v9-v4-rollout.md b/docs/releases/v9-v9-v4-rollout.md index d6a6942..2b2d160 100644 --- a/docs/releases/v9-v9-v4-rollout.md +++ b/docs/releases/v9-v9-v4-rollout.md @@ -12,6 +12,10 @@ If the quick collector cannot complete, a verified `v5/v5/v3` artifact may be served as a read-only emergency fallback. A successful quick scan always takes precedence and overwrites the current profile atomically. +`v8` is not a public read fallback. Public pages, score APIs, search, rankings, +badges, and profile snapshots serve only v9 artifacts; a v5 response is allowed +only after the synchronous quick collector has failed. + ## Deployment Checks 1. Run `pnpm versions:check`, `pnpm typecheck`, `pnpm lint`, and `pnpm test`. diff --git a/src/app/api/score/[username]/route.test.ts b/src/app/api/score/[username]/route.test.ts index 1705416..236d2f4 100644 --- a/src/app/api/score/[username]/route.test.ts +++ b/src/app/api/score/[username]/route.test.ts @@ -1,5 +1,6 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AccountDetail } from "@/lib/db"; import type { ScanResult } from "@/lib/types"; const mocks = vi.hoisted(() => ({ @@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => ({ publishCompleteQuickScan: vi.fn(), rateLimitHeaders: vi.fn(), recordAccountLookup: vi.fn(), + scanErrorResponse: vi.fn(), })); vi.mock("@/lib/db", () => ({ @@ -30,7 +32,10 @@ vi.mock("@/lib/redis", () => ({ getCachedScan: mocks.getCachedScan, rateLimitHeaders: mocks.rateLimitHeaders, })); -vi.mock("@/lib/scan-core", () => ({ buildScanResult: mocks.buildScanResult })); +vi.mock("@/lib/scan-core", () => ({ + buildScanResult: mocks.buildScanResult, + scanErrorResponse: mocks.scanErrorResponse, +})); import { GET } from "./route"; @@ -39,6 +44,42 @@ const quickScan = { scoring: { final_score: 71, tier: "人上人", tier_label: "trusted", sub_scores: {}, base_score: 71, total_penalty: 0, red_flags: [] }, } as unknown as ScanResult; +const subScores = { + account_maturity: 10, + original_project_quality: 10, + contribution_quality: 10, + ecosystem_impact: 10, + community_influence: 10, + activity_authenticity: 10, +}; + +const legacyFallback = { + username: "fixture-user", + display_name: "Fixture User", + avatar_url: null, + profile_url: "https://github.com/fixture-user", + final_score: 64, + tier: "人上人", + tags: { zh: [], en: [] }, + sub_scores: subScores, + roast_line: { zh: "", en: "" }, + roast: "legacy report", + roast_en: "legacy report", + score_version: "v5", + legacy_read_fallback: true, + score_source_collection_version: null, + score_source_snapshot_hash: null, + scanned_at: 1, + prev_score: null, + prev_scanned_at: null, +} as AccountDetail; + +const obsoleteV8Detail = { + ...legacyFallback, + score_version: "v8", + legacy_read_fallback: false, +}; + describe("GET /api/score immediate quick contract", () => { beforeEach(() => { mocks.getAccountDetail.mockResolvedValue(null); @@ -51,6 +92,7 @@ describe("GET /api/score immediate quick contract", () => { mocks.recordAccountLookup.mockResolvedValue(undefined); mocks.getPercentileCached.mockResolvedValue(null); mocks.getRankCached.mockResolvedValue(null); + mocks.scanErrorResponse.mockReturnValue({ error: "scan_failed", status: 500 }); }); it("materializes a cold account instead of returning 202", async () => { @@ -62,4 +104,50 @@ describe("GET /api/score immediate quick contract", () => { await expect(response.json()).resolves.toMatchObject({ source: "quick", coverage: "quick", final_score: 71 }); expect(mocks.publishCompleteQuickScan).toHaveBeenCalledWith(quickScan, expect.any(Number)); }); + + it("refreshes a v5 fallback with quick scan before serving it", async () => { + mocks.getAccountDetail.mockResolvedValue(legacyFallback); + + const response = await GET(new NextRequest("https://example.test/api/score/fixture-user"), { + params: Promise.resolve({ username: "fixture-user" }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + source: "quick", + coverage: "quick", + final_score: 71, + }); + expect(mocks.buildScanResult).toHaveBeenCalledWith("fixture-user"); + expect(mocks.publishCompleteQuickScan).toHaveBeenCalledWith(quickScan, expect.any(Number)); + }); + + it("serves the verified v5 fallback only after quick scan fails", async () => { + mocks.getAccountDetail.mockResolvedValue(legacyFallback); + mocks.buildScanResult.mockRejectedValue(new Error("upstream unavailable")); + + const response = await GET(new NextRequest("https://example.test/api/score/fixture-user"), { + params: Promise.resolve({ username: "fixture-user" }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + source: "legacy_v5_v5_v3", + coverage: "legacy", + stale: true, + final_score: 64, + }); + }); + + it("never replays a v8 detail when quick scan fails", async () => { + mocks.getAccountDetail.mockResolvedValue(obsoleteV8Detail); + mocks.buildScanResult.mockRejectedValue(new Error("upstream unavailable")); + + const response = await GET(new NextRequest("https://example.test/api/score/fixture-user"), { + params: Promise.resolve({ username: "fixture-user" }), + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ error: "scan_failed" }); + }); }); diff --git a/src/app/api/score/[username]/route.ts b/src/app/api/score/[username]/route.ts index c6fe7e9..49c7c2b 100644 --- a/src/app/api/score/[username]/route.ts +++ b/src/app/api/score/[username]/route.ts @@ -3,6 +3,7 @@ import { getAccountDetail, publishCompleteQuickScan, recordAccountLookup, + type AccountDetail, } from "@/lib/db"; import { getPercentileCached, getRankCached } from "@/lib/rank"; import { normalizeUsername } from "@/lib/username"; @@ -109,6 +110,44 @@ async function liveScoreResponse(scan: ScanResult, cached: boolean, headers: Rec ); } +function isCanonicalDetail(detail: AccountDetail): boolean { + return ( + detail.score_version === SCORE_CACHE_VERSION && + detail.score_source_collection_version === PUBLIC_SCAN_COLLECTION_VERSION && + typeof detail.score_source_snapshot_hash === "string" && + /^[a-f0-9]{64}$/.test(detail.score_source_snapshot_hash) + ); +} + +async function persistedScoreResponse( + detail: AccountDetail, + options: { source: "indexed" | "legacy_v5_v5_v3"; current: boolean; headers?: Record }, +) { + return json( + { + source: options.source, + coverage: options.current ? "quick" : "legacy", + stale: !options.current, + username: detail.username, + display_name: detail.display_name, + avatar_url: detail.avatar_url, + profile_url: detail.profile_url ?? `https://github.com/${detail.username}`, + final_score: detail.final_score, + tier: detail.tier, + tier_key: TIER_KEY[detail.tier], + sub_scores: detail.sub_scores, + tags: detail.tags, + roast_line: detail.roast_line, + percentile: await percentileFor(detail.final_score), + scanned_at: detail.scanned_at, + profile: `${SITE_URL}/u/${detail.username}`, + }, + 200, + options.current ? RATED_CACHE : LIVE_CACHE, + options.headers, + ); +} + /** Public deterministic score: bounded quick collection only, never queue work. */ export async function GET( req: NextRequest, @@ -129,34 +168,8 @@ export async function GET( } const detail = await getAccountDetail(handle); - if (detail) { - const currentScore = - detail.score_version === SCORE_CACHE_VERSION && - detail.score_source_collection_version === PUBLIC_SCAN_COLLECTION_VERSION && - typeof detail.score_source_snapshot_hash === "string" && - /^[a-f0-9]{64}$/.test(detail.score_source_snapshot_hash); - return json( - { - source: "indexed", - coverage: "quick", - stale: !currentScore, - username: detail.username, - display_name: detail.display_name, - avatar_url: detail.avatar_url, - profile_url: detail.profile_url ?? `https://github.com/${detail.username}`, - final_score: detail.final_score, - tier: detail.tier, - tier_key: TIER_KEY[detail.tier], - sub_scores: detail.sub_scores, - tags: detail.tags, - roast_line: detail.roast_line, - percentile: await percentileFor(detail.final_score), - scanned_at: detail.scanned_at, - profile: `${SITE_URL}/u/${detail.username}`, - }, - 200, - currentScore ? RATED_CACHE : LIVE_CACHE, - ); + if (detail && isCanonicalDetail(detail)) { + return persistedScoreResponse(detail, { source: "indexed", current: true }); } const limit = await checkRateLimit(clientIp(req)); @@ -190,6 +203,13 @@ export async function GET( } } catch (error) { if (error instanceof ScorePersistenceError) return scorePersistenceUnavailable(headers); + if (detail?.legacy_read_fallback) { + return persistedScoreResponse(detail, { + source: "legacy_v5_v5_v3", + current: false, + headers, + }); + } const { error: code, status, retry_after } = scanErrorResponse(error); return json( { error: code, message: code.replace(/_/g, " "), ...(retry_after ? { retry_after } : {}) }, diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts index cbb0fe5..39d0fbd 100644 --- a/src/lib/__tests__/db.test.ts +++ b/src/lib/__tests__/db.test.ts @@ -1993,7 +1993,7 @@ describe("profile snapshots", () => { }); }); - it("prefers v9, falls back to v8, and ignores non-release snapshots", async () => { + it("reads only v9 profile snapshots and ignores legacy snapshots", async () => { const client = createClient({ url: process.env.TURSO_DATABASE_URL! }); const username = "profile-version-fixture"; const rows = [ @@ -2017,10 +2017,7 @@ describe("profile snapshots", () => { sql: `DELETE FROM profile_snapshots WHERE id = ?`, args: ["profile-v9"], }); - await expect(db.getProfileSnapshot(username)).resolves.toMatchObject({ - metrics: { followers: 8 }, - scanned_at: 300, - }); + await expect(db.getProfileSnapshot(username)).resolves.toBeNull(); await client.execute({ sql: `INSERT INTO profile_snapshots (id, username, scanned_at, metrics, scan_version) @@ -2370,8 +2367,8 @@ describe("getRepoOverview + filterExistingRepoKeys", () => { }); }); -describe("legacy public score release guardrail", () => { - it("keeps a synthetic v8 row on every passive public surface without creating work", async () => { +describe("legacy public score exclusion", () => { + it("never serves a v8 row on passive public surfaces or creates background work", async () => { const username = "legacy-public-fixture"; const peer = "legacy-peer-fixture"; const repoKey = `${username}/public-fixture`; @@ -2452,40 +2449,19 @@ describe("legacy public score release guardrail", () => { db.getArchivedRoast(username, "zh"), ]); - expect(detail).toMatchObject({ - username, - final_score: 86, - score_version: "v8", - tags: { zh: [], en: [] }, - roast_line: { zh: "", en: "" }, - roast: null, - roast_en: null, - }); - expect(brief).toMatchObject({ username, final_score: 86 }); - expect(suggestions).toEqual(expect.arrayContaining([expect.objectContaining({ username })])); + expect(detail).toBeNull(); + expect(brief).toBeNull(); + expect(suggestions.some((item) => item.username === username)).toBe(false); for (const entries of [leaderboard, trending, heat]) { - expect(entries.some((item) => item.username === username)).toBe(true); - expect(entries.find((item) => item.username === username)?.tags).toEqual({ zh: [], en: [] }); + expect(entries.some((item) => item.username === username)).toBe(false); } - expect(facets).toEqual( - expect.arrayContaining([expect.objectContaining({ value: "FixtureLang" })]), - ); - expect(facetDevelopers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ username, tags: { zh: [], en: [] } }), - ]), - ); - expect(sitemapProfiles).toEqual( - expect.arrayContaining([expect.objectContaining({ username })]), - ); - expect(repo).toMatchObject({ owner: { username }, summary: { count: 1 } }); - expect(similar).toEqual( - expect.arrayContaining([ - expect.objectContaining({ username: peer, tags: { zh: [], en: [] } }), - ]), - ); + expect(facets.some((facet) => facet.value === "FixtureLang")).toBe(false); + expect(facetDevelopers.some((item) => item.username === username)).toBe(false); + expect(sitemapProfiles.some((item) => item.username === username)).toBe(false); + expect(repo).toMatchObject({ owner: null, summary: { count: 0 } }); + expect(similar.some((item) => item.username === peer)).toBe(false); expect(following).toEqual( - expect.arrayContaining([expect.objectContaining({ username, final_score: 86 })]), + expect.arrayContaining([expect.objectContaining({ username, final_score: null })]), ); expect(archivedRoast).toBeNull(); diff --git a/src/lib/__tests__/mcp-tools.test.ts b/src/lib/__tests__/mcp-tools.test.ts new file mode 100644 index 0000000..d716ae8 --- /dev/null +++ b/src/lib/__tests__/mcp-tools.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AccountDetail } from "@/lib/db"; +import type { ScanResult } from "@/lib/types"; + +const mocks = vi.hoisted(() => ({ + buildScanResult: vi.fn(), + coalesceScan: vi.fn(), + getAccountDetail: vi.fn(), + getCachedScan: vi.fn(), + getPercentileCached: vi.fn(), + getRankCached: vi.fn(), + publishCompleteQuickScan: vi.fn(), + scanErrorResponse: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + getAccountDetail: mocks.getAccountDetail, + publishCompleteQuickScan: mocks.publishCompleteQuickScan, + searchScoredUsers: vi.fn(), +})); +vi.mock("@/lib/rank", () => ({ + getPercentileCached: mocks.getPercentileCached, + getRankCached: mocks.getRankCached, +})); +vi.mock("@/lib/leaderboard", () => ({ getLeaderboardCached: vi.fn() })); +vi.mock("@/lib/redis", () => ({ + coalesceScan: mocks.coalesceScan, + getCachedScan: mocks.getCachedScan, +})); +vi.mock("@/lib/scan-core", () => ({ + buildScanResult: mocks.buildScanResult, + scanErrorResponse: mocks.scanErrorResponse, +})); + +import { scoreUser } from "@/lib/mcp-tools"; + +const quickScan = { + metrics: { username: "fixture-user", name: "Fixture User" }, + scoring: { + final_score: 71, + tier: "人上人", + sub_scores: {}, + red_flags: [], + }, +} as unknown as ScanResult; + +const subScores = { + account_maturity: 10, + original_project_quality: 10, + contribution_quality: 10, + ecosystem_impact: 10, + community_influence: 10, + activity_authenticity: 10, +}; + +const legacyFallback = { + username: "fixture-user", + display_name: "Fixture User", + avatar_url: null, + profile_url: "https://github.com/fixture-user", + final_score: 64, + tier: "人上人", + tags: { zh: [], en: [] }, + sub_scores: subScores, + roast_line: { zh: "", en: "" }, + roast: "legacy report", + roast_en: "legacy report", + score_version: "v5", + legacy_read_fallback: true, + score_source_collection_version: null, + score_source_snapshot_hash: null, + scanned_at: 1, + prev_score: null, + prev_scanned_at: null, +} as AccountDetail; + +describe("MCP score release reads", () => { + beforeEach(() => { + mocks.getAccountDetail.mockResolvedValue(legacyFallback); + mocks.getCachedScan.mockResolvedValue(null); + mocks.coalesceScan.mockImplementation(async (_handle: string, producer: () => unknown) => producer()); + mocks.buildScanResult.mockResolvedValue(quickScan); + mocks.publishCompleteQuickScan.mockResolvedValue(true); + mocks.getPercentileCached.mockResolvedValue(null); + mocks.getRankCached.mockResolvedValue(null); + mocks.scanErrorResponse.mockReturnValue({ error: "scan_failed", status: 503 }); + }); + + it("materializes v9 from quick scan before exposing a v5 fallback", async () => { + await expect(scoreUser("fixture-user")).resolves.toMatchObject({ + source: "quick", + coverage: "quick", + final_score: 71, + }); + expect(mocks.publishCompleteQuickScan).toHaveBeenCalledWith(quickScan); + }); + + it("returns v5 only after quick scan fails", async () => { + mocks.buildScanResult.mockRejectedValue(new Error("upstream unavailable")); + + await expect(scoreUser("fixture-user")).resolves.toMatchObject({ + source: "legacy_v5_v5_v3", + coverage: "legacy", + stale: true, + final_score: 64, + }); + }); +}); diff --git a/src/lib/__tests__/release-versions.test.ts b/src/lib/__tests__/release-versions.test.ts index e86eeff..9758b5a 100644 --- a/src/lib/__tests__/release-versions.test.ts +++ b/src/lib/__tests__/release-versions.test.ts @@ -35,6 +35,7 @@ describe("release version contract", () => { expect(RELEASE_VERSION_MANIFEST.compatibility.roastReplay).toEqual([ { score: "v9", roast: "v9" }, ]); + expect(RELEASE_VERSION_MANIFEST.compatibility.publicScoreReadOrder).toEqual(["v9"]); }); it("requires the runtime to remain on the canonical release after normalization", () => { @@ -70,6 +71,12 @@ describe("release version contract", () => { "roast replay must require the canonical score and roast pair", ); + const stalePublicRead = manifestCopy(); + stalePublicRead.compatibility.publicScoreReadOrder = ["v9", "v8"]; + expect(releaseVersionErrors(stalePublicRead)).toContain( + "public score reads must only serve the canonical target release", + ); + const changedFallback = manifestCopy(); changedFallback.legacyReadFallback.score = "v6"; expect(releaseVersionErrors(changedFallback)).toContain( diff --git a/src/lib/db.ts b/src/lib/db.ts index 530e8b1..7258a59 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -83,13 +83,24 @@ import type { } from "./scan-run-types"; const EMPTY_TAGS: Tags = { zh: [], en: [] }; -const PUBLIC_PROFILE_SCORE_VERSIONS = RELEASE_VERSION_MANIFEST.compatibility - .publicScoreReadOrder as [string, string]; +const PUBLIC_PROFILE_SCORE_VERSION = RELEASE_VERSION_MANIFEST.compatibility + .publicScoreReadOrder[0]; const HEAT_LOOKUP_WINDOW_MS = 24 * 60 * 60 * 1000; const TRENDING_LOOKUP_WINDOW_MS = 7 * HEAT_LOOKUP_WINDOW_MS; const MIN_RECORDED_LOOKUP_COUNT = 1; const MAX_PUBLIC_SCAN_EXECUTION_CAPACITY = 4; +/** + * Public reads must never surface a score merely because it is the newest row. + * A row is readable only after the current quick collector has materialized the + * current formal v9 score. The roast route separately requires an exact v9/v4 + * snapshot hash before it generates or replays text. The v5 emergency path is + * handled explicitly by the profile/roast readers and never joins rankings. + */ +function canonicalPublicScorePredicate(alias: string): string { + return `${alias}.score_version = '${SCORE_CACHE_VERSION}'`; +} + function hasLegacyReadFallbackReport(row: Record): boolean { return ( row.score_version === LEGACY_READ_FALLBACK.score && @@ -4119,9 +4130,9 @@ export async function hasProfileSnapshot(username: string): Promise { const res = await db.execute({ sql: `SELECT 1 FROM profile_snapshots - WHERE username = ? AND scan_version IN (?, ?) + WHERE username = ? AND scan_version = ? LIMIT 1`, - args: [username.toLowerCase(), ...PUBLIC_PROFILE_SCORE_VERSIONS], + args: [username.toLowerCase(), PUBLIC_PROFILE_SCORE_VERSION], }); return res.rows.length > 0; } catch (e) { @@ -4232,13 +4243,12 @@ export async function getProfileSnapshot( const res = await db.execute({ sql: `SELECT top_repos, impact_repos, signature_work, pinned_repos, organizations, metrics, scanned_at FROM profile_snapshots - WHERE username = ? AND scan_version IN (?, ?) - ORDER BY CASE WHEN scan_version = ? THEN 0 ELSE 1 END, scanned_at DESC + WHERE username = ? AND scan_version = ? + ORDER BY scanned_at DESC LIMIT 1`, args: [ username.toLowerCase(), - ...PUBLIC_PROFILE_SCORE_VERSIONS, - PUBLIC_PROFILE_SCORE_VERSIONS[0], + PUBLIC_PROFILE_SCORE_VERSION, ], }); const r = res.rows[0]; @@ -4426,8 +4436,10 @@ export async function getPercentile( await ensureSchema(db); const res = await db.execute({ sql: `SELECT - (SELECT COUNT(*) FROM scores WHERE final_score < ?) AS below, - (SELECT COUNT(*) FROM scores) AS total`, + (SELECT COUNT(*) FROM scores + WHERE final_score < ? AND ${canonicalPublicScorePredicate("scores")}) AS below, + (SELECT COUNT(*) FROM scores + WHERE ${canonicalPublicScorePredicate("scores")}) AS total`, args: [score], }); const row = res.rows[0]; @@ -4460,7 +4472,7 @@ export async function getRank( SUM(CASE WHEN final_score > ? THEN 1 ELSE 0 END) AS above, SUM(CASE WHEN final_score < ? THEN 1 ELSE 0 END) AS below, COUNT(*) AS total - FROM scores WHERE hidden = 0`, + FROM scores WHERE hidden = 0 AND ${canonicalPublicScorePredicate("scores")}`, args: [score, score], }); const row = res.rows[0]; @@ -4496,7 +4508,9 @@ export async function getScoreHistogram(): Promise { await ensureSchema(db); const res = await db.execute( `SELECT hidden, CAST(ROUND(final_score * 10) AS INTEGER) AS bucket, COUNT(*) AS n - FROM scores GROUP BY hidden, bucket`, + FROM scores + WHERE ${canonicalPublicScorePredicate("scores")} + GROUP BY hidden, bucket`, ); return res.rows.map((r) => ({ hidden: Number(r.hidden), @@ -4564,6 +4578,7 @@ export async function getFacetRank( WHERE f.facet_type = 'language' AND f.facet_value = ? AND s.hidden = 0 + AND ${canonicalPublicScorePredicate("s")} AND s.final_score >= ?`, args: [score, facetValue, FACET_MIN_SCORE], }, @@ -4574,6 +4589,7 @@ export async function getFacetRank( WHERE f.facet_type = 'language' AND f.facet_value = ? AND s.hidden = 0 + AND ${canonicalPublicScorePredicate("s")} AND s.final_score > ? ORDER BY s.final_score ASC LIMIT 1`, @@ -4611,7 +4627,9 @@ export async function getScoreCount(): Promise { if (!db) return null; try { await ensureSchema(db); - const res = await db.execute("SELECT COUNT(*) AS n FROM scores"); + const res = await db.execute( + `SELECT COUNT(*) AS n FROM scores WHERE ${canonicalPublicScorePredicate("scores")}`, + ); return Number(res.rows[0]?.n ?? 0); } catch (e) { console.error("getScoreCount failed:", e); @@ -4682,7 +4700,9 @@ export async function getTrendingLeaderboard( WHERE last_counted_at >= ? GROUP BY username ) AS recent ON recent.username = s.username - WHERE s.hidden = 0 AND s.final_score >= ? + WHERE s.hidden = 0 + AND ${canonicalPublicScorePredicate("s")} + AND s.final_score >= ? ${activeOnly ? "AND recent.recent_lookup_count > 0" : ""}`, args: [recentCutoff, minScore], }); @@ -4720,7 +4740,9 @@ export async function getAllPublicUsernames(minScore = 60): Promise