From 0d005930fde861f5a289308f8e714637efcc0d6e Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:39:25 +0900 Subject: [PATCH 1/3] refactor(tsmorph): reuse projectFactory import builders and drop dead guards `generateFiles.mts` kept a verbatim private copy of `buildCommonFileImports` and `buildHookFileImports`, duplicating the exported versions in `projectFactory.mts`. The exported ones are covered by `tests/tsmorph/projectFactory.test.ts`; the copies were not, so the duplicated code was also the untested code. Import them instead. Three `if (hook)` null guards were unreachable: each call site pre-filtered on `isPaginatable`, and `buildInfiniteHook` / `buildPrefetchInfiniteQueryFn` return null only when `!op.isPaginatable`. Rather than assert non-null, map over the GET operations and drop the nulls, which keeps the types honest and leaves the builder as the single source of truth for pagination. The `if (infiniteOptions)` guard in `generateQueryOptionsFile` is left alone: that call site does not pre-filter and both paths are exercised. Generated example output is byte-identical. Branch coverage: generateFiles.mts 68.75% -> 75%, repo aggregate 90.07% -> 91.25%. --- src/tsmorph/generateFiles.mts | 84 +++++++++++------------------------ 1 file changed, 27 insertions(+), 57 deletions(-) diff --git a/src/tsmorph/generateFiles.mts b/src/tsmorph/generateFiles.mts index a0fbfab..b125a01 100644 --- a/src/tsmorph/generateFiles.mts +++ b/src/tsmorph/generateFiles.mts @@ -32,49 +32,16 @@ import { buildQueryOptionsFn, } from "./buildQueryOptions.mjs"; import { - buildAxiosErrorImport, buildClientImport, + buildCommonFileImports, buildCommonImport, + buildHookFileImports, buildModelImport, - buildQueryImport, buildQueryOptionsImport, buildServiceImport, createGenerationProject, } from "./projectFactory.mjs"; -/** - * Build imports for common.ts file. - */ -function buildCommonFileImports( - ctx: GenerationContext, -): ImportDeclarationStructure[] { - const imports: ImportDeclarationStructure[] = [ - buildClientImport(ctx), - buildQueryImport(), - buildServiceImport(ctx), - ]; - - const modelImport = buildModelImport(ctx); - if (modelImport) { - imports.push(modelImport); - } - - if (ctx.client === "@hey-api/client-axios") { - imports.push(buildAxiosErrorImport()); - } - - return imports; -} - -/** - * Build imports for hook files (queries, suspense, infinite, prefetch, ensure). - */ -function buildHookFileImports( - ctx: GenerationContext, -): ImportDeclarationStructure[] { - return [buildCommonImport(), ...buildCommonFileImports(ctx)]; -} - /** * Generate the index.ts file content. * The content is constant, so no ts-morph project is needed. @@ -236,12 +203,14 @@ function generateSuspenseFile( sourceFile.addVariableStatement(buildUseSuspenseQueryHook(op, ctx)); } - // Add useSuspenseInfiniteQuery hooks for paginatable operations - for (const op of getOperations.filter((o) => o.isPaginatable)) { - const hook = buildUseSuspenseInfiniteQueryHook(op, ctx); - if (hook) { - sourceFile.addVariableStatement(hook); - } + // Add useSuspenseInfiniteQuery hooks. The builder returns null for + // non-paginatable operations, so dropping the nulls yields exactly the + // paginatable subset. + const suspenseInfiniteHooks = getOperations + .map((op) => buildUseSuspenseInfiniteQueryHook(op, ctx)) + .filter((hook) => hook !== null); + for (const hook of suspenseInfiniteHooks) { + sourceFile.addVariableStatement(hook); } return sourceFile.getFullText(); @@ -264,17 +233,16 @@ function generateInfiniteQueriesFile( // Add imports sourceFile.addImportDeclarations(buildHookFileImports(ctx)); - // Only paginatable GET operations - const paginatableOperations = operations.filter( - (op) => op.httpMethod === "GET" && op.isPaginatable, - ); + // Only GET operations can be paginatable + const getOperations = operations.filter((op) => op.httpMethod === "GET"); - // Add useInfiniteQuery hooks - for (const op of paginatableOperations) { - const hook = buildUseInfiniteQueryHook(op, ctx); - if (hook) { - sourceFile.addVariableStatement(hook); - } + // Add useInfiniteQuery hooks. The builder returns null for non-paginatable + // operations, so dropping the nulls yields exactly the paginatable subset. + const infiniteHooks = getOperations + .map((op) => buildUseInfiniteQueryHook(op, ctx)) + .filter((hook) => hook !== null); + for (const hook of infiniteHooks) { + sourceFile.addVariableStatement(hook); } return sourceFile.getFullText(); @@ -305,12 +273,14 @@ function generatePrefetchFile( sourceFile.addVariableStatement(buildPrefetchFn(op, ctx)); } - // Add prefetchInfiniteQuery functions for paginatable operations - for (const op of getOperations.filter((o) => o.isPaginatable)) { - const fn = buildPrefetchInfiniteQueryFn(op, ctx); - if (fn) { - sourceFile.addVariableStatement(fn); - } + // Add prefetchInfiniteQuery functions. The builder returns null for + // non-paginatable operations, so dropping the nulls yields exactly the + // paginatable subset. + const prefetchInfiniteFns = getOperations + .map((op) => buildPrefetchInfiniteQueryFn(op, ctx)) + .filter((fn) => fn !== null); + for (const fn of prefetchInfiniteFns) { + sourceFile.addVariableStatement(fn); } return sourceFile.getFullText(); From 4097d0329f106544613330ce5919ed661e8d11a1 Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:41:01 +0900 Subject: [PATCH 2/3] refactor(tsmorph): extract buildQueryOptionsFileImports into projectFactory `generateQueryOptionsFile` assembled its own import list inline, repeating the `buildModelImport` + null-check pattern that `buildCommonFileImports` already owns. Like the copies removed in the previous commit, that inline branch was the one part of the import wiring no test reached. Move it next to its siblings in `projectFactory.mts` and cover both the with-models and no-models paths, mirroring the existing `buildCommonFileImports` tests. Generated example output is byte-identical. Branch coverage: generateFiles.mts 75% -> 100%, repo aggregate 91.25% -> 91.66%. --- src/tsmorph/generateFiles.mts | 19 ++--------------- src/tsmorph/projectFactory.mts | 23 ++++++++++++++++++++ tests/tsmorph/projectFactory.test.ts | 32 ++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/tsmorph/generateFiles.mts b/src/tsmorph/generateFiles.mts index b125a01..5b65204 100644 --- a/src/tsmorph/generateFiles.mts +++ b/src/tsmorph/generateFiles.mts @@ -1,4 +1,3 @@ -import type { ImportDeclarationStructure } from "ts-morph"; import { OpenApiRqFiles } from "../constants.mjs"; import type { GeneratedFile, @@ -32,13 +31,9 @@ import { buildQueryOptionsFn, } from "./buildQueryOptions.mjs"; import { - buildClientImport, buildCommonFileImports, - buildCommonImport, buildHookFileImports, - buildModelImport, - buildQueryOptionsImport, - buildServiceImport, + buildQueryOptionsFileImports, createGenerationProject, } from "./projectFactory.mjs"; @@ -149,17 +144,7 @@ function generateQueryOptionsFile( ); // Add imports - const imports: ImportDeclarationStructure[] = [ - buildCommonImport(), - buildQueryOptionsImport(), - buildClientImport(ctx), - buildServiceImport(ctx), - ]; - const modelImport = buildModelImport(ctx); - if (modelImport) { - imports.push(modelImport); - } - sourceFile.addImportDeclarations(imports); + sourceFile.addImportDeclarations(buildQueryOptionsFileImports(ctx)); // Only GET operations have query options const getOperations = operations.filter((op) => op.httpMethod === "GET"); diff --git a/src/tsmorph/projectFactory.mts b/src/tsmorph/projectFactory.mts index e3b74ba..eb996d9 100644 --- a/src/tsmorph/projectFactory.mts +++ b/src/tsmorph/projectFactory.mts @@ -174,3 +174,26 @@ export function buildHookFileImports( ): ImportDeclarationStructure[] { return [buildCommonImport(), ...buildCommonFileImports(ctx)]; } + +/** + * Build all imports needed for the queryOptions file. + * Narrower than the hook file imports: queryOptions only needs the + * queryOptions/infiniteQueryOptions helpers, not the TanStack hooks. + */ +export function buildQueryOptionsFileImports( + ctx: GenerationContext, +): ImportDeclarationStructure[] { + const imports: ImportDeclarationStructure[] = [ + buildCommonImport(), + buildQueryOptionsImport(), + buildClientImport(ctx), + buildServiceImport(ctx), + ]; + + const modelImport = buildModelImport(ctx); + if (modelImport) { + imports.push(modelImport); + } + + return imports; +} diff --git a/tests/tsmorph/projectFactory.test.ts b/tests/tsmorph/projectFactory.test.ts index d1a2cbe..033b2c4 100644 --- a/tests/tsmorph/projectFactory.test.ts +++ b/tests/tsmorph/projectFactory.test.ts @@ -8,6 +8,7 @@ import { buildHookFileImports, buildModelImport, buildQueryImport, + buildQueryOptionsFileImports, buildServiceImport, createGenerationProject, } from "../../src/tsmorph/projectFactory.mjs"; @@ -232,4 +233,35 @@ describe("projectFactory", () => { ).toBe(true); }); }); + + describe("buildQueryOptionsFileImports", () => { + it("should include Common, the queryOptions helpers, sdk and models", () => { + const result = buildQueryOptionsFileImports(mockFetchContext); + + expect(result[0].moduleSpecifier).toBe("./common"); + expect(result[0].namespaceImport).toBe("Common"); + expect( + result.some((i) => i.moduleSpecifier === "../requests/sdk.gen"), + ).toBe(true); + expect( + result.some((i) => i.moduleSpecifier === "../requests/types.gen"), + ).toBe(true); + // queryOptions never calls the TanStack hooks, so no hook import + expect( + result.some((i) => + i.namedImports?.some( + (n) => typeof n === "object" && n.name === "useQuery", + ), + ), + ).toBe(false); + }); + + it("should not include model import when no models", () => { + const result = buildQueryOptionsFileImports(mockEmptyModelsContext); + + expect( + result.some((i) => i.moduleSpecifier === "../requests/types.gen"), + ).toBe(false); + }); + }); }); From 5f98c3cc89a75285b47ebcdf6e02b1ba6f0bdf9c Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:38:00 +0900 Subject: [PATCH 3/3] refactor(tsmorph): route nullable builders through one addStatements helper generateFiles.mts had four call sites for builders that return null when the operation is out of scope, written three different ways: a plain `if` guard for `buildInfiniteQueryOptionsFn`, and three near-identical `.map(build).filter(x => x !== null)` blocks each carrying its own near-identical three-line comment. Collapse all four onto a single `addStatements(sourceFile, operations, build)` helper. The contract ("a builder returns null for operations outside its scope") is now stated once, in the helper's JSDoc, instead of being re-derived at every site. This also fixes a build regression: narrowing `(T | null)[]` via a bare `.filter(x => x !== null)` relies on inferred type predicates, a TypeScript 5.5 feature, while package.json declares a `typescript: "5.x || 6.x"` peer range. Verified with typescript@5.4.5 -- `tsc --noEmit` failed with three TS2345 errors before this commit and passes after. Consumers of the published `dist` were never affected; this only broke building from source on TS 5.0-5.4. The queryOptions import test now asserts the exact module-specifier order rather than mere membership, since that order is emitted verbatim. Generated example output is byte-identical. --- src/tsmorph/generateFiles.mts | 68 +++++++++++++++------------- tests/tsmorph/projectFactory.test.ts | 15 +++--- 2 files changed, 44 insertions(+), 39 deletions(-) diff --git a/src/tsmorph/generateFiles.mts b/src/tsmorph/generateFiles.mts index 5b65204..fbaf85c 100644 --- a/src/tsmorph/generateFiles.mts +++ b/src/tsmorph/generateFiles.mts @@ -1,3 +1,4 @@ +import type { SourceFile, VariableStatementStructure } from "ts-morph"; import { OpenApiRqFiles } from "../constants.mjs"; import type { GeneratedFile, @@ -37,6 +38,25 @@ import { createGenerationProject, } from "./projectFactory.mjs"; +/** + * Add one variable statement per operation, skipping the ones the builder + * declines. A builder returns null when the operation is out of its scope — + * every infinite-query builder returns null for non-paginatable operations, + * which is how the paginatable subset gets selected. + */ +function addStatements( + sourceFile: SourceFile, + operations: OperationInfo[], + build: (op: OperationInfo) => VariableStatementStructure | null, +): void { + for (const op of operations) { + const statement = build(op); + if (statement) { + sourceFile.addVariableStatement(statement); + } + } +} + /** * Generate the index.ts file content. * The content is constant, so no ts-morph project is needed. @@ -153,12 +173,10 @@ function generateQueryOptionsFile( sourceFile.addVariableStatement(buildQueryOptionsFn(op, ctx)); } - for (const op of getOperations) { - const infiniteOptions = buildInfiniteQueryOptionsFn(op, ctx); - if (infiniteOptions) { - sourceFile.addVariableStatement(infiniteOptions); - } - } + // Add infiniteQueryOptions factories + addStatements(sourceFile, getOperations, (op) => + buildInfiniteQueryOptionsFn(op, ctx), + ); return sourceFile.getFullText(); } @@ -188,15 +206,10 @@ function generateSuspenseFile( sourceFile.addVariableStatement(buildUseSuspenseQueryHook(op, ctx)); } - // Add useSuspenseInfiniteQuery hooks. The builder returns null for - // non-paginatable operations, so dropping the nulls yields exactly the - // paginatable subset. - const suspenseInfiniteHooks = getOperations - .map((op) => buildUseSuspenseInfiniteQueryHook(op, ctx)) - .filter((hook) => hook !== null); - for (const hook of suspenseInfiniteHooks) { - sourceFile.addVariableStatement(hook); - } + // Add useSuspenseInfiniteQuery hooks + addStatements(sourceFile, getOperations, (op) => + buildUseSuspenseInfiniteQueryHook(op, ctx), + ); return sourceFile.getFullText(); } @@ -221,14 +234,10 @@ function generateInfiniteQueriesFile( // Only GET operations can be paginatable const getOperations = operations.filter((op) => op.httpMethod === "GET"); - // Add useInfiniteQuery hooks. The builder returns null for non-paginatable - // operations, so dropping the nulls yields exactly the paginatable subset. - const infiniteHooks = getOperations - .map((op) => buildUseInfiniteQueryHook(op, ctx)) - .filter((hook) => hook !== null); - for (const hook of infiniteHooks) { - sourceFile.addVariableStatement(hook); - } + // Add useInfiniteQuery hooks + addStatements(sourceFile, getOperations, (op) => + buildUseInfiniteQueryHook(op, ctx), + ); return sourceFile.getFullText(); } @@ -258,15 +267,10 @@ function generatePrefetchFile( sourceFile.addVariableStatement(buildPrefetchFn(op, ctx)); } - // Add prefetchInfiniteQuery functions. The builder returns null for - // non-paginatable operations, so dropping the nulls yields exactly the - // paginatable subset. - const prefetchInfiniteFns = getOperations - .map((op) => buildPrefetchInfiniteQueryFn(op, ctx)) - .filter((fn) => fn !== null); - for (const fn of prefetchInfiniteFns) { - sourceFile.addVariableStatement(fn); - } + // Add prefetchInfiniteQuery functions + addStatements(sourceFile, getOperations, (op) => + buildPrefetchInfiniteQueryFn(op, ctx), + ); return sourceFile.getFullText(); } diff --git a/tests/tsmorph/projectFactory.test.ts b/tests/tsmorph/projectFactory.test.ts index 033b2c4..150d713 100644 --- a/tests/tsmorph/projectFactory.test.ts +++ b/tests/tsmorph/projectFactory.test.ts @@ -238,14 +238,15 @@ describe("projectFactory", () => { it("should include Common, the queryOptions helpers, sdk and models", () => { const result = buildQueryOptionsFileImports(mockFetchContext); - expect(result[0].moduleSpecifier).toBe("./common"); + // Import order is emitted verbatim, so it is part of the contract + expect(result.map((i) => i.moduleSpecifier)).toEqual([ + "./common", + "@tanstack/react-query", + "../requests/sdk.gen", + "../requests/sdk.gen", + "../requests/types.gen", + ]); expect(result[0].namespaceImport).toBe("Common"); - expect( - result.some((i) => i.moduleSpecifier === "../requests/sdk.gen"), - ).toBe(true); - expect( - result.some((i) => i.moduleSpecifier === "../requests/types.gen"), - ).toBe(true); // queryOptions never calls the TanStack hooks, so no hook import expect( result.some((i) =>