Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .api-sync/spec-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
}
85 changes: 85 additions & 0 deletions scripts/api-sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <factoryFn>(...)`. 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 <factoryFn>(...) { 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)
);
}
24 changes: 16 additions & 8 deletions scripts/api-sync/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Binary file modified scripts/api-sync/index.ts
Binary file not shown.
6 changes: 6 additions & 0 deletions scripts/api-sync/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
139 changes: 139 additions & 0 deletions scripts/api-sync/operation-gen.golden.test.ts
Original file line number Diff line number Diff line change
@@ -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<BlindpayApiResponse<GetPayinResponse>> {"
);
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<BlindpayApiResponse<CreateQuoteResponse>> {"
);
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<string, string> = {
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);
});
Loading
Loading