diff --git a/.api-sync/spec-map.json b/.api-sync/spec-map.json index c121b7c..95e0aac 100644 --- a/.api-sync/spec-map.json +++ b/.api-sync/spec-map.json @@ -1858,6 +1858,28 @@ "name": "WalletInboundSchemaOut", "reason": "Webhook payload body for wallet.inbound; same reasoning as BankAccountWebhookOut." } + ], + "operations": [ + { + "name": "GET /v1/instances/{instance_id}/customers/{customer_id}/rfi", + "reason": "Rfi (request-for-information) family has no existing SDK resource; a human must design it, the generator will not guess a brand-new resource." + }, + { + "name": "GET /v1/instances/{instance_id}/rfi", + "reason": "Rfi family, same as the customer-scoped variant." + }, + { + "name": "POST /v1/instances/{instance_id}/customers/{customer_id}/rfi", + "reason": "Rfi family, same as the customer-scoped GET." + }, + { + "name": "POST /v1/instances/{instance_id}/rfi", + "reason": "Rfi family, same as the customer-scoped GET." + }, + { + "name": "POST /v1/upload/analyze", + "reason": "Multipart/binary upload, not instance-scoped and not JSON in/out; classifier never guesses this shape." + } ] } } diff --git a/scripts/api-sync/apply.ts b/scripts/api-sync/apply.ts index cb10ae0..0d5489b 100644 --- a/scripts/api-sync/apply.ts +++ b/scripts/api-sync/apply.ts @@ -96,3 +96,88 @@ export function insertOptionalField( const patched = lines.join("\n"); return content.slice(0, span.start) + patched + content.slice(span.end); } + +/** + * Inserts `typeDecls` (one or more `export type X = ...;` blocks, already blank-line separated) + * directly above `export function (...)`. Idempotent: a no-op if the exact block + * (verbatim) is already present, so a second --apply against unchanged state is byte-identical. + */ +export function insertTypeDeclarations( + content: string, + factoryFn: string, + typeDecls: string +): string { + if (!typeDecls) return content; + if (content.includes(typeDecls)) return content; + const declRe = new RegExp(`^export function ${factoryFn}\\b`, "m"); + const match = declRe.exec(content); + if (!match) throw new Error(`apply: could not locate "export function ${factoryFn}"`); + return `${content.slice(0, match.index)}${typeDecls}\n\n${content.slice(match.index)}`; +} + +/** + * Inserts one method (already formatted as `methodName(...): ReturnType { ... },`, 8-space + * indented) as the last entry of `export function (...) { return { ... }; }`'s + * returned object literal. Idempotent: a no-op if a method with the same name already exists + * anywhere in that returned object. + */ +export function insertResourceMethod( + content: string, + factoryFn: string, + methodName: string, + methodSource: string +): string { + const declRe = new RegExp(`^export function ${factoryFn}\\b[^{]*\\{`, "m"); + const declMatch = declRe.exec(content); + if (!declMatch) throw new Error(`apply: could not locate "export function ${factoryFn}"`); + + const fnBodyStart = declMatch.index + declMatch[0].length; + const returnRe = /return\s*\{/; + const returnMatch = returnRe.exec(content.slice(fnBodyStart)); + if (!returnMatch) throw new Error(`apply: could not locate "return {" in "${factoryFn}"`); + const objStart = fnBodyStart + returnMatch.index + returnMatch[0].length; // just after "return {" + + let depth = 1; + let i = objStart; + while (i < content.length && depth > 0) { + if (content[i] === "{") depth++; + else if (content[i] === "}") depth--; + if (depth === 0) break; + i++; + } + const objEnd = i; // index of the matching "}" + + const objectText = content.slice(objStart, objEnd); + if (new RegExp(`(^|\\s)${methodName}\\s*\\(`).test(objectText)) { + return content; // method already present; idempotent no-op + } + + const insertion = `\n ${methodSource}`; + return `${content.slice(0, objEnd)}${insertion}\n ${content.slice(objEnd)}`; +} + +/** + * Adds any of `names` not already present to the (single) `import type { ... } from + * "../../../types";` statement, alphabetically merged into the existing list. + */ +export function ensureSharedTypeImports(content: string, names: string[]): string { + if (names.length === 0) return content; + const re = /import type \{([^}]*)\} from "([./]*types)";/; + const match = re.exec(content); + if (!match) { + const missing = names.map((n) => `"${n}"`).join(", "); + throw new Error( + `apply: could not locate a shared "import type { ... } from \\".../types\\";" statement to add ${missing} to` + ); + } + const existing = match[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const merged = new Set([...existing, ...names]); + const sorted = [...merged].sort(); + const replacement = `import type {\n ${sorted.join(",\n ")},\n} from "${match[2]}";`; + return ( + content.slice(0, match.index) + replacement + content.slice(match.index + match[0].length) + ); +} diff --git a/scripts/api-sync/coverage.ts b/scripts/api-sync/coverage.ts index dd32b18..12b0c2f 100644 --- a/scripts/api-sync/coverage.ts +++ b/scripts/api-sync/coverage.ts @@ -21,22 +21,30 @@ function allResourceSource(repoRoot: string): string { /** * Non-blocking heuristic: an operation "has SDK coverage" if the last literal (non-`{param}`) * path segment appears anywhere in the resource sources' request-building template strings. - * Approximate by design (this is a printed report, not a gate), its job is to make gaps like - * the RFI resource and POST /v1/upload/analyze show up on every run instead of silently. + * Approximate by design, its job is to make gaps like the RFI resource show up on every run + * instead of silently. Used both by the standalone --coverage report and, for the handful of + * operations outside operation-gen.ts's instance-scoped template (its precise per-operation + * matcher covers everything else), as the operation-insert discovery signal. */ +export function hasCoarseSdkEndpointString(path: string, source: string): boolean { + const segments = path + .replace(/^\/v1\//, "") + .split("/") + .filter((seg) => seg.length > 0 && !seg.startsWith("{")); + const needle = segments[segments.length - 1]; + return !!needle && source.includes(needle); +} + export function computeCoverageGaps(spec: Spec, repoRoot: string): string[] { const source = allResourceSource(repoRoot); const gaps: string[] = []; for (const key of operationKeys(spec)) { const [method, path] = key.split(" "); - const segments = path - .replace(/^\/v1\//, "") - .split("/") - .filter((seg) => seg.length > 0 && !seg.startsWith("{")); - const needle = segments[segments.length - 1]; - if (!needle || !source.includes(needle)) { + if (!hasCoarseSdkEndpointString(path, source)) { gaps.push(`${method.toUpperCase()} ${path}`); } } return gaps.sort(); } + +export { allResourceSource }; diff --git a/scripts/api-sync/index.ts b/scripts/api-sync/index.ts index a05a961..11a48a6 100644 Binary files a/scripts/api-sync/index.ts and b/scripts/api-sync/index.ts differ diff --git a/scripts/api-sync/map.ts b/scripts/api-sync/map.ts index e15f1a3..4e7e1ba 100644 --- a/scripts/api-sync/map.ts +++ b/scripts/api-sync/map.ts @@ -68,3 +68,9 @@ export function isKnownTypeMismatchDivergence( export function findSdkFileExists(repoRoot: string, file: string): boolean { return existsSync(join(repoRoot, file)); } + +/** True if `${method.toUpperCase()} ${path}` is a recorded, human-accepted ignore.operations entry. */ +export function isIgnoredOperation(map: SpecMap, method: string, path: string): boolean { + const key = `${method.toUpperCase()} ${path}`; + return (map.ignore.operations ?? []).some((e) => e.name === key); +} diff --git a/scripts/api-sync/operation-gen.golden.test.ts b/scripts/api-sync/operation-gen.golden.test.ts new file mode 100644 index 0000000..17c169e --- /dev/null +++ b/scripts/api-sync/operation-gen.golden.test.ts @@ -0,0 +1,139 @@ +import { execFileSync } from "node:child_process"; +import { cpSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const REPO_ROOT = join(__dirname, "../.."); + +let scratchDir: string | null = null; + +/** A scratch copy of the whole repo (source only; node_modules is symlinked in, not copied, + * so `tsc`/`vitest` inside the copy resolve real dependencies without a second install). */ +function makeScratchRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "api-sync-golden-")); + cpSync(REPO_ROOT, dir, { + recursive: true, + filter: (src) => + !src.includes(`${REPO_ROOT}/node_modules`) && + !src.includes(`${REPO_ROOT}/.git`) && + // Not itself: `bun run test` inside the scratch copy would otherwise re-run this + // same golden test recursively (and against an already-regenerated repo, wrongly). + !src.endsWith("operation-gen.golden.test.ts"), + }); + symlinkSync(join(REPO_ROOT, "node_modules"), join(dir, "node_modules")); + return dir; +} + +afterEach(() => { + if (scratchDir) rmSync(scratchDir, { recursive: true, force: true }); + scratchDir = null; +}); + +function deleteSpan(content: string, startMarker: string, endMarker: string): string { + const start = content.indexOf(startMarker); + if (start === -1) throw new Error(`marker not found: ${startMarker}`); + const end = content.indexOf(endMarker, start); + if (end === -1) throw new Error(`end marker not found: ${endMarker}`); + return content.slice(0, start) + content.slice(end); +} + +/** + * Deletes payins.get + its Input/Output types, and quotes.create + its Input/Output types: + * the SDK method and the types it exposes, exactly what a human accidentally reverting or + * force-pushing over that code would lose. (spec-map.json's own QuoteIn/QuoteOut coverage + * entries are deliberately left alone here: they're exercised by a separate, isolated unit + * test below, `operation-insert on a never-before-modeled schema`, without entangling this + * live-repo run with the unrelated enum-coverage gate a fully-unmapped QuoteIn would also trip.) + */ +function deleteTwoOperations(dir: string): void { + const payinsPath = join(dir, "src/resources/payins/index.ts"); + let payins = readFileSync(payinsPath, "utf8"); + payins = deleteSpan( + payins, + "export type GetPayinInput = string;", + "export type GetPayinTrackInput" + ); + payins = deleteSpan(payins, " get(payinId: GetPayinInput)", " getTrack("); + writeFileSync(payinsPath, payins); + + const quotesPath = join(dir, "src/resources/quotes/index.ts"); + let quotes = readFileSync(quotesPath, "utf8"); + quotes = deleteSpan(quotes, "export type CreateQuoteInput", "export type GetFxRateInput"); + quotes = deleteSpan( + quotes, + " create({ ...data }: CreateQuoteInput)", + " getFxRate(" + ); + writeFileSync(quotesPath, quotes); +} + +function runSync(dir: string, ...args: string[]): { status: number; output: string } { + try { + const output = execFileSync( + "bun", + ["scripts/api-sync/index.ts", ...args, "--spec", ".api-sync/spec-snapshot.json"], + { cwd: dir, encoding: "utf8" } + ); + return { status: 0, output }; + } catch (err) { + const e = err as { status: number; stdout: string; stderr: string }; + return { status: e.status, output: `${e.stdout}\n${e.stderr}` }; + } +} + +describe("operation-insert golden self-test", () => { + it("regenerates a deleted GET-by-id and a deleted POST-with-body from the committed snapshot spec", () => { + const dir = makeScratchRepo(); + scratchDir = dir; + deleteTwoOperations(dir); + + const first = runSync(dir, "--apply"); + expect(first.status).toBe(0); + + const payins = readFileSync(join(dir, "src/resources/payins/index.ts"), "utf8"); + expect(payins).toContain("export type GetPayinInput = string;"); + expect(payins).toContain("export type GetPayinResponse = Payin;"); + expect(payins).toContain( + "get(payinId: GetPayinInput): Promise> {" + ); + expect(payins).toContain( + "return client.get(`/instances/${instanceId}/payins/${payinId}`);" + ); + + const quotes = readFileSync(join(dir, "src/resources/quotes/index.ts"), "utf8"); + expect(quotes).toContain("bank_account_id: string;"); + expect(quotes).toContain("currency_type:"); + expect(quotes).toContain("request_amount: number;"); + expect(quotes).toContain( + "create({ ...data }: CreateQuoteInput): Promise> {" + ); + expect(quotes).toContain("return client.post(`/instances/${instanceId}/quotes`, data);"); + + execFileSync("bun", ["run", "check-types"], { cwd: dir, stdio: "pipe" }); + execFileSync("bun", ["run", "test"], { cwd: dir, stdio: "pipe" }); + + const afterFirstApply: Record = { + payins, + quotes, + map: readFileSync(join(dir, ".api-sync/spec-map.json"), "utf8"), + pkg: readFileSync(join(dir, "package.json"), "utf8"), + }; + + const second = runSync(dir, "--apply"); + expect(second.status).toBe(0); + expect(readFileSync(join(dir, "src/resources/payins/index.ts"), "utf8")).toBe( + afterFirstApply.payins + ); + expect(readFileSync(join(dir, "src/resources/quotes/index.ts"), "utf8")).toBe( + afterFirstApply.quotes + ); + expect(readFileSync(join(dir, ".api-sync/spec-map.json"), "utf8")).toBe( + afterFirstApply.map + ); + expect(readFileSync(join(dir, "package.json"), "utf8")).toBe(afterFirstApply.pkg); + + const check = runSync(dir, "--check"); + expect(check.status).toBe(0); + }, 60_000); +}); diff --git a/scripts/api-sync/operation-gen.test.ts b/scripts/api-sync/operation-gen.test.ts new file mode 100644 index 0000000..0a8f4e7 --- /dev/null +++ b/scripts/api-sync/operation-gen.test.ts @@ -0,0 +1,201 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { classifyNewOperation, loadCanonicalEnums } from "./operation-gen"; +import type { Spec } from "./spec"; +import type { SpecMap } from "./types"; + +let dir: string | null = null; + +function makeRepo(files: Record): string { + dir = mkdtempSync(join(tmpdir(), "api-sync-opgen-")); + writeFileSync( + join(dir, "package.json.placeholder"), + "" // keep the dir non-empty even before any files + ); + for (const [rel, content] of Object.entries(files)) { + const full = join(dir, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); + } + return dir; +} + +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = null; +}); + +function emptyMap(): SpecMap { + return { enums: [], types: [], ignore: { schemas: [] } }; +} + +const WIDGETS_RESOURCE = `import type { BlindpayApiResponse } from "../../../types"; +import type { InternalApiClient } from "../../internal/api-client"; + +export type ListWidgetsResponse = { id: string }[]; + +export function createWidgetsResource(instanceId: string, client: InternalApiClient) { + return { + list(): Promise> { + return client.get(\`/instances/\${instanceId}/widgets\`); + }, + }; +} +`; + +function baseFiles(): Record { + return { + "src/resources/widgets/index.ts": WIDGETS_RESOURCE, + "types/index.d.ts": 'export type Currency = "USD" | "EUR";\n', + }; +} + +describe("operation-insert classifier", () => { + it("classifies a GET-by-id on an existing resource as STANDARD", () => { + const repoRoot = makeRepo(baseFiles()); + const spec: Spec = { + paths: { + "/v1/instances/{instance_id}/widgets/{id}": { + get: { + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + "200": { + content: { + "application/json": { + schema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + }, + }, + }, + }, + }, + }, + components: { schemas: {} }, + }; + const canon = loadCanonicalEnums(repoRoot); + const result = classifyNewOperation( + spec, + "get", + "/v1/instances/{instance_id}/widgets/{id}", + emptyMap(), + repoRoot, + canon + ); + expect(result.standard).toBe(true); + if (!result.standard) throw new Error("unreachable"); + expect(result.change.methodName).toBe("get"); + expect(result.change.site.file).toBe("src/resources/widgets/index.ts"); + expect(result.change.methodSource).toContain("get(widgetId: GetWidgetInput)"); + expect(result.change.methodSource).toContain( + "client.get(`/instances/${instanceId}/widgets/${widgetId}`)" + ); + }); + + it("classifies a multipart/form-data request body as NON-STANDARD, never generated", () => { + const repoRoot = makeRepo(baseFiles()); + const spec: Spec = { + paths: { + "/v1/instances/{instance_id}/widgets/upload": { + post: { + requestBody: { + content: { "multipart/form-data": { schema: { type: "object" } } }, + }, + responses: { + "200": { + content: { + "application/json": { + schema: { type: "object", properties: {} }, + }, + }, + }, + }, + }, + }, + }, + components: { schemas: {} }, + }; + const canon = loadCanonicalEnums(repoRoot); + const result = classifyNewOperation( + spec, + "post", + "/v1/instances/{instance_id}/widgets/upload", + emptyMap(), + repoRoot, + canon + ); + expect(result.standard).toBe(false); + if (result.standard) throw new Error("unreachable"); + expect(result.alreadyImplemented).not.toBe(true); + expect((result as { reason: string }).reason).toContain("multipart/binary"); + }); + + it("adds a new schema map entry when the request body is a never-before-modeled named schema", () => { + const repoRoot = makeRepo(baseFiles()); + const spec: Spec = { + paths: { + "/v1/instances/{instance_id}/widgets": { + post: { + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/WidgetIn" }, + }, + }, + }, + responses: { + "200": { + content: { + "application/json": { + schema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + WidgetIn: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + }, + }; + const canon = loadCanonicalEnums(repoRoot); + const result = classifyNewOperation( + spec, + "post", + "/v1/instances/{instance_id}/widgets", + emptyMap(), + repoRoot, + canon + ); + expect(result.standard).toBe(true); + if (!result.standard) throw new Error("unreachable"); + expect(result.change.typeDecls).toContain("export type CreateWidgetInput"); + expect(result.change.typeDecls).toContain("name: string;"); + expect( + result.change.newMapEntries.some( + (e) => + e.kind === "schema" && + e.schema === "WidgetIn" && + e.symbol === "CreateWidgetInput" + ) + ).toBe(true); + }); +}); diff --git a/scripts/api-sync/operation-gen.ts b/scripts/api-sync/operation-gen.ts new file mode 100644 index 0000000..f91f291 --- /dev/null +++ b/scripts/api-sync/operation-gen.ts @@ -0,0 +1,523 @@ +import { allResourceSource, hasCoarseSdkEndpointString } from "./coverage"; +import { + buildSegmentIndex, + findResourceInfo, + operationIsImplemented, + resourceSegmentOf, +} from "./resource-registry"; +import type { JsonValue, Spec } from "./spec"; +import { + loadCanonicalEnums, + loadWholeSchemaSymbols, + synthesizeObjectFields, + synthesizeType, + type TypegenContext, +} from "./typegen"; +import type { OperationInsertChange, SpecMap } from "./types"; + +const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const; +type HttpMethod = (typeof HTTP_METHODS)[number]; + +export type ClassifyResult = + | { standard: true; change: OperationInsertChange } + | { standard: false; alreadyImplemented: true } + | { standard: false; alreadyImplemented?: false; reason: string }; + +function pascalCase(segment: string): string { + return segment + .split(/[-_]/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(""); +} + +function camelCase(segment: string): string { + const p = pascalCase(segment); + return p ? p[0].toLowerCase() + p.slice(1) : p; +} + +function singularize(segment: string): string { + if (segment.endsWith("ies")) return `${segment.slice(0, -3)}y`; + if (segment.endsWith("ss")) return segment; + if (segment.endsWith("s")) return segment.slice(0, -1); + return segment; +} + +function refName(node: JsonValue): string | null { + if (node && typeof node === "object" && typeof node.$ref === "string") { + return node.$ref.split("/").pop() as string; + } + return null; +} + +function pathSegments(path: string): string[] { + return path + .replace(/^\/v1\/?/, "") + .split("/") + .filter((s) => s.length > 0); +} + +/** JSON content-type map for a requestBody or response, or null if there is none at all. */ +function jsonContentOnly(content: Record | undefined): "none" | "json" | "other" { + if (!content) return "none"; + const keys = Object.keys(content); + if (keys.length === 0) return "none"; + if (keys.length === 1 && keys[0] === "application/json") return "json"; + return "other"; +} + +type Field = { name: string; tsType: string; required: boolean }; + +/** True if `declareNamedType({refName: ref, pure, currentFile, ...})` will end up emitting a + * bare alias to an already-modeled symbol, without ever looking at `fields`: mirrors exactly + * the condition inside `declareNamedType` itself, checked before doing the (possibly deeply + * recursive) field synthesis, so that work's only side effect (canonical-enum import + * collection) never fires for a schema whose own shape we're not actually declaring. */ +function willBeAlias( + ref: string | null, + pure: boolean, + currentFile: string, + alreadyWholeMapped: Map, + symbolName: string +): boolean { + if (!ref || !pure) return false; + const existing = alreadyWholeMapped.get(ref); + if (!existing) return false; + return !(existing.symbol === symbolName && existing.file === currentFile); +} + +function objectBody(fields: Field[] | null): string { + if (!fields || fields.length === 0) return "Record"; + return `{\n${fields.map((f) => ` ${f.name}${f.required ? "" : "?"}: ${f.tsType};`).join("\n")}\n}`; +} + +/** + * Compiles a schema/query/response block into a named TypeScript type declaration. + * + * `pure` (the merged shape has no extra path/query fields folded in, only the ref's own body) + * decides whether reusing an already-modeled schema can be a bare alias (`export type X = Y;`); + * a merged shape is never a byte-for-byte alias of its ref, even when the ref is named. + * + * Reuses the ref's SDK symbol when it is already modeled whole elsewhere; declares the full + * synthesized shape and records a new schema map entry when it is not (or when regenerating + * this exact site, so it never aliases to itself). + */ +function declareNamedType(params: { + symbolName: string; + fields: Field[] | null; + refName: string | null; + pure: boolean; + currentFile: string; + alreadyWholeMapped: Map; + pathLocator: { path: string; method: string; location: string } | null; +}): { decl: string; mapEntry: OperationInsertChange["newMapEntries"][number] | null } { + const { + symbolName, + fields, + refName: ref, + pure, + currentFile, + alreadyWholeMapped, + pathLocator, + } = params; + + if (ref) { + const existing = alreadyWholeMapped.get(ref); + const regeneratingSelf = + !!existing && existing.symbol === symbolName && existing.file === currentFile; + if (pure && existing && !regeneratingSelf) { + return { decl: `export type ${symbolName} = ${existing.symbol};`, mapEntry: null }; + } + if (existing && (regeneratingSelf || !pure)) { + // Either regenerating the exact site the existing entry already points at, or a + // merged shape that folds in path/query fields the ref alone doesn't carry: declare + // the full shape, and only add a *new* map entry when the existing one is anchored + // to a different site than the one we're about to (re)write. + const mapEntry = regeneratingSelf + ? null + : { + kind: "schema" as const, + schema: ref, + file: currentFile, + symbol: symbolName, + notes: null, + }; + return { decl: `export type ${symbolName} = ${objectBody(fields)};`, mapEntry }; + } + return { + decl: `export type ${symbolName} = ${objectBody(fields)};`, + mapEntry: { + kind: "schema", + schema: ref, + file: currentFile, + symbol: symbolName, + notes: null, + }, + }; + } + + const mapEntry = pathLocator + ? { + kind: "path" as const, + path: pathLocator.path, + method: pathLocator.method, + location: pathLocator.location, + file: currentFile, + symbol: symbolName, + notes: "inline object body, not a named schema", + } + : null; + return { decl: `export type ${symbolName} = ${objectBody(fields)};`, mapEntry }; +} + +export function classifyNewOperation( + spec: Spec, + method: HttpMethod, + path: string, + map: SpecMap, + repoRoot: string, + canonicalEnums: Map +): ClassifyResult { + const op = spec.paths[path]?.[method]; + if (!op) return { standard: false, reason: `operation "${method} ${path}" not found in spec` }; + + if (!/^\/v1\/instances\/\{instance_id\}\//.test(path)) { + // Outside the generator's template shape by construction (available/*, upload, the + // instances resource's own CRUD keyed by {id} rather than a wrapping instanceId, ...). + // Reuse the coarse coverage.ts heuristic here (imprecise, but this SDK already accepted + // that trade-off for its non-blocking report) so an already-hand-implemented one of + // these doesn't turn into a needs-human finding on every single run. + if (hasCoarseSdkEndpointString(path, allResourceSource(repoRoot))) { + return { standard: false, alreadyImplemented: true }; + } + return { + standard: false, + reason: `"${method} ${path}" is not instance-scoped (does not start with /v1/instances/{instance_id}/); the generator's method template assumes that shape.`, + }; + } + + const requestBodyKind = jsonContentOnly(op.requestBody?.content); + if (requestBodyKind === "other") { + return { + standard: false, + reason: `"${method} ${path}" has a non-JSON request body (${Object.keys(op.requestBody.content).join(", ")}); multipart/binary uploads are never auto-generated.`, + }; + } + + const successCode = ["200", "201"].find((c) => op.responses?.[c]); + if (!successCode) { + return { + standard: false, + reason: `"${method} ${path}" has no 200/201 response declared; cannot determine the SDK's return type.`, + }; + } + const responseKind = jsonContentOnly(op.responses[successCode].content); + if (responseKind === "other") { + return { + standard: false, + reason: `"${method} ${path}" has a non-JSON success response (${Object.keys(op.responses[successCode].content).join(", ")}); streaming/binary responses are never auto-generated.`, + }; + } + + const parameters: JsonValue[] = op.parameters ?? []; + // An *optional* header (e.g. every mutation's "Idempotency-Key") is never modeled by this + // SDK at all, on any existing hand-written method; only a REQUIRED header/cookie param is + // out of the generator's scope, since the template has nowhere to plumb it from. + if (parameters.some((p) => (p.in === "header" || p.in === "cookie") && p.required)) { + return { + standard: false, + reason: `"${method} ${path}" has a required header or cookie parameter; unsupported by the generator template.`, + }; + } + const queryParams = parameters.filter((p) => p.in === "query"); + if (queryParams.length > 0 && method !== "get") { + return { + standard: false, + reason: `"${method} ${path}" has query parameters on a non-GET operation; unsupported by the generator template.`, + }; + } + + const resourceSegment = resourceSegmentOf(path); + if (!resourceSegment) { + return { + standard: false, + reason: `"${method} ${path}" has no literal path segment after {instance_id} to identify a resource.`, + }; + } + const segmentIndex = buildSegmentIndex(repoRoot); + const candidates = [...(segmentIndex.get(resourceSegment) ?? [])]; + if (candidates.length !== 1) { + return { + standard: false, + reason: + candidates.length === 0 + ? `"${method} ${path}" belongs to no existing resource (no SDK file uses path segment "${resourceSegment}"); this needs a brand-new resource, which a human must design.` + : `"${method} ${path}" is ambiguous: path segment "${resourceSegment}" appears in ${candidates.length} different resource files (${candidates.join(", ")}).`, + }; + } + const resourceFile = candidates[0]; + const resourceInfo = findResourceInfo(repoRoot, resourceFile); + if (!resourceInfo || !resourceInfo.standardSignature) { + return { + standard: false, + reason: `resource file "${resourceFile}" does not have the standard (instanceId, client) factory signature the generator template requires.`, + }; + } + + if (operationIsImplemented(repoRoot, method, path, resourceFile)) { + return { standard: false, alreadyImplemented: true }; + } + + const segs = pathSegments(path); + const rest = segs.slice(2); // drop "instances", "{instance_id}" + const resourceIdx = rest.lastIndexOf(resourceSegment); + const pathParamNames = rest + .slice(resourceIdx + 1) + .filter((s) => s.startsWith("{")) + .map((s) => s.slice(1, -1)); + const extraLiteralSegments = rest.slice(resourceIdx + 1).filter((s) => !s.startsWith("{")); + const isCollection = !rest[rest.length - 1]?.startsWith("{"); + + let verbWord: string; + if (method === "get") verbWord = isCollection ? "list" : "get"; + else if (method === "post") verbWord = "create"; + else if (method === "delete") verbWord = "delete"; + else verbWord = "update"; // put | patch + + const methodName = verbWord + extraLiteralSegments.map(pascalCase).join(""); + const resourcePascal = pascalCase(singularize(resourceSegment)); + const typePrefix = pascalCase(methodName) + resourcePascal; + + const wholeSchemas = loadWholeSchemaSymbols(map); + const ctx: TypegenContext = { spec, canonicalEnums, wholeSchemas, importsNeeded: new Set() }; + + const newMapEntries: OperationInsertChange["newMapEntries"] = []; + + // --- request body --- + const bodySchemaRaw: JsonValue | null = + requestBodyKind === "json" ? op.requestBody.content["application/json"].schema : null; + const bodyRefName = bodySchemaRaw ? refName(bodySchemaRaw) : null; + let bodyFields: Field[] = []; + const inputIsPure = pathParamNames.length === 0 && queryParams.length === 0; + if (bodySchemaRaw) { + const resolved = bodyRefName ? spec.components.schemas[bodyRefName] : bodySchemaRaw; + const looksLikeObject = + !!resolved?.properties || resolved?.type === "object" || !resolved?.type; + if (!looksLikeObject) { + return { + standard: false, + reason: `"${method} ${path}" request body does not resolve to a plain JSON object; unsupported by the generator template.`, + }; + } + const inputSymbolGuess = `${typePrefix}Input`; + if (!willBeAlias(bodyRefName, inputIsPure, resourceFile, wholeSchemas, inputSymbolGuess)) { + bodyFields = synthesizeObjectFields(ctx, resolved); + } + } + + const pathFields: Field[] = pathParamNames.map((name) => ({ + name, + tsType: "string", + required: true, + })); + const queryFields: Field[] = queryParams.map((p) => ({ + name: p.name as string, + tsType: synthesizeType(ctx, p.schema, !!p.required), + required: !!p.required, + })); + + let inputKind: "none" | "scalar" | "object" = "none"; + let inputSymbol = "void"; + let scalarArgName = ""; + const mergedFields = [...pathFields, ...bodyFields, ...queryFields]; + const hasBody = bodySchemaRaw !== null; + + if (pathFields.length === 0 && !hasBody && queryFields.length === 0) { + inputKind = "none"; + } else if (pathFields.length === 1 && !hasBody && queryFields.length === 0) { + inputKind = "scalar"; + inputSymbol = `${typePrefix}Input`; + scalarArgName = `${camelCase(singularize(resourceSegment))}Id`; + } else { + inputKind = "object"; + inputSymbol = `${typePrefix}Input`; + } + let typeDecls = ""; + if (inputKind === "scalar") { + typeDecls += `export type ${inputSymbol} = string;\n\n`; + } else if (inputKind === "object") { + const { decl, mapEntry } = declareNamedType({ + symbolName: inputSymbol, + fields: mergedFields, + refName: bodyRefName, + pure: inputIsPure, + currentFile: resourceFile, + alreadyWholeMapped: wholeSchemas, + pathLocator: bodyRefName ? null : { path, method, location: "requestBody" }, + }); + typeDecls += `${decl}\n\n`; + if (mapEntry) newMapEntries.push(mapEntry); + } + + // --- response --- + let outputSymbol = "void"; + if (responseKind === "json") { + outputSymbol = `${typePrefix}Response`; + const responseSchemaRaw = op.responses[successCode].content["application/json"].schema; + const responseRefName = refName(responseSchemaRaw); + const responseResolved = responseRefName + ? spec.components.schemas[responseRefName] + : responseSchemaRaw; + const responseFields = willBeAlias( + responseRefName, + true, + resourceFile, + wholeSchemas, + outputSymbol + ) + ? null + : synthesizeObjectFields(ctx, responseResolved); + const { decl, mapEntry } = declareNamedType({ + symbolName: outputSymbol, + fields: responseFields, + refName: responseRefName, + pure: true, + currentFile: resourceFile, + alreadyWholeMapped: wholeSchemas, + pathLocator: responseRefName + ? null + : { path, method, location: `response:${successCode}` }, + }); + typeDecls += `${decl}\n\n`; + if (mapEntry) newMapEntries.push(mapEntry); + } + + const pathTemplate = buildPathTemplate(path, inputKind, scalarArgName); + const methodSource = buildMethodSource({ + methodName, + method, + inputKind, + inputSymbol, + scalarArgName, + pathFieldNames: pathFields.map((f) => f.name), + queryFieldNames: queryFields.map((f) => f.name), + hasBody, + outputSymbol, + pathTemplate, + }); + + return { + standard: true, + change: { + kind: "operation-insert", + method, + path, + site: { file: resourceFile, symbol: resourceInfo.factoryFn }, + methodName, + typeDecls: typeDecls.trimEnd(), + methodSource, + importsNeeded: [...ctx.importsNeeded].sort(), + newMapEntries, + }, + }; +} + +function buildPathTemplate( + path: string, + inputKind: "none" | "scalar" | "object", + scalarArgName: string +): string { + const segs = pathSegments(path).slice(2); // drop "instances", "{instance_id}" + let scalarUsed = false; + const rendered = segs.map((seg) => { + if (!seg.startsWith("{")) return seg; + const name = seg.slice(1, -1); + if (inputKind === "scalar" && !scalarUsed) { + scalarUsed = true; + return `\${${scalarArgName}}`; + } + return `\${${name}}`; + }); + return `/instances/\${instanceId}/${rendered.join("/")}`; +} + +function buildMethodSource(args: { + methodName: string; + method: HttpMethod; + inputKind: "none" | "scalar" | "object"; + inputSymbol: string; + scalarArgName: string; + pathFieldNames: string[]; + queryFieldNames: string[]; + hasBody: boolean; + outputSymbol: string; + pathTemplate: string; +}): string { + const { + methodName, + method, + inputKind, + inputSymbol, + scalarArgName, + pathFieldNames, + queryFieldNames, + hasBody, + outputSymbol, + pathTemplate, + } = args; + const returnType = `Promise>`; + + if (inputKind === "none") { + return `${methodName}(): ${returnType} {\n return client.${method}(\`${pathTemplate}\`);\n },`; + } + + if (inputKind === "scalar") { + return ( + `${methodName}(${scalarArgName}: ${inputSymbol}): ${returnType} {\n` + + ` return client.${method}(\`${pathTemplate}\`);\n` + + ` },` + ); + } + + // inputKind === "object" + if (method === "get" && pathFieldNames.length === 0 && queryFieldNames.length > 0) { + return ( + `${methodName}(params?: ${inputSymbol}): ${returnType} {\n` + + ` const queryParams = params ? \`?\${new URLSearchParams(params)}\` : "";\n` + + ` return client.get(\`${pathTemplate}\${queryParams}\`);\n` + + ` },` + ); + } + if (method === "get" && pathFieldNames.length > 0 && queryFieldNames.length > 0) { + return ( + `${methodName}({ ${pathFieldNames.join(", ")}, ...params }: ${inputSymbol}): ${returnType} {\n` + + ` const queryParams = Object.keys(params).length\n` + + ` ? \`?\${new URLSearchParams(params as Record)}\`\n` + + ` : "";\n` + + ` return client.get(\`${pathTemplate}\${queryParams}\`);\n` + + ` },` + ); + } + if (pathFieldNames.length > 0 && hasBody) { + return ( + `${methodName}({ ${pathFieldNames.join(", ")}, ...data }: ${inputSymbol}): ${returnType} {\n` + + ` return client.${method}(\`${pathTemplate}\`, data);\n` + + ` },` + ); + } + if (pathFieldNames.length > 0 && !hasBody) { + return ( + `${methodName}({ ${pathFieldNames.join(", ")} }: ${inputSymbol}): ${returnType} {\n` + + ` return client.${method}(\`${pathTemplate}\`);\n` + + ` },` + ); + } + // pathFieldNames.length === 0 && hasBody + return ( + `${methodName}({ ...data }: ${inputSymbol}): ${returnType} {\n` + + ` return client.${method}(\`${pathTemplate}\`, data);\n` + + ` },` + ); +} + +export { loadCanonicalEnums, HTTP_METHODS }; diff --git a/scripts/api-sync/reconcile.ts b/scripts/api-sync/reconcile.ts index ccedf96..b76d606 100644 --- a/scripts/api-sync/reconcile.ts +++ b/scripts/api-sync/reconcile.ts @@ -81,7 +81,11 @@ export function reconcile( spec: Spec, map: SpecMap, unmodeled: UnmodeledFile, - repoRoot: string + repoRoot: string, + /** "file:symbol" sites operation-insert is about to (re)create this same run: a mapped + * site not existing YET is exactly what discovers the coverage gap in the first place, not + * a separate failure reconcile() should also report. */ + pendingSites: Set = new Set() ): ReconcileResult { const applicable: ApplicableChange[] = []; const needsHuman: string[] = []; @@ -97,6 +101,8 @@ export function reconcile( const enumSymbolsCovered = new Set(map.enums.flatMap((e) => e.sdk.map((s) => s.symbol))); for (const entry of map.enums) { + if (entry.sdk.every((s) => pendingSites.has(`${s.file}:${s.symbol}`))) continue; + const label = locatorLabel(entry.spec); let specMembers: Set; try { @@ -122,9 +128,11 @@ export function reconcile( } sdkMembers = extracted; } catch (err) { - needsHuman.push( - `enum mapping "${label}" -> ${site.file}:${site.symbol} not found: ${(err as Error).message}` - ); + if (!pendingSites.has(`${site.file}:${site.symbol}`)) { + needsHuman.push( + `enum mapping "${label}" -> ${site.file}:${site.symbol} not found: ${(err as Error).message}` + ); + } continue; } @@ -149,6 +157,10 @@ export function reconcile( } for (const entry of map.types) { + // Every mapped site is about to be (re)created by operation-insert this same run: it + // owns this entry's fields entirely, nothing here to compare against yet. + if (entry.sdk.every((s) => pendingSites.has(`${s.file}:${s.symbol}`))) continue; + const label = locatorLabel(entry.spec); const schemaName = isPathLocator(entry.spec) ? label : entry.spec.schema; const propertyName = isPathLocator(entry.spec) ? undefined : entry.spec.property; @@ -179,9 +191,13 @@ export function reconcile( for (const k of keys) union.add(k); fieldsBySite.set(siteKey, extractTopLevelFields(span.text)); } catch (err) { - needsHuman.push( - `type mapping "${label}" -> ${siteKey} not found: ${(err as Error).message}` - ); + if (pendingSites.has(siteKey)) { + opaqueSites.add(siteKey); // about to be (re)created; nothing to compare yet + } else { + needsHuman.push( + `type mapping "${label}" -> ${siteKey} not found: ${(err as Error).message}` + ); + } } } diff --git a/scripts/api-sync/resource-registry.ts b/scripts/api-sync/resource-registry.ts new file mode 100644 index 0000000..4adaf28 --- /dev/null +++ b/scripts/api-sync/resource-registry.ts @@ -0,0 +1,152 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +export type ResourceInfo = { + file: string; + factoryFn: string; + /** True only for the common `(instanceId: string, client: InternalApiClient)` shape the + * generator's method template assumes; anything else is out of scope (needs-human). */ + standardSignature: boolean; +}; + +function listResourceFiles(repoRoot: string): string[] { + const dir = join(repoRoot, "src/resources"); + const out: string[] = []; + const walk = (d: string) => { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const full = join(d, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) out.push(full); + } + }; + walk(dir); + return out; +} + +/** Same rule as `resourceSegmentOf`, applied to a hand-written request-path template instead + * of a spec path: the deepest literal segment that starts a new collection level. */ +function owningSegmentOfTemplate(template: string): string | null { + const rest = template + .replace(/^\/instances\/\$\{[^}]+\}\/?/, "") + .split("/") + .filter((s) => s.length > 0); + let last: string | null = null; + for (let i = 0; i < rest.length; i++) { + const seg = rest[i]; + if (/^\{[^}]*\}$/.test(seg) || /^\$\{[^}]*\}$/.test(seg)) continue; + const literal = seg.split("${")[0]; // drop a trailing non-slash spread, e.g. "payins${queryParams}" + if (!literal) continue; + const prev = rest[i - 1]; + const prevIsParam = + prev !== undefined && (/^\{[^}]*\}$/.test(prev) || /^\$\{[^}]*\}$/.test(prev)); + if (i === 0 || prevIsParam) last = literal; + } + return last; +} + +/** + * Maps a resource segment (e.g. "payins", "bank-accounts") to the resource file(s) that OWN a + * `client.get/post/put/patch/delete(\`...\`)` call at that collection level (see + * `owningSegmentOfTemplate`; a segment merely passing through as an ancestor, like "customers" + * in a bank-accounts template, is not ownership). The same segment owned by more than one file + * is ambiguous on purpose: the classifier treats that as "no single resource owns this", never + * a guess. + */ +export function buildSegmentIndex(repoRoot: string): Map> { + const index = new Map>(); + for (const full of listResourceFiles(repoRoot)) { + const rel = relative(repoRoot, full); + const content = readFileSync(full, "utf8"); + const calls = content.matchAll(/client\.(?:get|post|put|patch|delete)\(\s*`([^`]*)`/g); + for (const m of calls) { + const seg = owningSegmentOfTemplate(m[1]); + if (!seg) continue; + if (!index.has(seg)) index.set(seg, new Set()); + (index.get(seg) as Set).add(rel); + } + } + return index; +} + +/** Path shape with every `{param}` / `${...}` segment collapsed to a bare "{}" marker, and any + * trailing dynamic suffix on the final segment (e.g. a spread `${queryParams}`) dropped, so a + * spec path and a hand-written template literal can be compared regardless of variable names. */ +function pathShape(segments: string[]): string { + return segments + .map((seg, i) => { + // A whole-segment param, either OpenAPI's `{name}` or a JS template's `${name}`. + if (/^\{[^}]*\}$/.test(seg) || /^\$\{[^}]*\}$/.test(seg)) return "{}"; + if (i === segments.length - 1) { + // A trailing spread with no slash of its own, e.g. `payins${queryParams}` + // (query string, not a path segment): keep only its literal prefix. + const idx = seg.indexOf("${"); + if (idx > 0) return seg.slice(0, idx); + } + return seg; + }) + .join("/"); +} + +/** + * Precise (not the coarse "does this literal segment appear anywhere" heuristic coverage.ts + * uses for its non-blocking report): true only if `resourceFile` has a `client.(...)` + * call whose path template has the exact same literal/dynamic segment shape as `path`, after + * dropping the `instances/{instance_id}` prefix both sides share. Sensitive to a single method + * disappearing even when its resource file still implements sibling operations on the same + * literal segment (unlike the coarse heuristic), which is what makes it safe to use as the + * operation-insert discovery signal instead of only a diff against the previous spec. + */ +export function operationIsImplemented( + repoRoot: string, + method: string, + path: string, + resourceFile: string +): boolean { + const expected = pathShape( + path + .replace(/^\/v1\/?/, "") + .replace(/^instances\/\{[^}]+\}\/?/, "") + .split("/") + .filter((s) => s.length > 0) + ); + const content = readFileSync(join(repoRoot, resourceFile), "utf8"); + const calls = content.matchAll(new RegExp(`client\\.${method}\\(\\s*\`([^\`]*)\``, "g")); + for (const m of calls) { + const template = m[1] + .replace(/^\/instances\/\$\{[^}]+\}\/?/, "") + .split("/") + .filter((s) => s.length > 0); + if (pathShape(template) === expected) return true; + } + return false; +} + +export function findResourceInfo(repoRoot: string, file: string): ResourceInfo | null { + const content = readFileSync(join(repoRoot, file), "utf8"); + const m = /^export function (create\w+Resource)\(([^)]*)\)/m.exec(content); + if (!m) return null; + const params = m[2].replace(/\s+/g, " ").trim(); + const standardSignature = /^instanceId:\s*string,\s*client:\s*InternalApiClient$/.test(params); + return { file, factoryFn: m[1], standardSignature }; +} + +/** + * The path segment that identifies which resource a new operation belongs to: the deepest + * literal segment that starts a new "collection level" (either the very first segment after + * {instance_id}, or a literal immediately following a `{param}`). For a nested path like + * `customers/{customer_id}/bank-accounts/{id}` that is "bank-accounts", not "customers": the + * generic parent container is never the right resource once the path nests past it. + */ +export function resourceSegmentOf(path: string): string | null { + const rest = path + .replace(/^\/v1\/?/, "") + .replace(/^instances\/\{[^}]+\}\/?/, "") + .split("/") + .filter((s) => s.length > 0); + let last: string | null = null; + for (let i = 0; i < rest.length; i++) { + if (rest[i].startsWith("{")) continue; + if (i === 0 || rest[i - 1].startsWith("{")) last = rest[i]; + } + return last; +} diff --git a/scripts/api-sync/typegen.ts b/scripts/api-sync/typegen.ts new file mode 100644 index 0000000..0a74a99 --- /dev/null +++ b/scripts/api-sync/typegen.ts @@ -0,0 +1,167 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { extractEnumMembers, findDeclarationSpan } from "./sdk-scan"; +import type { JsonValue, Spec } from "./spec"; +import type { SpecMap } from "./types"; + +function resolveRef(spec: Spec, node: JsonValue): JsonValue { + if (node && typeof node === "object" && typeof node.$ref === "string") { + const name = node.$ref.split("/").pop() as string; + return spec.components.schemas[name]; + } + return node; +} + +function refName(node: JsonValue): string | null { + if (node && typeof node === "object" && typeof node.$ref === "string") { + return node.$ref.split("/").pop() as string; + } + return null; +} + +/** Every `export type X = "a" | "b" | ...;` declared in types/index.d.ts, keyed by a sorted, + * comma-joined signature of its member values, so an inline spec enum with the same value set + * can be pointed at the canonical shared symbol instead of a fresh inline literal union. */ +export function loadCanonicalEnums(repoRoot: string): Map { + const path = join(repoRoot, "types/index.d.ts"); + const content = readFileSync(path, "utf8"); + const out = new Map(); + const declRe = /^export type ([A-Za-z_][A-Za-z0-9_]*)\b/gm; + for (const m of content.matchAll(declRe)) { + const symbol = m[1]; + try { + const span = findDeclarationSpan(content, symbol); + const members = extractEnumMembers(span.text); + if (members && members.size > 0) { + out.set([...members].sort().join(","), symbol); + } + } catch { + // not a bare string-literal union; skip + } + } + return out; +} + +/** Whole-schema entries (no `property`) already mapped in spec-map.json, keyed by schema name, + * so a $ref to an already-modeled schema reuses its SDK symbol instead of being re-inlined. */ +export function loadWholeSchemaSymbols( + map: SpecMap +): Map { + const out = new Map(); + for (const entry of [...map.enums, ...map.types]) { + const loc = entry.spec as { schema?: string; property?: string }; + if (loc.schema && !loc.property && entry.sdk[0]) { + out.set(loc.schema, entry.sdk[0]); + } + } + return out; +} + +export type TypegenContext = { + spec: Spec; + canonicalEnums: Map; + wholeSchemas: Map; + /** Symbols this synthesis run ended up reusing; the caller must ensure each is imported. */ + importsNeeded: Set; +}; + +function quoteUnion(values: unknown[]): string { + return values.map((v) => JSON.stringify(v)).join(" | "); +} + +function wrapArrayItem(itemType: string): string { + return itemType.includes("|") ? `(${itemType})[]` : `${itemType}[]`; +} + +/** + * Recursively compiles a raw OpenAPI schema node into a TypeScript type expression, matching + * this codebase's existing hand-written house style: + * - required object properties are non-optional and drop the spec's nullability (matching + * e.g. CreateQuoteResponse's required `expires_at: number`, spec type `["number","null"]`). + * - optional properties keep `?` and, if the spec node itself is nullable, append `| null`. + * - string enums become a literal union, or the canonical shared symbol if one already + * declares the exact same value set (e.g. spec inlines Network's values -> `Network`). + * - a $ref to a schema already mapped whole in spec-map.json reuses that SDK symbol. + */ +export function synthesizeType(ctx: TypegenContext, node: JsonValue, required: boolean): string { + const named = refName(node); + if (named) { + const whole = ctx.wholeSchemas.get(named); + if (whole) { + ctx.importsNeeded.add(whole.symbol); + return whole.symbol; + } + } + const resolved = resolveRef(ctx.spec, node); + return synthesizeResolvedType(ctx, resolved, required); +} + +function synthesizeResolvedType(ctx: TypegenContext, node: JsonValue, required: boolean): string { + if (node === undefined || node === null) return "unknown"; + + if (Array.isArray(node.enum) && node.enum.length > 0) { + const sig = [...node.enum].map(String).sort().join(","); + const canonical = ctx.canonicalEnums.get(sig); + const base = canonical ?? quoteUnion(node.enum); + if (canonical) ctx.importsNeeded.add(canonical); + return nullableSuffix(node, base, required); + } + + const rawTypes: string[] = Array.isArray(node.type) ? node.type : node.type ? [node.type] : []; + const nonNull = rawTypes.filter((t) => t !== "null"); + + if (nonNull.includes("array")) { + const itemType = node.items ? synthesizeType(ctx, node.items, true) : "unknown"; + return nullableSuffix(node, wrapArrayItem(itemType), required); + } + if (nonNull.includes("object") || node.properties) { + return nullableSuffix(node, synthesizeObjectLiteral(ctx, node), required); + } + if (nonNull.includes("integer") || nonNull.includes("number")) { + return nullableSuffix(node, "number", required); + } + if (nonNull.includes("boolean")) { + return nullableSuffix(node, "boolean", required); + } + if (nonNull.includes("string")) { + return nullableSuffix(node, "string", required); + } + return "unknown"; +} + +function nullableSuffix(node: JsonValue, base: string, required: boolean): string { + const rawTypes: string[] = Array.isArray(node.type) ? node.type : node.type ? [node.type] : []; + const nullable = rawTypes.includes("null"); + // House style (see reconcile.ts DISCRIMINATOR_FIELDS comment block): a *required* field drops + // spec nullability entirely; only an *optional* field's `?` carries `| null` alongside it. + if (nullable && !required) return `${base} | null`; + return base; +} + +/** Compiles an object schema's properties into `{ a: T; b?: U; }`, one property per line. */ +export function synthesizeObjectLiteral(ctx: TypegenContext, node: JsonValue): string { + const props = node?.properties ?? {}; + const requiredSet = new Set(node?.required ?? []); + const lines: string[] = []; + for (const [name, propNode] of Object.entries(props)) { + const isRequired = requiredSet.has(name); + const tsType = synthesizeType(ctx, propNode, isRequired); + lines.push(` ${name}${isRequired ? "" : "?"}: ${tsType};`); + } + if (lines.length === 0) return "Record"; + return `{\n${lines.join("\n")}\n}`; +} + +/** Property list of an object schema, each with its own compiled type text (not indented), + * used to build a flat Input type merging path/query fields with a request body's properties. */ +export function synthesizeObjectFields( + ctx: TypegenContext, + node: JsonValue +): Array<{ name: string; tsType: string; required: boolean }> { + const props = node?.properties ?? {}; + const requiredSet = new Set(node?.required ?? []); + return Object.entries(props).map(([name, propNode]) => { + const isRequired = requiredSet.has(name); + return { name, tsType: synthesizeType(ctx, propNode, isRequired), required: isRequired }; + }); +} diff --git a/scripts/api-sync/types.ts b/scripts/api-sync/types.ts index 7cf0758..0616371 100644 --- a/scripts/api-sync/types.ts +++ b/scripts/api-sync/types.ts @@ -44,6 +44,13 @@ export type SpecMap = { types: TypeMapEntry[]; ignore: { schemas: IgnoreEntry[]; + /** Coverage gaps (spec operation, no SDK endpoint) the operation-insert classifier + * found NON-STANDARD, and a human has reviewed and accepted as "not generatable, not + * yet built" rather than a bug. `name` is "METHOD /path", matching computeCoverageGaps' + * key format. Grandfathers pre-existing known gaps so this patcher doesn't suddenly + * hard-fail on gaps nobody has gotten to yet; a genuinely NEW non-standard gap still + * fails needs-human until someone adds it here (or builds it by hand). */ + operations?: IgnoreEntry[]; }; }; @@ -111,6 +118,37 @@ export type UnmodeledFile = { nestedShapeOmissions: NestedShapeOmissionEntry[]; }; +export type NewSchemaMapEntry = + | { kind: "schema"; schema: string; file: string; symbol: string; notes: string | null } + | { + kind: "path"; + path: string; + method: string; + location: string; + file: string; + symbol: string; + notes: string; + }; + +/** + * A spec operation with no SDK endpoint, classified STANDARD by operation-gen.ts: JSON in/out, + * belongs to an existing resource (by literal path segment), and expressible exactly by the + * generator's method template. `typeDecls`/`methodSource` are the literal text to splice into + * `site.file`; `newMapEntries` are spec-map.json types[] entries to append so future field/enum + * drift on any newly-modeled named schema is patchable the same way as everything else. + */ +export type OperationInsertChange = { + kind: "operation-insert"; + method: string; + path: string; + site: SdkSite; + methodName: string; + typeDecls: string; + methodSource: string; + importsNeeded: string[]; + newMapEntries: NewSchemaMapEntry[]; +}; + export type ApplicableChange = | { kind: "enum-insert"; @@ -126,7 +164,8 @@ export type ApplicableChange = field: string; tsType: string; site: SdkSite; - }; + } + | OperationInsertChange; export type NeedsHumanIssue = { message: string;