diff --git a/packages/gittensory-engine/src/discovery-index-contract.ts b/packages/gittensory-engine/src/discovery-index-contract.ts new file mode 100644 index 0000000000..3e364eb0dd --- /dev/null +++ b/packages/gittensory-engine/src/discovery-index-contract.ts @@ -0,0 +1,238 @@ +// Discovery-index API contract (#4300). The typed request/response shape a miner uses to query the OPTIONAL hosted +// discovery-index service (the server side is #4250, maintainer-only, explicitly blocked on this contract). The +// plane exists to mitigate the rate-limit incident (#1936): one shared GitHub-metadata crawler across the fleet +// instead of every miner independently hammering the same repos' search/listing endpoints. +// +// This module is schema/shape ONLY — no server, no deployed endpoint, no client HTTP. It stays inside the Phase 1 +// boundary (packages/gittensory-miner/docs/cross-repo-discovery-phase1.md): metadata-only, GET/list/search-only, +// and NO raw scores / rewards / wallet / hotkey data / source contents crossing the public boundary. The response +// candidate shape deliberately maps onto opportunity-ranker.js's `normalizeCandidate` fields so a miner can swap a +// local fan-out for a hosted query without the ranker changing. Tolerant-parser convention, mirroring +// miner-goal-spec.ts / fleet-run-manifest.ts: every field optional, malformed input degrades to a documented +// default with a warning rather than throwing. + +export const DISCOVERY_INDEX_CONTRACT_VERSION = 1; + +const MAX_QUERY_ITEMS = 200; +const MAX_PAGE_LIMIT = 200; +const DEFAULT_PAGE_LIMIT = 50; + +/** Query scope for a discovery-index request: which repos/orgs/search-terms to fan out over, plus pagination. */ +export type DiscoveryIndexQuery = { + /** Canonical `owner/repo` targets. */ + repos: readonly string[]; + /** Bare `owner` (org/user) targets — every open issue across the owner's repos. */ + orgs: readonly string[]; + /** Free-text GitHub issue-search terms. */ + searchTerms: readonly string[]; + /** Page size, clamped to [1, 200]. Default 50. */ + limit: number; + /** Opaque forward pagination cursor from a previous response's `nextCursor`, or null for the first page. */ + cursor: string | null; +}; + +export type DiscoveryIndexRequest = { + contractVersion: number; + query: DiscoveryIndexQuery; +}; + +export type DiscoveryIndexAiPolicySource = "AI-USAGE.md" | "CONTRIBUTING.md" | "none"; + +/** One metadata-only candidate issue. Field-for-field compatible with opportunity-ranker.js `normalizeCandidate` + * output, so `rankCandidateIssues` consumes hosted results exactly like a local fan-out. Public-safe by contract: + * no scores/rewards/wallet/hotkey/source fields ever appear here (see {@link DISCOVERY_INDEX_FORBIDDEN_FIELDS}). */ +export type DiscoveryIndexCandidate = { + owner: string; + repo: string; + repoFullName: string; + issueNumber: number; + title: string; + labels: readonly string[]; + commentsCount: number; + createdAt: string | null; + updatedAt: string | null; + htmlUrl: string | null; + aiPolicyAllowed: boolean; + aiPolicySource: DiscoveryIndexAiPolicySource; +}; + +export type DiscoveryIndexResponse = { + contractVersion: number; + candidates: readonly DiscoveryIndexCandidate[]; + /** Forward cursor for the next page, or null when the result set is exhausted. */ + nextCursor: string | null; +}; + +export type ParsedDiscoveryIndexRequest = { + request: DiscoveryIndexRequest; + warnings: string[]; +}; + +export type ParsedDiscoveryIndexResponse = { + response: DiscoveryIndexResponse; + warnings: string[]; +}; + +/** Field-name fragments that must NEVER cross the public discovery boundary (Phase 1 acceptance: + * cross-repo-discovery-phase1.md:13-14,54). A candidate carrying any of these is rejected, not silently trimmed, + * so a misbehaving server can't smuggle raw economic/identity/source data past the contract. */ +export const DISCOVERY_INDEX_FORBIDDEN_FIELDS: readonly string[] = Object.freeze([ + "score", + "reward", + "wallet", + "hotkey", + "coldkey", + "mnemonic", + "payout", + "ranking", + "rawtrust", + "trustscore", + "sourcecontent", + "diff", + "patch", +]); + +/** Owner/repo names of forbidden-field violations present on a raw candidate object (own enumerable keys whose + * lower-cased name contains a forbidden fragment). Empty array = public-safe. */ +export function discoveryIndexBoundaryViolations(raw: unknown): string[] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const violations: string[] = []; + for (const key of Object.keys(raw)) { + const lower = key.toLowerCase(); + if (DISCOVERY_INDEX_FORBIDDEN_FIELDS.some((fragment) => lower.includes(fragment))) violations.push(key); + } + return violations; +} + +function normalizeStringList(value: unknown, transform: (entry: string) => string | null): string[] { + if (!Array.isArray(value)) return []; + const result: string[] = []; + const seen = new Set(); + for (const entry of value) { + if (typeof entry !== "string") continue; + if (result.length >= MAX_QUERY_ITEMS) break; + const normalized = transform(entry); + if (normalized === null || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +/** `owner/repo` with exactly one slash and non-empty halves; anything else → null (mirrors normalizeCandidate). */ +function normalizeRepoFullName(value: string): string | null { + const [owner, repo, extra] = value.trim().split("/"); + if (!owner || !repo || extra !== undefined) return null; + return `${owner}/${repo}`; +} + +function normalizeOwner(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed || trimmed.includes("/")) return null; + return trimmed; +} + +function normalizeSearchTerm(value: string): string | null { + const trimmed = value.trim(); + return trimmed || null; +} + +function clampLimit(value: unknown, warnings: string[]): number { + if (value === undefined || value === null) return DEFAULT_PAGE_LIMIT; + if (typeof value !== "number" || !Number.isFinite(value)) { + warnings.push(`DiscoveryIndexRequest "limit" must be a number; falling back to ${DEFAULT_PAGE_LIMIT}.`); + return DEFAULT_PAGE_LIMIT; + } + const floored = Math.floor(value); + if (floored < 1) return 1; + if (floored > MAX_PAGE_LIMIT) return MAX_PAGE_LIMIT; + return floored; +} + +function normalizeAiPolicySource(value: unknown): DiscoveryIndexAiPolicySource { + return value === "AI-USAGE.md" || value === "CONTRIBUTING.md" ? value : "none"; +} + +/** + * Tolerantly normalize a raw discovery-index request into a canonical {@link DiscoveryIndexRequest}. Never throws: + * unknown fields are ignored, malformed scope entries are skipped, and the page limit is clamped, accumulating + * warnings. A non-object raw yields an empty query. + */ +export function normalizeDiscoveryIndexRequest(raw: unknown): ParsedDiscoveryIndexRequest { + const warnings: string[] = []; + const record = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : null; + if (!record) { + warnings.push("DiscoveryIndexRequest must be a mapping; falling back to an empty query."); + } + const source = record ?? {}; + const query: DiscoveryIndexQuery = { + repos: normalizeStringList(source.repos, normalizeRepoFullName), + orgs: normalizeStringList(source.orgs, normalizeOwner), + searchTerms: normalizeStringList(source.searchTerms, normalizeSearchTerm), + limit: clampLimit(source.limit, warnings), + cursor: typeof source.cursor === "string" && source.cursor.trim() ? source.cursor : null, + }; + return { request: { contractVersion: DISCOVERY_INDEX_CONTRACT_VERSION, query }, warnings }; +} + +/** + * Normalize one raw candidate into a public-safe {@link DiscoveryIndexCandidate}, mirroring opportunity-ranker.js + * `normalizeCandidate`. Returns null when required fields are missing/invalid OR when the raw object carries any + * forbidden boundary field (a public-safety rejection, not a silent trim). + */ +export function normalizeDiscoveryIndexCandidate(raw: unknown): DiscoveryIndexCandidate | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + if (discoveryIndexBoundaryViolations(raw).length > 0) return null; + const candidate = raw as Record; + const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName : ""; + const canonical = normalizeRepoFullName(repoFullName); + const issueNumber = candidate.issueNumber; + const title = typeof candidate.title === "string" ? candidate.title.trim() : ""; + if (canonical === null) return null; + if (typeof issueNumber !== "number" || !Number.isInteger(issueNumber) || issueNumber <= 0 || !title) return null; + // `canonical` is guaranteed `owner/repo` with exactly one slash, so slice yields two non-empty strings. + const slashIndex = canonical.indexOf("/"); + const owner = canonical.slice(0, slashIndex); + const repo = canonical.slice(slashIndex + 1); + const labels = Array.isArray(candidate.labels) + ? candidate.labels.filter((label): label is string => typeof label === "string" && label.trim() !== "").map((label) => label.trim()) + : []; + return { + owner, + repo, + repoFullName: canonical, + issueNumber, + title, + labels, + commentsCount: typeof candidate.commentsCount === "number" && Number.isFinite(candidate.commentsCount) ? candidate.commentsCount : 0, + createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : null, + updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null, + htmlUrl: typeof candidate.htmlUrl === "string" ? candidate.htmlUrl : null, + aiPolicyAllowed: candidate.aiPolicyAllowed !== false, + aiPolicySource: normalizeAiPolicySource(candidate.aiPolicySource), + }; +} + +/** + * Tolerantly normalize a raw discovery-index response: keep only valid, public-safe candidates (invalid or + * boundary-violating entries are dropped with a warning), and carry a forward cursor. Never throws. + */ +export function normalizeDiscoveryIndexResponse(raw: unknown): ParsedDiscoveryIndexResponse { + const warnings: string[] = []; + const record = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : null; + if (!record) { + warnings.push("DiscoveryIndexResponse must be a mapping; falling back to an empty candidate list."); + } + const rawCandidates = record && Array.isArray(record.candidates) ? record.candidates : []; + const candidates: DiscoveryIndexCandidate[] = []; + for (const entry of rawCandidates) { + const normalized = normalizeDiscoveryIndexCandidate(entry); + if (normalized === null) { + warnings.push("DiscoveryIndexResponse dropped an invalid or boundary-violating candidate."); + continue; + } + candidates.push(normalized); + } + const nextCursor = record && typeof record.nextCursor === "string" && record.nextCursor.trim() ? (record.nextCursor as string) : null; + return { response: { contractVersion: DISCOVERY_INDEX_CONTRACT_VERSION, candidates, nextCursor }, warnings }; +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 2b8e1a90ae..2fc4f34606 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -247,6 +247,21 @@ export { type FleetRunManifestRepo, type ParsedFleetRunManifest, } from "./fleet-run-manifest.js"; +export { + DISCOVERY_INDEX_CONTRACT_VERSION, + DISCOVERY_INDEX_FORBIDDEN_FIELDS, + discoveryIndexBoundaryViolations, + normalizeDiscoveryIndexCandidate, + normalizeDiscoveryIndexRequest, + normalizeDiscoveryIndexResponse, + type DiscoveryIndexAiPolicySource, + type DiscoveryIndexCandidate, + type DiscoveryIndexQuery, + type DiscoveryIndexRequest, + type DiscoveryIndexResponse, + type ParsedDiscoveryIndexRequest, + type ParsedDiscoveryIndexResponse, +} from "./discovery-index-contract.js"; export { computeMetadataLaneFit, computeMinerGoalLaneFit, diff --git a/packages/gittensory-miner/docs/discovery-index-contract.md b/packages/gittensory-miner/docs/discovery-index-contract.md new file mode 100644 index 0000000000..41be376a6f --- /dev/null +++ b/packages/gittensory-miner/docs/discovery-index-contract.md @@ -0,0 +1,60 @@ +# Discovery-index API contract + +The **discovery-index contract** is the typed request/response shape a miner uses to query the *optional* hosted +discovery-index service. It is defined in `@jsonbored/gittensory-engine` +(`packages/gittensory-engine/src/discovery-index-contract.ts`) so both sides — this repo's future server +implementation ([#4250](https://github.com/JSONbored/gittensory/issues/4250), maintainer-only, explicitly blocked +on this contract) and any client — build against one shape. + +This is **schema/shape only**: no server, no deployed endpoint, no client HTTP implementation (those are #4250 and +the sibling soft-claim-coordination issue, respectively). + +## Why + +The plane mitigates the rate-limit incident already fixed once for the review stack +([#1936](https://github.com/JSONbored/gittensory/issues/1936)): *one* shared GitHub-metadata crawler across the +miner fleet instead of every miner instance independently hammering the same repos' search/listing endpoints. The +existing single-instance client pipeline (`packages/gittensory-miner/lib/opportunity-fanout.js` + +`opportunity-ranker.js`) is what a hosted version must stay compatible with, so the response candidate shape is +field-for-field compatible with `opportunity-ranker.js`'s `normalizeCandidate` — a miner can swap a local fan-out +for a hosted query without the ranker changing. + +## Boundary (Phase 1) + +The contract stays inside the Phase 1 discovery boundary +([`cross-repo-discovery-phase1.md`](./cross-repo-discovery-phase1.md)): metadata-only, GET/list/search-only, and +**no raw scores, rewards, wallet/hotkey data, or source contents** cross the public boundary. This is enforced in +code, not just documented: `discoveryIndexBoundaryViolations` lists any forbidden field on a raw object, and +`normalizeDiscoveryIndexCandidate` **rejects** (returns `null`) — rather than silently trimming — any candidate +carrying one, so a misbehaving server cannot smuggle economic/identity/source data past the contract. +`DISCOVERY_INDEX_FORBIDDEN_FIELDS` is the fragment list (`score`, `reward`, `wallet`, `hotkey`, `coldkey`, +`mnemonic`, `payout`, `ranking`, `rawtrust`, `trustscore`, `sourcecontent`, `diff`, `patch`). + +## Request — `DiscoveryIndexQuery` + +Every field is optional; a malformed value degrades to a documented default with a warning rather than throwing +(`normalizeDiscoveryIndexRequest`). + +- **`repos`** — canonical `owner/repo` targets. Non-string / non-`owner/repo` entries are skipped; deduplicated; + capped at 200. Default: `[]`. +- **`orgs`** — bare `owner` (org/user) targets. Entries containing `/` are skipped; deduplicated; capped at 200. + Default: `[]`. +- **`searchTerms`** — free-text GitHub issue-search terms. Blank entries skipped; deduplicated; capped at 200. + Default: `[]`. +- **`limit`** — page size, floored and clamped to `[1, 200]`; a non-numeric value warns and falls back. Default: + `50`. +- **`cursor`** — opaque forward pagination cursor from a previous response's `nextCursor`; a blank/non-string value + becomes `null`. Default: `null`. + +## Response — `DiscoveryIndexResponse` + +- **`candidates`** — a list of `DiscoveryIndexCandidate`. `normalizeDiscoveryIndexResponse` keeps only valid, + public-safe entries and drops invalid or boundary-violating ones with a warning. +- **`nextCursor`** — forward cursor for the next page, or `null` when the result set is exhausted. +- **`contractVersion`** — `DISCOVERY_INDEX_CONTRACT_VERSION` (currently `1`). + +### `DiscoveryIndexCandidate` + +Metadata-only, field-for-field compatible with `opportunity-ranker.js` `normalizeCandidate`: +`owner`, `repo`, `repoFullName`, `issueNumber`, `title`, `labels`, `commentsCount`, `createdAt`, `updatedAt`, +`htmlUrl`, `aiPolicyAllowed`, `aiPolicySource` (`"AI-USAGE.md" | "CONTRIBUTING.md" | "none"`). diff --git a/test/unit/discovery-index-contract.test.ts b/test/unit/discovery-index-contract.test.ts new file mode 100644 index 0000000000..5d484f81b2 --- /dev/null +++ b/test/unit/discovery-index-contract.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import { + DISCOVERY_INDEX_CONTRACT_VERSION, + DISCOVERY_INDEX_FORBIDDEN_FIELDS, + discoveryIndexBoundaryViolations, + normalizeDiscoveryIndexCandidate, + normalizeDiscoveryIndexRequest, + normalizeDiscoveryIndexResponse, +} from "../../packages/gittensory-engine/src/index"; + +const VALID_CANDIDATE = { + repoFullName: "owner/repo", + issueNumber: 42, + title: "Fix the thing", + labels: ["help wanted", " ", 7, "bug"], + commentsCount: 3, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + htmlUrl: "https://github.com/owner/repo/issues/42", + aiPolicyAllowed: true, + aiPolicySource: "AI-USAGE.md", +}; + +describe("discovery-index API contract (#4300)", () => { + it("re-exports the contract API from the engine barrel", () => { + expect(typeof normalizeDiscoveryIndexRequest).toBe("function"); + expect(typeof normalizeDiscoveryIndexCandidate).toBe("function"); + expect(typeof normalizeDiscoveryIndexResponse).toBe("function"); + expect(typeof discoveryIndexBoundaryViolations).toBe("function"); + expect(DISCOVERY_INDEX_CONTRACT_VERSION).toBe(1); + expect(DISCOVERY_INDEX_FORBIDDEN_FIELDS).toContain("wallet"); + }); + + describe("normalizeDiscoveryIndexRequest", () => { + it("normalizes scope lists (dedupe, skip invalid, cap) and clamps the limit", () => { + const { request, warnings } = normalizeDiscoveryIndexRequest({ + repos: ["owner/a", "owner/a", "no-slash", "owner/b/extra", "/norepo", 5, "owner/b"], + orgs: ["acme", "acme", "bad/owner", " ", "beta"], + searchTerms: ["label:bug", " ", "", "is:open"], + limit: 12.9, + cursor: "eyJwIjoyfQ==", + }); + expect(request.contractVersion).toBe(1); + expect(request.query.repos).toEqual(["owner/a", "owner/b"]); + expect(request.query.orgs).toEqual(["acme", "beta"]); + expect(request.query.searchTerms).toEqual(["label:bug", "is:open"]); + expect(request.query.limit).toBe(12); + expect(request.query.cursor).toBe("eyJwIjoyfQ=="); + expect(warnings).toEqual([]); + }); + + it("clamps limit bounds and warns on a non-numeric limit", () => { + expect(normalizeDiscoveryIndexRequest({ limit: 0 }).request.query.limit).toBe(1); + expect(normalizeDiscoveryIndexRequest({ limit: 9999 }).request.query.limit).toBe(200); + expect(normalizeDiscoveryIndexRequest({}).request.query.limit).toBe(50); + const bad = normalizeDiscoveryIndexRequest({ limit: "lots" }); + expect(bad.request.query.limit).toBe(50); + expect(bad.warnings.join(" ")).toMatch(/"limit" must be a number/); + }); + + it("blanks a non-string / empty cursor and non-array scopes", () => { + expect(normalizeDiscoveryIndexRequest({ cursor: " " }).request.query.cursor).toBeNull(); + expect(normalizeDiscoveryIndexRequest({ cursor: 12 }).request.query.cursor).toBeNull(); + expect(normalizeDiscoveryIndexRequest({ repos: "owner/a" }).request.query.repos).toEqual([]); + }); + + it("caps a scope list at 200 entries", () => { + const many = Array.from({ length: 250 }, (_, i) => `owner/r${i}`); + expect(normalizeDiscoveryIndexRequest({ repos: many }).request.query.repos).toHaveLength(200); + }); + + it("degrades a non-mapping request to an empty query with a warning", () => { + for (const raw of [null, undefined, 42, ["a"]]) { + const parsed = normalizeDiscoveryIndexRequest(raw); + expect(parsed.request.query).toEqual({ repos: [], orgs: [], searchTerms: [], limit: 50, cursor: null }); + expect(parsed.warnings.join(" ")).toMatch(/must be a mapping/); + } + }); + }); + + describe("discoveryIndexBoundaryViolations", () => { + it("lists forbidden field names present on a raw object", () => { + expect(discoveryIndexBoundaryViolations({ title: "ok", score: 9, walletAddress: "x", HotKey: "y" }).sort()).toEqual( + ["HotKey", "score", "walletAddress"].sort(), + ); + }); + it("returns [] for a clean object or a non-object", () => { + expect(discoveryIndexBoundaryViolations({ repoFullName: "o/r", title: "t" })).toEqual([]); + expect(discoveryIndexBoundaryViolations(null)).toEqual([]); + expect(discoveryIndexBoundaryViolations(["score"])).toEqual([]); + }); + }); + + describe("normalizeDiscoveryIndexCandidate", () => { + it("mirrors normalizeCandidate's shape for a valid public-safe candidate", () => { + expect(normalizeDiscoveryIndexCandidate(VALID_CANDIDATE)).toEqual({ + owner: "owner", + repo: "repo", + repoFullName: "owner/repo", + issueNumber: 42, + title: "Fix the thing", + labels: ["help wanted", "bug"], + commentsCount: 3, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + htmlUrl: "https://github.com/owner/repo/issues/42", + aiPolicyAllowed: true, + aiPolicySource: "AI-USAGE.md", + }); + }); + + it("applies defaults for missing optional fields", () => { + const c = normalizeDiscoveryIndexCandidate({ repoFullName: "o/r", issueNumber: 1, title: "t" }); + expect(c).toMatchObject({ + labels: [], + commentsCount: 0, + createdAt: null, + updatedAt: null, + htmlUrl: null, + aiPolicyAllowed: true, + aiPolicySource: "none", + }); + }); + + it("maps aiPolicySource and respects an explicit aiPolicyAllowed:false", () => { + expect(normalizeDiscoveryIndexCandidate({ repoFullName: "o/r", issueNumber: 1, title: "t", aiPolicySource: "CONTRIBUTING.md", aiPolicyAllowed: false })).toMatchObject({ + aiPolicySource: "CONTRIBUTING.md", + aiPolicyAllowed: false, + }); + expect(normalizeDiscoveryIndexCandidate({ repoFullName: "o/r", issueNumber: 1, title: "t", aiPolicySource: "nope" })?.aiPolicySource).toBe("none"); + }); + + it("rejects a candidate carrying any forbidden boundary field", () => { + expect(normalizeDiscoveryIndexCandidate({ ...VALID_CANDIDATE, rewardScore: 0.9 })).toBeNull(); + expect(normalizeDiscoveryIndexCandidate({ ...VALID_CANDIDATE, diff: "@@ -1 +1 @@" })).toBeNull(); + }); + + it("returns null on an invalid repo, issue number, title, or non-object", () => { + expect(normalizeDiscoveryIndexCandidate({ issueNumber: 1, title: "t" })).toBeNull(); + expect(normalizeDiscoveryIndexCandidate({ repoFullName: "no-slash", issueNumber: 1, title: "t" })).toBeNull(); + expect(normalizeDiscoveryIndexCandidate({ repoFullName: "o/r", issueNumber: 0, title: "t" })).toBeNull(); + expect(normalizeDiscoveryIndexCandidate({ repoFullName: "o/r", issueNumber: 1.5, title: "t" })).toBeNull(); + expect(normalizeDiscoveryIndexCandidate({ repoFullName: "o/r", issueNumber: "1", title: "t" })).toBeNull(); + expect(normalizeDiscoveryIndexCandidate({ repoFullName: "o/r", issueNumber: 1, title: " " })).toBeNull(); + expect(normalizeDiscoveryIndexCandidate(null)).toBeNull(); + expect(normalizeDiscoveryIndexCandidate([VALID_CANDIDATE])).toBeNull(); + }); + + it("coerces a non-finite commentsCount and non-array labels to defaults", () => { + const c = normalizeDiscoveryIndexCandidate({ repoFullName: "o/r", issueNumber: 1, title: "t", commentsCount: Infinity, labels: "bug" }); + expect(c?.commentsCount).toBe(0); + expect(c?.labels).toEqual([]); + }); + }); + + describe("normalizeDiscoveryIndexResponse", () => { + it("keeps valid candidates, drops invalid/boundary ones with warnings, and carries the cursor", () => { + const parsed = normalizeDiscoveryIndexResponse({ + candidates: [VALID_CANDIDATE, { repoFullName: "no-slash" }, { ...VALID_CANDIDATE, walletBalance: 5 }], + nextCursor: "next==", + }); + expect(parsed.response.contractVersion).toBe(1); + expect(parsed.response.candidates).toHaveLength(1); + expect(parsed.response.candidates[0]?.repoFullName).toBe("owner/repo"); + expect(parsed.response.nextCursor).toBe("next=="); + expect(parsed.warnings.filter((w) => /dropped an invalid/.test(w))).toHaveLength(2); + }); + + it("degrades a non-mapping response and a missing/blank cursor", () => { + const empty = normalizeDiscoveryIndexResponse(7); + expect(empty.response.candidates).toEqual([]); + expect(empty.response.nextCursor).toBeNull(); + expect(empty.warnings.join(" ")).toMatch(/must be a mapping/); + expect(normalizeDiscoveryIndexResponse({ candidates: "nope", nextCursor: " " }).response).toMatchObject({ candidates: [], nextCursor: null }); + }); + }); +});