diff --git a/.changeset/qraft-tree-shaking-plugin.md b/.changeset/qraft-tree-shaking-plugin.md new file mode 100644 index 000000000..483fbee1a --- /dev/null +++ b/.changeset/qraft-tree-shaking-plugin.md @@ -0,0 +1,5 @@ +--- +"@openapi-qraft/tree-shaking-plugin": minor +--- + +Add a cross-bundler tree-shaking plugin for generated context API clients. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fcfeedbfb..c5e6a88ff 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -39,7 +39,7 @@ jobs: run: yarn install --immutable - name: Build - run: yarn build:publishable + run: yarn build:publishable --force - name: Remove Verdaccio Storage run: rm -rf e2e/verdaccio-storage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d11df219..3d16a1027 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: run: yarn install --immutable - name: Build - run: yarn build:publishable + run: yarn build:publishable --force - name: Create dummy npmrc # Prevent creation of '.npmrc' by 'changesets-gitlab' with the 'NPM_TOKEN' run: touch ".npmrc" diff --git a/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-explicit-options-coverage.md b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-explicit-options-coverage.md new file mode 100644 index 000000000..c5e7fa599 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-explicit-options-coverage.md @@ -0,0 +1,86 @@ +# Qraft Tree-Shaking Explicit Options Coverage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. When spawning workers, prefer a mini model and keep `reasoning_effort` at `high` or lower. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Cover the explicit `requestFn` and `queryClient` branches in the `tree-shaking-bundlers` fixture so the external e2e loop proves those overloads still tree-shake correctly. + +**Architecture:** This plan stays relative-path only so it isolates branch coverage from resolver diversity. Two small entrypoints exercise the generated client in context-style and precreated-style form, each with explicit options calls that must survive bundling. `scenarios.mjs` owns the new matrix rows and `assert-dist.mjs` verifies both the constructor choice and the option-branch tokens. + +**Tech Stack:** Node.js, Yarn 4, Vite, Rollup, Webpack, Rspack, esbuild, and the existing tree-shaking fixture scripts. + +**File Structure:** +- `e2e/projects/tree-shaking-bundlers/src/context-explicit-options-relative.ts`: new context-style entrypoint that calls the generated client with explicit options. +- `e2e/projects/tree-shaking-bundlers/src/precreated-explicit-options-relative.ts`: new precreated-style entrypoint that calls the precreated client with explicit options. +- `e2e/projects/tree-shaking-bundlers/scripts/scenarios.mjs`: add the new scenarios and their expected tokens. +- `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs`: assert the explicit-option branches and the constructor token choices. + +--- + +### Task 1: Add the new explicit-options entrypoints and make the assertions fail first + +**Files:** +- Create: `e2e/projects/tree-shaking-bundlers/src/context-explicit-options-relative.ts` +- Create: `e2e/projects/tree-shaking-bundlers/src/precreated-explicit-options-relative.ts` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/scenarios.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` + +- [ ] **Step 1: Add a context-style entrypoint that exercises both option branches** + +Create `src/context-explicit-options-relative.ts` so it imports `createRelativeAPIClient` and exports both `createRelativeAPIClient({ requestFn: () => Promise.reject(new Error('stub')) })` and `createRelativeAPIClient({ queryClient: {} })`. Keep the file tiny and export the results so bundlers cannot drop either call. + +- [ ] **Step 2: Add a precreated-style entrypoint that exercises both option branches** + +Create `src/precreated-explicit-options-relative.ts` so it imports `createRelativePrecreatedAPIClient` and exports both `createRelativePrecreatedAPIClient({ requestFn: async () => ({}) })` and `createRelativePrecreatedAPIClient({ queryClient: {} })`. + +- [ ] **Step 3: Add scenario rows for the two new entrypoints** + +Add `context-explicit-options-relative` and `precreated-explicit-options-relative` to `scenarios.mjs`. Keep them relative-only; alias and extension diversity are already covered elsewhere in the matrix. + +- [ ] **Step 4: Tighten the output assertions around constructor choice and explicit-option branches** + +Update `assert-dist.mjs` so the context-style scenario must include `qraftReactAPIClient`, `requestFn`, and `queryClient`, while excluding `qraftAPIClient`. The precreated-style scenario must include `qraftAPIClient`, `requestFn`, and `queryClient`, while excluding `qraftReactAPIClient`. Keep the existing "unused context symbol must stay out" check for the precreated case. + +- [ ] **Step 5: Run the local e2e workflow and confirm the new matrix is green** + +Run: + +```bash +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +Expected: all bundlers pass for both new scenarios and the bundle text still reflects the intended branch selection. + +- [ ] **Step 6: Commit the explicit-options coverage** + +```bash +git add e2e/projects/tree-shaking-bundlers +git commit -m "test: cover explicit tree-shaking options branches" +``` + +### Task 2: Refresh the baseline after the new scenarios land + +**Files:** +- Modify: `e2e/projects/tree-shaking-bundlers/dist/**` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/scenarios.mjs` + +- [ ] **Step 1: Re-run the local e2e workflow from a clean state** + +Run: + +```bash +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +Expected: the new explicit-options scenarios and the pre-existing matrix all pass together. + +- [ ] **Step 2: Refresh only the checked-in outputs that changed** + +If the new scenarios change bundle text, update the checked-in fixture outputs under `e2e/projects/tree-shaking-bundlers/dist`. Keep the one-file bundle contract intact. + +- [ ] **Step 3: Commit the refreshed baseline** + +```bash +git add e2e/projects/tree-shaking-bundlers +git commit -m "test: refresh explicit options e2e baseline" +``` diff --git a/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-path-rendering.md b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-path-rendering.md new file mode 100644 index 000000000..b51bfaa49 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-path-rendering.md @@ -0,0 +1,130 @@ +# Qraft Tree-Shaking Path Rendering Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. When spawning workers, prefer a mini model and keep `reasoning_effort` at `high` or lower. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract the import-path rendering rules out of `src/core.ts` so path normalization lives in one focused helper module, while keeping emitted import strings unchanged. + +**Architecture:** This spec depends on the earlier pipeline split and source-map work. `src/lib/transform/path-rendering.ts` owns `composeImportPath`, `resolveRelativeImportPath`, `composeResolvedSourceImportPath`, `resolvePrecreatedOptionsImportPath`, `normalizeResolvedId`, `stripQueryAndHash`, `stripSourceExtension`, and `stripIndexSourceExtension`. `src/core.ts` imports those helpers instead of reimplementing path logic. `README.md` gets a short convention note so the output format stays documented. + +**Tech Stack:** TypeScript, Node `path`, Vitest, Yarn 4. + +--- + +### Task 1: Add helper-level tests that pin the rendering rules + +**Files:** + +- Create: `packages/tree-shaking-plugin/src/lib/transform/path-rendering.test.ts` +- Modify: `packages/tree-shaking-plugin/src/core.ts` + +- [x] **Step 1: Write the helper test before the new module exists** + +```ts +import { + composeImportPath, + composeResolvedSourceImportPath, + resolvePrecreatedOptionsImportPath, +} from './path-rendering.js'; + +it('renders relative source imports without source extensions or /index', () => { + expect( + composeResolvedSourceImportPath('/src/App.tsx', '/src/api/index.ts') + ).toBe('./api'); + expect( + composeResolvedSourceImportPath('/src/App.tsx', '/src/api/client.tsx') + ).toBe('./api/client'); + expect( + resolvePrecreatedOptionsImportPath( + '/src/App.tsx', + './client-options', + '/src/client-options/index.ts' + ) + ).toBe('./client-options'); + expect(composeImportPath('/src/App.tsx', '@openapi-qraft/react')).toBe( + '@openapi-qraft/react' + ); +}); +``` + +- [x] **Step 2: Run the helper test and confirm it fails because the module has not been extracted yet** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test -- src/lib/transform/path-rendering.test.ts +``` + +Expected: FAIL because `path-rendering.ts` does not exist yet. + +- [x] **Step 3: Add the helper module and move the path logic out of `core.ts`** + +Move these functions unchanged except for imports and exports, and update `core.ts` to import them from the new module: + +```ts +import { + composeImportPath, + composeResolvedSourceImportPath, + normalizeResolvedId, + resolvePrecreatedOptionsImportPath, + resolveRelativeImportPath, + stripIndexSourceExtension, + stripQueryAndHash, + stripSourceExtension, +} from './lib/transform/path-rendering.js'; +``` + +- [x] **Step 4: Re-run the helper test and one representative core snapshot** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test -- src/lib/transform/path-rendering.test.ts src/core.test.ts -t "renders relative source imports without source extensions or /index" +``` + +Expected: PASS, and the representative tree-shaking snapshot still emits the same bundler-friendly relative imports. + +### Task 2: Document the convention and validate the external fixture + +**Files:** + +- Modify: `packages/tree-shaking-plugin/README.md` +- Modify: `packages/tree-shaking-plugin/src/core.ts` + +- [x] **Step 1: Add a short README note for the rendering rule** + +Add this note near the options or path-convention section: + +```md +Relative generated imports are emitted without source extensions or `/index` so the output stays bundler-friendly. +Bare module specifiers are preserved as-is. +``` + +- [x] **Step 2: Run the package unit suite and typecheck** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test +yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both commands pass after the helper extraction. + +- [x] **Step 3: Run the external tree-shaking e2e checkpoint** + +Run: + +```bash +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +Expected: the external multi-bundler fixture still produces the same output shape and the path strings remain bundler-friendly. + +- [x] **Step 4: Commit the extraction** + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/path-rendering.ts packages/tree-shaking-plugin/src/lib/transform/path-rendering.test.ts packages/tree-shaking-plugin/src/core.ts packages/tree-shaking-plugin/README.md +git commit -m "refactor: centralize tree-shaking path rendering" +``` + +--- diff --git a/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-pipeline-split.md b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-pipeline-split.md new file mode 100644 index 000000000..6c7598f3f --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-pipeline-split.md @@ -0,0 +1,164 @@ +# Qraft Tree-Shaking Pipeline Split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. When spawning workers, prefer a mini model and keep `reasoning_effort` at `high` or lower. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split `transformQraftTreeShaking` into explicit planning and mutation phases without changing emitted code or public plugin options. + +**Architecture:** `src/core.ts` stays the orchestration entrypoint. `src/lib/transform/plan.ts` owns parsing, resolution, usage collection, and plan construction. `src/lib/transform/mutate.ts` owns AST writes and import insertion. `src/lib/transform/types.ts` holds the shared shapes so the plan and mutator can evolve without circular imports. This spec does not change source-map composition or path rendering rules. + +**Tech Stack:** TypeScript, Babel parser/traverse/types/generator, unplugin, Vitest, Yarn 4. + +--- + +### Task 1: Introduce the planner boundary with a failing contract test + +**Files:** +- Create: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Add a planner test that fails before the new module exists** + +```ts +import { createTransformPlan } from './lib/transform/plan.js'; + +it('collects named and inline usages in one transform plan', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const plan = await createTransformPlan( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + api.pets.getPets.useQuery(); + createAPIClient().pets.findPetsByStatus.invalidateQueries(); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(plan.namedUsages).toHaveLength(1); + expect(plan.inlineUsages).toHaveLength(1); +}); +``` + +- [x] **Step 2: Run the targeted test and confirm the planner is missing** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test -- src/core.test.ts -t "collects named and inline usages in one transform plan" +``` + +Expected: FAIL because `createTransformPlan` and the shared plan types do not exist yet. + +- [x] **Step 3: Add the shared plan types and the planner implementation** + +Use this shape for the new boundary: + +```ts +export type TransformPlan = { + ast: t.File; + clients: ClientBinding[]; + namedUsages: OperationUsage[]; + inlineUsages: InlineImportRequest[]; + generatedInfoByImport: Map; + generatedInfoRequests: Map; + transformedReferenceKeys: Set; + localClientNamesByOperation: Map; +}; + +export async function createTransformPlan( + code: string, + id: string, + options: QraftTreeShakeOptions, + resolver?: QraftResolver +): Promise; +``` + +Keep the planner responsible for discovery, resolution, and bookkeeping only. Do not move source-map composition or path rendering into this spec. + +- [x] **Step 4: Re-run the targeted test and confirm the new boundary is real** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test -- src/core.test.ts -t "collects named and inline usages in one transform plan" +``` + +Expected: PASS. + +### Task 2: Move AST mutation out of `core.ts` + +**Files:** +- Create: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` +- Modify: `packages/tree-shaking-plugin/src/core.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Add a regression snapshot that exercises the public transform after the refactor** + +Keep one representative snapshot in `core.test.ts` that still proves the emitted tree-shaking output is unchanged for a named client. + +```ts +expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { APIClientContext } from "./api/APIClientContext"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + export function App() { + return api_pets_getPets.useQuery(); + }" +`); +``` + +- [x] **Step 2: Move the write path into `applyTransformPlan` and keep `core.ts` as orchestration only** + +Use this mutator boundary: + +```ts +export function applyTransformPlan( + plan: TransformPlan, + runtimeLocalNames: RuntimeLocalNames +): void; +``` + +`src/core.ts` should parse, build a plan, apply it, and generate code. The AST write path belongs in `mutate.ts`, not in `core.ts`. + +- [x] **Step 3: Run the package unit suite and typecheck** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test +yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both commands pass with the refactor in place. + +- [x] **Step 4: Run the external tree-shaking e2e checkpoint** + +Run: + +```bash +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +Expected: the local multi-bundler fixture still publishes, updates, builds, and unpublishes cleanly with the same emitted contract. + +- [x] **Step 5: Commit the split** + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts packages/tree-shaking-plugin/src/lib/transform/plan.ts packages/tree-shaking-plugin/src/lib/transform/mutate.ts packages/tree-shaking-plugin/src/core.ts packages/tree-shaking-plugin/src/core.test.ts +git commit -m "refactor: split tree-shaking pipeline" +``` + +--- + +**Status:** completed and validated with package `lint`, `test`, `typecheck`, and external `e2e:tree-shaking-bundlers-local`. diff --git a/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-resolution-matrix-expansion.md b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-resolution-matrix-expansion.md new file mode 100644 index 000000000..4e1c820be --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-resolution-matrix-expansion.md @@ -0,0 +1,90 @@ +# Qraft Tree-Shaking Resolution Matrix Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. When spawning workers, prefer a mini model and keep `reasoning_effort` at `high` or lower. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expand the `tree-shaking-bundlers` resolution matrix with one nested-boundary `.mjs` case and one multi-factory case, then refresh the baseline without changing the one-file bundle contract. + +**Architecture:** This plan is intentionally narrow. It adds a small nested boundary under `src/extension-boundary/` to probe extension-sensitive resolution, and a separate multi-factory module to keep the tree-shaking plugin honest when more than one generated factory appears in the same bundle. `scenarios.mjs` defines the new matrix rows, `assert-dist.mjs` checks the expected tokens, and the final step refreshes the checked-in `dist` baseline if bundle text changes. + +**Tech Stack:** Node.js, Yarn 4, Vite, Rollup, Webpack, Rspack, esbuild, and the existing e2e fixture scripts. + +**File Structure:** +- `e2e/projects/tree-shaking-bundlers/src/extension-boundary/package.json`: nested boundary marker for the extension-sensitive scenario. +- `e2e/projects/tree-shaking-bundlers/src/extension-boundary/nested-entry.mjs`: entrypoint that crosses the nested boundary. +- `e2e/projects/tree-shaking-bundlers/src/extension-boundary/bridge.ts`: helper module used by the nested boundary case. +- `e2e/projects/tree-shaking-bundlers/src/extension-boundary/mixed-factories.mjs`: entrypoint that imports multiple generated factories in one module. +- `e2e/projects/tree-shaking-bundlers/scripts/scenarios.mjs`: add the two new scenarios. +- `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs`: assert the narrow token set for each new scenario. + +--- + +### Task 1: Add the new matrix cases and make the assertions fail first + +**Files:** +- Create: `e2e/projects/tree-shaking-bundlers/src/extension-boundary/package.json` +- Create: `e2e/projects/tree-shaking-bundlers/src/extension-boundary/nested-entry.mjs` +- Create: `e2e/projects/tree-shaking-bundlers/src/extension-boundary/bridge.ts` +- Create: `e2e/projects/tree-shaking-bundlers/src/extension-boundary/mixed-factories.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/scenarios.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` + +- [ ] **Step 1: Add the nested-boundary entrypoint** + +Create `src/extension-boundary/package.json` with `{ "type": "module" }`, then create `src/extension-boundary/bridge.ts` and `src/extension-boundary/nested-entry.mjs` so the entrypoint crosses that nested boundary before it calls one generated API client. + +- [ ] **Step 2: Add the multi-factory entrypoint** + +Create `src/extension-boundary/mixed-factories.mjs` so it imports more than one generated factory in the same file and exports the results from both a context-style and a precreated-style call. Keep the example small and deliberate. + +- [ ] **Step 3: Add the two scenario rows** + +Add `extension-boundary-nested-entry` and `extension-boundary-mixed-factories` to `scenarios.mjs`. Keep the matrix narrow and do not add a `.cjs` case in this plan. + +- [ ] **Step 4: Tighten the assertions for both new scenarios** + +Update `assert-dist.mjs` so the nested-boundary scenario proves the intended source file and path form survive bundling, and the multi-factory scenario proves the expected factory tokens remain while unrelated service groups still disappear. + +- [ ] **Step 5: Run the local e2e workflow and confirm the new cases are green** + +Run: + +```bash +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +Expected: the new matrix cases pass across all bundlers. + +- [ ] **Step 6: Commit the matrix expansion** + +```bash +git add e2e/projects/tree-shaking-bundlers +git commit -m "test: expand tree-shaking resolution matrix" +``` + +### Task 2: Refresh the baseline after the new matrix lands + +**Files:** +- Modify: `e2e/projects/tree-shaking-bundlers/dist/**` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/scenarios.mjs` + +- [ ] **Step 1: Re-run the local e2e workflow from a clean state** + +Run: + +```bash +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +Expected: all existing scenarios plus the two new resolution-matrix cases pass together. + +- [ ] **Step 2: Refresh only the checked-in bundle outputs that actually changed** + +If the emitted bundle text changes after the matrix expansion, update the checked-in fixture outputs under `e2e/projects/tree-shaking-bundlers/dist`. Do not add chunk or asset assertions. + +- [ ] **Step 3: Commit the refreshed baseline** + +```bash +git add e2e/projects/tree-shaking-bundlers +git commit -m "test: refresh tree-shaking matrix baseline" +``` diff --git a/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-source-map-coverage.md b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-source-map-coverage.md new file mode 100644 index 000000000..b3518689c --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-source-map-coverage.md @@ -0,0 +1,83 @@ +# Qraft Tree-Shaking E2E Source Map Coverage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. When spawning workers, prefer a mini model and keep `reasoning_effort` at `high` or lower. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add source-map assertions to the `tree-shaking-bundlers` fixture so the external e2e loop verifies original positions while keeping the one-file bundle contract. + +**Architecture:** The fixture still emits one primary JS file per bundler and scenario. This plan only adds the `.js.map` sidecar as a validation target and keeps chunk and asset assertions out of scope. `assert-dist.mjs` becomes the source-map checker, `shared.mjs` can host any helper needed to locate map files, and the bundler configs enable sourcemaps without changing the output topology. + +**Tech Stack:** Node.js, Yarn 4, Vite, Rollup, Webpack, Rspack, esbuild, and `@jridgewell/trace-mapping`. + +**File Structure:** +- `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs`: read bundle maps and verify that representative generated call sites trace back to the expected original source files. +- `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs`: bundle and map path helpers. +- `e2e/projects/tree-shaking-bundlers/vite.config.ts`, `rollup.config.mjs`, `webpack.config.mjs`, `rspack.config.mjs`, `scripts/build-esbuild.mjs`: emit sourcemaps while preserving the one-file bundle contract. +- `e2e/projects/tree-shaking-bundlers/package.json`: add `@jridgewell/trace-mapping` for the map assertions. +- `yarn.lock`: record the new dependency resolution if the fixture package gains a direct dev dependency. + +--- + +### Task 1: Make source-map coverage a first-class e2e assertion + +**Files:** +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/package.json` +- Modify: `yarn.lock` +- Modify: `e2e/projects/tree-shaking-bundlers/vite.config.ts` +- Modify: `e2e/projects/tree-shaking-bundlers/rollup.config.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/webpack.config.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/rspack.config.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/build-esbuild.mjs` + +- [x] **Step 1: Add a failing assertion that reads `.js.map` and traces back to the original source** + +Update `assert-dist.mjs` so it loads the source map for `barrel-context-relative` and `barrel-precreated-relative`, then uses `originalPositionFor(new TraceMap(map), { line, column })` to verify that one emitted call site maps back to `src/barrel-context-relative.ts` and `src/barrel-precreated-relative.ts`. + +- [x] **Step 2: Enable sourcemaps in every bundler config without changing the output shape** + +Turn on source-map emission in Vite, Rollup, Webpack, Rspack, and esbuild. Keep the existing one-entry, one-JS-file layout intact and do not add assertions for chunks or assets. + +- [x] **Step 3: Re-run the local e2e workflow to prove the new contract is stable** + +Run: + +```bash +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +Expected: the fixture still produces one JS file per scenario, the `.js.map` files exist, and the new source-map assertions pass. + +- [x] **Step 4: Commit the source-map coverage change** + +```bash +git add e2e/projects/tree-shaking-bundlers +git commit -m "test: add tree-shaking source-map coverage" +``` + +### Task 2: Refresh the checked-in baseline if bundle text changes + +**Files:** +- Modify: `e2e/projects/tree-shaking-bundlers/dist/**` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` + +- [x] **Step 1: Re-run the local e2e workflow from a clean state** + +Run: + +```bash +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +Expected: all current scenarios pass with the source-map assertions in place. + +- [x] **Step 2: Update only the checked-in bundle outputs that actually changed** + +If the emitted bundle text changes because of sourcemap-enabled builds, refresh the checked-in fixture outputs under `e2e/projects/tree-shaking-bundlers/dist`. Do not introduce any extra checks for chunks or assets. + +- [x] **Step 3: Commit the final baseline** + +```bash +git add e2e/projects/tree-shaking-bundlers +git commit -m "test: refresh tree-shaking source-map baseline" +``` diff --git a/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-source-maps.md b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-source-maps.md new file mode 100644 index 000000000..e9794a8bd --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-qraft-tree-shaking-source-maps.md @@ -0,0 +1,200 @@ +# Qraft Tree-Shaking Source Maps Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. When spawning workers, prefer a mini model and keep `reasoning_effort` at `high` or lower. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Thread incoming bundler source maps through the tree-shaking transform so rewritten user call sites remain traceable to original source code. + +**Architecture:** This spec builds on the pipeline split. `src/lib/plugin/create-qraft-tree-shake-plugin.ts` forwards `this.inputSourceMap` into `transformQraftTreeShaking` as part of the plugin contract. `src/core.ts` accepts the incoming map and passes it to Babel generator through `inputSourceMap`. The composition scope is intentionally narrow: only rewritten user call sites must resolve back to original source positions. Synthetic inserts at the top level or other generated-only regions may remain mapped to generated code if that keeps the implementation simple and predictable. Unit tests assert the composed map with `@jridgewell/trace-mapping`, while the external `tree-shaking-bundlers` fixture confirms the change does not break real bundler output. + +**Tech Stack:** TypeScript, Babel generator, unplugin, `@jridgewell/trace-mapping`, Vitest, Yarn 4. + +**File Structure:** +- `packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.ts` forwards `this.inputSourceMap` from the bundler context into the core transform. +- `packages/tree-shaking-plugin/src/core.ts` accepts an optional incoming map and passes it to Babel generator through `inputSourceMap`. +- `packages/tree-shaking-plugin/src/core.test.ts` adds the regression test, updates the local test helper to pass the optional map, and verifies the composed position with `@jridgewell/trace-mapping`. +- `packages/tree-shaking-plugin/package.json` and `yarn.lock` add the direct dev dependency required by the new test. +- `e2e/projects/tree-shaking-bundlers/` is not expected to change for this feature, but it is the external validation target. + +--- + +### Task 1: Add the failing composed-map regression test + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: `packages/tree-shaking-plugin/package.json` +- Modify: `yarn.lock` + +- [x] **Step 1: Add a source-map test that traces the rewritten call site back to the original source** + +Update the local test helper first so the new regression test can pass the incoming map through to the real transform: + +```ts +async function transformQraftTreeShaking( + code: string, + id: string, + options: TransformOptions, + inputSourceMap?: unknown +) { + const fixtureRoot = path.dirname(path.dirname(id)); + const fixtureResolver = createFixtureResolver(fixtureRoot); + const resolver = async (specifier: string, importer: string) => { + if (options.resolve) { + try { + const resolved = await options.resolve(specifier, importer); + if (resolved) return resolved; + } catch { + // Fall through to the fixture resolver. + } + } + + return fixtureResolver(specifier, importer); + }; + + return transformQraftTreeShakingImpl( + code, + id, + options, + resolver, + inputSourceMap + ); +} +``` + +```ts +import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'; + +it('keeps a rewritten user call site traceable through an incoming source map', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const originalCode = ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`; + const inputSourceMap = { + version: 3, + file: sourceFile, + sources: [sourceFile], + sourcesContent: [originalCode], + names: [], + mappings: 'AAAA', + }; + + const result = await transformQraftTreeShaking( + originalCode, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] }, + undefined, + inputSourceMap + ); + + const generatedLine = + result!.code.split('\n').findIndex((line) => line.includes('api_pets_getPets.useQuery()')) + 1; + const generatedColumn = result!.code.split('\n')[generatedLine - 1].indexOf('api_pets_getPets'); + + const traced = originalPositionFor(new TraceMap(result!.map!), { + line: generatedLine, + column: generatedColumn, + }); + + expect(traced.source).toBe(sourceFile); + expect(traced.line).toBe(7); +}); +``` + +- [x] **Step 2: Add `@jridgewell/trace-mapping` as a direct dev dependency** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin add -D @jridgewell/trace-mapping +``` + +Expected: `packages/tree-shaking-plugin/package.json` and `yarn.lock` now list `@jridgewell/trace-mapping` directly, so the new test can compile under Yarn PnP. + +- [x] **Step 3: Run the focused test before plumbing exists and confirm it fails** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test -- src/core.test.ts -t "keeps a rewritten user call site traceable through an incoming source map" +``` + +Expected: FAIL because the incoming bundler map is not threaded into the transform yet, so the composed-map assertion still points at generated-only positions. + +### Task 2: Thread the incoming map through the plugin and generator + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.ts` +- Modify: `packages/tree-shaking-plugin/src/core.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Pass `this.inputSourceMap` from the unplugin wrapper into the core transform** + +The wrapper should keep using the current resolver creation logic, but it must forward the incoming map: + +```ts +handler(this: any, code, id) { + const resolver = createResolver(this, options.resolve); + return transformQraftTreeShaking(code, id, options, resolver, this.inputSourceMap); +} +``` + +- [x] **Step 2: Extend the core transform signature and pass the incoming map into Babel generator** + +Update `packages/tree-shaking-plugin/src/core.ts` so the function accepts the optional map and forwards it unchanged: + +```ts +export async function transformQraftTreeShaking( + code: string, + id: string, + options: QraftTreeShakeOptions, + resolver: QraftResolver = createAgnosticResolver(options.resolve), + inputSourceMap?: unknown +) { + // ... + const result = generate(ast, { + sourceMaps: true, + sourceFileName: id, + inputSourceMap, + jsescOption: { minimal: true }, + }); +} +``` + +Keep the rest of the transform unchanged. This spec is only about source-map composition for rewritten user call sites; synthetic generated statements do not need bespoke original-source mapping. + +- [x] **Step 3: Re-run the focused source-map test** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test -- src/core.test.ts -t "keeps a rewritten user call site traceable through an incoming source map" +``` + +Expected: PASS, with `originalPositionFor(...)` resolving to the original `api.pets.getPets.useQuery()` call. + +- [x] **Step 4: Run the package suite, typecheck, and the external e2e checkpoint** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test +yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +Expected: all three checks pass and the external fixture still builds through every bundler. + +- [x] **Step 5: Commit the source-map plumbing** + +```bash +git add packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.ts packages/tree-shaking-plugin/src/core.ts packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/package.json yarn.lock +git commit -m "feat: compose tree-shaking source maps" +``` + +--- diff --git a/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-callback-scope-isolation.md b/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-callback-scope-isolation.md new file mode 100644 index 000000000..be1fa319a --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-callback-scope-isolation.md @@ -0,0 +1,176 @@ +# Qraft Tree-Shaking Callback Scope Isolation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ensure the tree-shaking plugin generates independent optimized client declarations for the same operation when it appears in sibling callback scopes, instead of reusing a declaration across `onMutate`, `onError`, and `onSuccess`. + +**Architecture:** The fix stays inside the tree-shaking plugin. `plan.ts` needs to remember which callback/function scope owns each usage, `types.ts` needs to carry that scope identity through the transform plan, and `mutate.ts` needs to group optimized-client declarations by that scope before inserting them. The regression test should prove that two sibling callbacks can each own their own optimized client declaration for the same operation without sharing one declaration across scopes. + +**Tech Stack:** TypeScript, Babel traverse/types, Vitest, Yarn 4, inline snapshots. + +**File Structure:** +- `packages/tree-shaking-plugin/src/core.test.ts`: regression test for sibling callback scopes using the same operation. +- `packages/tree-shaking-plugin/src/lib/transform/types.ts`: add the scope identity field to `OperationUsage`. +- `packages/tree-shaking-plugin/src/lib/transform/plan.ts`: compute callback-scope identity and key optimized-client names by scope, not only by `client/service/operation`. +- `packages/tree-shaking-plugin/src/lib/transform/mutate.ts`: partition declaration insertion by callback scope so sibling callbacks do not share one optimized declaration. + +--- + +### Task 1: Add a regression test that fails on shared declarations across sibling callbacks + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Add a mutation fixture that uses the same operation in sibling callbacks** + +Use the current explicit-options callback fixture shape, but keep the important part visible in both branches: + +```ts +const api = createAPIClient(); + +function PetUpdateForm({ petId }: { petId: number }) { + const apiContext = useContext(APIClientContext); + const petParams = { path: { petId } }; + const onUpdate = () => {}; + + api.pets.updatePet.useMutation(undefined, { + async onMutate(variables) { + const miniQraft = createAPIClient(apiContext!); + await miniQraft.pets.getPetById.cancelQueries({ + parameters: petParams, + }); + + const prevPet = miniQraft.pets.getPetById.getQueryData(petParams); + + miniQraft.pets.getPetById.setQueryData(petParams, (oldData) => ({ + ...oldData, + ...variables.body, + })); + + return { prevPet }; + }, + async onError(_error, _variables, context) { + if (context?.prevPet) { + createAPIClient(apiContext!).pets.getPetById.setQueryData( + petParams, + context.prevPet + ); + } + }, + async onSuccess(updatedPet) { + const miniQraft = createAPIClient(apiContext!); + miniQraft.pets.getPetById.setQueryData(petParams, updatedPet); + await miniQraft.pets.findPetsByStatus.invalidateQueries(); + onUpdate(); + }, + }); +} +``` + +Assert that the emitted code contains two callback-local optimized declarations for `getPetById`, one inside `onMutate` and one inside `onSuccess`, instead of a single declaration reused across both scopes. + +- [x] **Step 2: Run the targeted test and confirm the current snapshot is wrong** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "optimizes mutation callbacks across onMutate, onError, and onSuccess" +``` + +Expected: the test fails with a snapshot mismatch showing the declaration is shared across sibling callbacks or otherwise not emitted in both scopes. + +--- + +### Task 2: Thread callback-scope identity through the transform plan + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` + +- [x] **Step 1: Add scope identity to `OperationUsage`** + +Extend `OperationUsage` with a field that identifies the owning callback/function scope for the usage, for example a string key derived from the nearest function parent. Keep the field on the plan data, not on the AST, so the mutator can group declarations later without re-walking the source. + +- [x] **Step 2: Compute the scope key while scanning call expressions** + +In `plan.ts`, derive a stable scope key for each matched usage from the nearest function parent of the call site. Top-level usages should keep a program-level key so existing behavior stays unchanged. Sibling callbacks must get different keys even when they reference the same operation. + +- [x] **Step 3: Key optimized-client naming by scope, not just by operation** + +Change the `localClientNamesByOperation` bookkeeping so it includes the scope key alongside `client`, `serviceName`, and `operationName`. That keeps same-scope reuse intact while preventing sibling callbacks from collapsing to one shared local client name. + +- [x] **Step 4: Re-run the regression test to confirm the planner now separates sibling scopes** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "optimizes mutation callbacks across onMutate, onError, and onSuccess" +``` + +Expected: the snapshot should move closer to the desired shape, but the mutator may still need a scope-aware insertion pass before the test is fully green. + +--- + +### Task 3: Split optimized-client insertion by callback scope + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` + +- [x] **Step 1: Group declaration emission by the new scope key** + +Update `insertOptimizedClients` so explicit-options usages are partitioned by callback scope before declarations are created. Keep repeated same-scope references deduped, but never dedupe across sibling callback scopes. + +- [x] **Step 2: Insert declarations into the owning callback body** + +Make sure each partition is inserted at the statement list that owns that callback scope, not at the first matching declaration from another sibling callback. The important behavior is that `onMutate` and `onSuccess` each own their own optimized client declaration, even when the generated identifier text is identical. + +- [x] **Step 3: Keep the existing reuse behavior within one callback** + +Do not change same-scope reuse. If the same operation is referenced twice inside one callback body, it should still share one optimized declaration inside that callback. + +- [x] **Step 4: Re-run the targeted test until it passes, then refresh the inline snapshot** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "optimizes mutation callbacks across onMutate, onError, and onSuccess" -u +``` + +Expected: the snapshot now shows separate callback-local declarations instead of one declaration being reused across sibling callbacks. + +--- + +### Task 4: Validate the package and commit the fix + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` + +- [x] **Step 1: Run the package typecheck** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: clean typecheck with the new scope key threaded through the plan and mutator. + +- [x] **Step 2: Run the full package test file** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +``` + +Expected: the new regression passes and the existing snapshot coverage stays green. + +- [x] **Step 3: Commit the focused fix** + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/src/lib/transform/types.ts packages/tree-shaking-plugin/src/lib/transform/plan.ts packages/tree-shaking-plugin/src/lib/transform/mutate.ts docs/superpowers/plans/2026-05-09-qraft-tree-shaking-callback-scope-isolation.md +git commit -m "fix: isolate tree-shaking callback scopes" +``` diff --git a/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-no-context-callbacks.md b/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-no-context-callbacks.md new file mode 100644 index 000000000..9121ad432 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-no-context-callbacks.md @@ -0,0 +1,211 @@ +# Qraft Tree-Shaking No-Context Callback Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the tree-shaking transform recognize context-based API client calls that use only `getQueryKey`, `getInfiniteQueryKey`, or `getMutationKey`, including both inline `createAPIClient().pets...` call sites and named zero-arg locals like `const utilityClient = createAPIClient()` that currently fall through and keep the original factory import alive. + +**Architecture:** Add one small shared callback-classification helper so the transform has one source of truth for which callbacks need runtime context and which do not. The mutator then uses that classification in two places: it omits the `APIClientContext` argument and import when every callback for a generated client is context-free, and it allows inline `createAPIClient()` calls to be rewritten even when the factory call has no runtime options. Existing behavior for `useQuery`, `useMutation`, `operationInvokeFn`, and precreated clients stays unchanged. Named zero-arg locals inside a function follow the same rule when they are only used for utility callbacks. + +**Tech Stack:** TypeScript, Babel parser/traverse/types, Vitest, Yarn 4, inline snapshots. + +**File Structure:** + +- `packages/tree-shaking-plugin/src/core.test.ts`: add regression coverage for zero-arg `createAPIClient()` usage with `getQueryKey`-style callbacks. +- `packages/tree-shaking-plugin/src/lib/transform/callbacks.ts`: new shared callback-classification helper for context-free callbacks. +- `packages/tree-shaking-plugin/src/lib/transform/plan.ts`: allow zero-arg inline factory calls to enter the transform plan when they are used only with context-free callbacks. +- `packages/tree-shaking-plugin/src/lib/transform/mutate.ts`: use the shared helper to emit 2-arg `qraftReactAPIClient(...)` calls when context is unnecessary and to accept zero-arg inline factory calls for those callbacks. + +--- + +### Task 1: Add a regression test that captures the current failure + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Add a focused regression for zero-arg `createAPIClient()` usage in inline and named local form** + +Add a test that exercises both the inline call and the named local binding in the same file so the transform must handle each path: + +```ts +it('rewrites context-free callbacks from zero-arg createAPIClient calls', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +function App() { + void createAPIClient().pets.findPetsByStatus.getQueryKey(); + const utilityClient = createAPIClient(); + void utilityClient.pets.findPetsByStatus.getQueryKey(); + api.pets.findPetsByStatus.getQueryKey(); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { findPetsByStatus } from "./api/services/PetsService"; + const api_pets_findPetsByStatus = qraftReactAPIClient(findPetsByStatus, { + getQueryKey + }); + function App() { + void qraftReactAPIClient(findPetsByStatus, { + getQueryKey + }).getQueryKey(); + const utilityClient_pets_findPetsByStatus = qraftReactAPIClient(findPetsByStatus, { + getQueryKey + }); + void utilityClient_pets_findPetsByStatus.getQueryKey(); + api_pets_findPetsByStatus.getQueryKey(); + }" + `); +}); +``` + +This snapshot should prove three things at once: + +- the inline `createAPIClient().pets...` call is no longer ignored, +- the named `const utilityClient = createAPIClient()` binding is also eliminated and replaced with its own optimized client declaration in the same function scope when it is only used for utility callbacks, +- the original `createAPIClient` import disappears when it is fully transformed, +- the emitted client call does not need `APIClientContext` when the only callback is `getQueryKey`. + +- [x] **Step 2: Run the focused test and confirm it fails before code changes** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "rewrites context-free callbacks from zero-arg createAPIClient calls" +``` + +Expected: fail with the inline call still left as an untouched `createAPIClient().pets.findPetsByStatus.getQueryKey()` expression and/or with `APIClientContext` still present in the generated call for the named `utilityClient` or the inline call. + +### Task 2: Add a shared callback classification helper + +**Files:** + +- Create: `packages/tree-shaking-plugin/src/lib/transform/callbacks.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` + +- [x] **Step 1: Introduce a shared helper for callbacks that do not need runtime context** + +Add a small helper module with a single source of truth for the three no-context callbacks: + +```ts +const noContextCallbacks: ReadonlySet = new Set([ + 'getInfiniteQueryKey', + 'getMutationKey', + 'getQueryKey', +]); + +export function callbackNeedsRuntimeContext(callbackName: string) { + return !noContextCallbacks.has(callbackName); +} +``` + +Keep the helper boring: a plain `Set` and a boolean predicate are enough. Import it into both `plan.ts` and `mutate.ts` so the named-client and inline-client rewrite paths can ask the same question without duplicating the string list. + +- [x] **Step 2: Update the mutator to use the helper when building optimized client declarations** + +Change `createOptimizedClientDeclaration(...)` so it only pushes the third `APIClientContext` argument when at least one callback for that generated client needs runtime context. For a client whose callback list contains only `getQueryKey`, `getInfiniteQueryKey`, or `getMutationKey`, emit: + +```ts +qraftReactAPIClient(findPetsByStatus, { + getQueryKey, +}); +``` + +and do not import `APIClientContext` for that client. + +- [x] **Step 3: Update named zero-arg client bindings so `const utilityClient = createAPIClient()` is transformed and removed** + +Change the named-client plan and mutation paths so a zero-arg `createAPIClient()` binding inside a function is still collected into the transform plan when it is only used with context-free callbacks. The emitted optimized declaration should replace the original `utilityClient` binding in the same function scope, not keep `createAPIClient` alive, and the removal logic should delete the dead `const utilityClient = createAPIClient();` statement after the rewritten binding is inserted. + +- [x] **Step 4: Update inline rewrite logic to allow zero-arg factory calls for context-free callbacks** + +Change `matchInlineClientCall(...)` in the plan phase so it accepts both of these forms: + +```ts +createAPIClient({ queryClient: {} }).pets.getPets.useQuery(); +createAPIClient().pets.findPetsByStatus.getQueryKey(); +``` + +Keep the existing one-argument requirement for callbacks that still need runtime context or options. For no-context callbacks, treat a zero-argument factory call as valid and emit the same 2-argument `qraftReactAPIClient(...)` shape as the named-client path. + +- [x] **Step 5: Run the focused test and update the snapshot** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "rewrites context-free callbacks from zero-arg createAPIClient calls" -u +``` + +Expected: the snapshot now shows both the inline call and the named `utilityClient` binding rewritten, with the `utilityClient` declaration staying in the same function scope and the generated client declarations omitting `APIClientContext`. + +### Task 3: Validate the broader transform behavior + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` + +- [x] **Step 1: Add one mixed-behavior regression so contextful callbacks keep the old path** + +Add a second test that uses a no-context callback and a contextful callback on the same client, for example: + +```ts +it('keeps APIClientContext when the same client also uses a contextful callback', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + api.pets.findPetsByStatus.getQueryKey(); + api.pets.getPets.useQuery(); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toContain('APIClientContext'); + expect(result?.code).toContain('getQueryKey'); + expect(result?.code).toContain('useQuery'); +}); +``` + +This guards against an over-aggressive change that strips context from the whole client as soon as one no-context callback appears. + +- [x] **Step 2: Run the targeted test subset** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "rewrites context-free callbacks from zero-arg createAPIClient calls|keeps APIClientContext when the same client also uses a contextful callback" +``` + +Expected: both tests pass, and the mixed case still imports and passes `APIClientContext` only because `useQuery` is present. + +- [x] **Step 3: Run the package test and typecheck sweep** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test +yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both commands pass without introducing any new `as` casts beyond what the file already uses, and no preexisting transform tests regress. diff --git a/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-precreated-collision-safe-naming.md b/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-precreated-collision-safe-naming.md new file mode 100644 index 000000000..9de5d7967 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-precreated-collision-safe-naming.md @@ -0,0 +1,254 @@ +# Qraft Tree-Shaking Precreated Collision-Safe Naming Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make precreated optimized client declarations use file-wide unique names so nested callback locals cannot shadow them, while keeping client creation at the current top-level insertion point. + +**Architecture:** This is a naming-only phase. The tree-shaking planner already has the right inputs to make program-wide unique names: the full set of file bindings, the program scope, and the reserved import name tracker. Phase 1 switches the optimized-client name allocator from declaration-scope uniqueness to program-wide uniqueness so the emitted top-level binding cannot collide with a callback-local binding such as `APIClient_pets_getPetById` or `_APIClient_pets_getPetById`. No insertion-point logic changes, no hook placement changes, and no runtime factory changes are needed yet. The regression test stays in `core.test.ts` and proves the emitted top-level client name changes while the callback-local shadow bindings remain legal user code. + +**Tech Stack:** TypeScript, Babel parser/traverse/types, Vitest, Yarn 4, inline snapshots, Changesets. + +**File Structure:** +- `packages/tree-shaking-plugin/src/core.test.ts`: regression test for a precreated mutation callback that shadows the generated optimized client name. +- `packages/tree-shaking-plugin/src/lib/transform/plan.ts`: switch optimized-client naming to program-wide unique generation and remove the now-unneeded scope-local helper if it becomes dead code. +- `.changeset/qraft-tree-shaking-precreated-collision-safe-naming.md`: patch changeset for the published plugin package. + +--- + +### Task 1: Add the regression test that proves the collision exists today + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Replace the broken snapshot test with a collision-focused regression** + +Keep the current precreated fixture shape and the same user-visible shadowing pattern, but rename the test so it describes the actual failure: + +```ts +it('keeps precreated optimized client names collision-safe inside shadowed callbacks', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +const petParams = { path: { petId: 1 } }; + +export function App() { + APIClient.pets.updatePet.useMutation(undefined, { + async onMutate(variables) { + const APIClient_pets_getPetById = () => null; + await APIClient.pets.getPetById.cancelQueries({ parameters: petParams }); + const prevPet = APIClient.pets.getPetById.getQueryData(petParams); + APIClient.pets.getPetById.setQueryData(petParams, (old) => ({ + ...old, + ...variables.body, + })); + return { prevPet }; + }, + async onSuccess(updatedPet) { + const _APIClient_pets_getPetById = () => null; + APIClient.pets.getPetById.setQueryData(petParams, updatedPet); + await APIClient.pets.findPetsByStatus.invalidateQueries(); + }, + }); +} +`, + sourceFile, + { + apiClient: [ + { + client: 'APIClient', + clientModule: './client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './client-options', + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useMutation } from "@openapi-qraft/react/callbacks/useMutation"; + import { updatePet } from "./api/services/PetsService"; + import { createAPIClientOptions } from "./client-options"; + import { cancelQueries } from "@openapi-qraft/react/callbacks/cancelQueries"; + import { getPetById } from "./api/services/PetsService"; + import { getQueryData } from "@openapi-qraft/react/callbacks/getQueryData"; + import { setQueryData } from "@openapi-qraft/react/callbacks/setQueryData"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { findPetsByStatus } from "./api/services/PetsService"; + const APIClient_pets_updatePet = qraftAPIClient(updatePet, { + useMutation + }, createAPIClientOptions()); + const _APIClient_pets_getPetById2 = qraftAPIClient(getPetById, { + cancelQueries, + getQueryData, + setQueryData + }, createAPIClientOptions()); + const APIClient_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, createAPIClientOptions()); + const petParams = { + path: { + petId: 1 + } + }; + export function App() { + APIClient_pets_updatePet.useMutation(undefined, { + async onMutate(variables) { + const APIClient_pets_getPetById = () => null; + await _APIClient_pets_getPetById2.cancelQueries({ + parameters: petParams + }); + const prevPet = _APIClient_pets_getPetById2.getQueryData(petParams); + _APIClient_pets_getPetById2.setQueryData(petParams, old => ({ + ...old, + ...variables.body + })); + return { + prevPet + }; + }, + async onSuccess(updatedPet) { + const _APIClient_pets_getPetById = () => null; + _APIClient_pets_getPetById2.setQueryData(petParams, updatedPet); + await APIClient_pets_findPetsByStatus.invalidateQueries(); + } + }); + }" + `); +} +``` + +The important assertion is that the emitted top-level optimized client no longer uses `APIClient_pets_getPetById`, because that identifier is already occupied by a callback-local binding in the same file. The callback locals themselves should remain unchanged. + +- [x] **Step 2: Run the focused test and confirm the current output is wrong** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "keeps precreated optimized client names collision-safe inside shadowed callbacks" +``` + +Expected: the test fails before the implementation change because the generated top-level client name still collides with the shadowed callback-local binding. + +--- + +### Task 2: Switch optimized-client naming to program-wide uniqueness + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Replace the scope-local allocator with the existing program-wide allocator** + +Change the `localClientName` branch in `createTransformPlan(...)` from: + +```ts +const localClientName = + localClientNamesByOperation.get(operationKey) ?? + createScopedUniqueName( + match.client.declarationScope, + composeLocalClientName( + match.client.name, + match.serviceName, + match.operationName + ) + ); +``` + +to a program-wide allocation that uses the existing `fileBindingNames` and `reservedImportLocalNames` bookkeeping: + +```ts +const localClientName = + localClientNamesByOperation.get(operationKey) ?? + createProgramUniqueName( + programScope, + composeLocalClientName( + match.client.name, + match.serviceName, + match.operationName + ), + fileBindingNames, + reservedImportLocalNames + ); +``` + +Keep the `localClientNamesByOperation` cache so repeated references to the same operation in the same scope still reuse one generated binding. The only change in behavior should be that nested callback locals can no longer shadow the emitted optimized client declaration. + +- [x] **Step 2: Remove the now-unused `createScopedUniqueName` helper if nothing else references it** + +Delete the helper if `rg -n "createScopedUniqueName\\(" packages/tree-shaking-plugin/src/lib/transform/plan.ts` shows no remaining uses. Do not leave dead naming helpers behind if the file no longer needs them. + +- [x] **Step 3: Refresh the inline snapshot for the regression test** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "keeps precreated optimized client names collision-safe inside shadowed callbacks" -u +``` + +Expected: the inline snapshot updates so the top-level optimized client now uses the file-wide unique name, which in this fixture should be a generated uid like `_APIClient_pets_getPetById2` instead of the shadowed `APIClient_pets_getPetById`. + +--- + +### Task 3: Add the release note and validate the package + +**Files:** +- Create: `.changeset/qraft-tree-shaking-precreated-collision-safe-naming.md` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` + +- [x] **Step 1: Add a patch changeset for the plugin package** + +Create a new changeset file with this content: + +```md +--- +"@openapi-qraft/tree-shaking-plugin": patch +--- + +Make precreated optimized client names file-wide unique so shadowed callback locals cannot collide with the emitted top-level binding. +``` + +- [x] **Step 2: Run the package test file** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +``` + +Expected: the full tree-shaking test file passes, including the updated regression and the existing snapshots around context-based and explicit-options clients. + +- [x] **Step 3: Run the package typecheck** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: clean typecheck with no remaining references to the removed helper and no signature changes outside `plan.ts`. + +- [x] **Step 4: Commit the focused fix** + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/src/lib/transform/plan.ts .changeset/qraft-tree-shaking-precreated-collision-safe-naming.md +git commit -m "fix: make precreated tree-shaking names collision-safe" +``` diff --git a/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-schema-support.md b/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-schema-support.md new file mode 100644 index 000000000..55df0d258 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-qraft-tree-shaking-schema-support.md @@ -0,0 +1,332 @@ +# Qraft Tree-Shaking Schema Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite `api.pets.findPetsByStatus.schema` and `createAPIClient().pets.findPetsByStatus.schema` to direct `findPetsByStatus.schema` accesses in both context-based and precreated modes, without changing existing callback tree-shaking behavior. + +**Architecture:** Add a narrow schema-access path beside the existing callback path. The planner will recognize static member chains that end in `.schema`, resolve the underlying operation import once, and record those accesses in a separate usage bucket. The mutator will then replace the client root with the operation identifier, insert only the operation import for schema accesses, and let the existing dead-client cleanup remove unused client bindings and factory imports. This plan intentionally does not introduce a general property-rewrite framework; only `.schema` is supported. + +**Tech Stack:** TypeScript, Babel parser/traverse/types/generator, Vitest, Yarn 4, inline snapshots, existing e2e multi-bundler fixture. + +**File Structure:** + +- `packages/tree-shaking-plugin/src/core.test.ts`: add regressions for named context-based, inline zero-arg, and precreated `.schema` accesses. +- `packages/tree-shaking-plugin/src/lib/transform/types.ts`: add a schema-usage shape to the shared plan data. +- `packages/tree-shaking-plugin/src/lib/transform/plan.ts`: detect `.schema` accesses and resolve the operation import for them. +- `packages/tree-shaking-plugin/src/lib/transform/mutate.ts`: rewrite schema accesses to the direct imported operation and keep dead-client cleanup consistent. +- `e2e/projects/tree-shaking-bundlers/src/mixed-context-precreated-mirrors.ts`: add schema accesses for every existing client/operation pair in the mixed fixture. +- `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs`: extend the mixed scenario include list with the existing schema proof tokens. +- `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs`: add or update the mixed source-map assertions for the schema access lines. + +--- + +### Task 1: Add failing regressions for named, inline, and precreated schema accesses + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Add a context-based regression that covers both a named client and an inline zero-arg call** + +Add a test that proves `.schema` is rewritten even when the client is created with zero args and even when the call is inline: + +```ts +it('rewrites .schema from context-based createAPIClient calls', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + console.log(api.pets.findPetsByStatus.schema); + console.log(createAPIClient().pets.findPetsByStatus.schema); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { findPetsByStatus } from "./api/services/PetsService"; + export function App() { + console.log(findPetsByStatus.schema); + console.log(findPetsByStatus.schema); + }" + `); +}); +``` + +The important failure mode before implementation is that the output still contains either the `createAPIClient` import or an untouched `.schema` chain rooted at the client binding. + +- [x] **Step 2: Add a precreated regression that proves the imported client is removed** + +Add a second test that uses the existing precreated fixture helper so the same behavior is covered in precreated mode: + +```ts +it('rewrites .schema from precreated clients', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { services } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = qraftAPIClient(services, {}, createAPIClientOptions()); +`) + ); + + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +export function App() { + return APIClient.pets.findPetsByStatus.schema; +} +`, + sourceFile, + { + apiClient: [ + { + client: 'APIClient', + clientModule: './client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './client-options', + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { findPetsByStatus } from "./api/services/PetsService"; + export function App() { + return findPetsByStatus.schema; + }" + `); +}); +``` + +This snapshot should fail before the implementation because the precreated client import is still present and the `.schema` access is still rooted at `APIClient`. + +- [x] **Step 3: Run the focused test selection and confirm it fails** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "rewrites .schema from context-based createAPIClient calls|rewrites .schema from precreated clients" +``` + +Expected: fail with `.schema` left on the client chain and/or the original client import still present. + +### Task 2: Add a schema usage bucket to the planner and mutator + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` + +- [x] **Step 1: Add a schema-specific usage type to the shared transform plan** + +Extend the shared plan types so schema accesses are not forced through the callback-shaped `OperationUsage` record: + +```ts +export type SchemaUsage = { + client: ClientBinding; + serviceName: string; + operationName: string; + operationImport: OperationImportInfo; + scopeKey: string; +}; + +export type TransformPlan = { + ast: t.File; + clients: ClientBinding[]; + namedUsages: OperationUsage[]; + schemaUsages: SchemaUsage[]; + inlineUsages: InlineImportRequest[]; + generatedInfoByImport: Map; + generatedInfoRequests: Map; + transformedReferenceKeys: Set; + localClientNamesByOperation: Map; + runtimeLocalNames: RuntimeLocalNames; + createImports: Map; + configuredFactoryNames: Set; +}; +``` + +Keep this shape intentionally small. The only extra information schema rewrite needs is the resolved operation import and the client binding that should be removed once the access is rewritten. + +- [x] **Step 2: Teach the planner to detect `.schema` member chains** + +Add a dedicated planner pass that walks `MemberExpression` and `OptionalMemberExpression` nodes and matches the two supported shapes: + +```ts +api.pets.findPetsByStatus.schema; +createAPIClient().pets.findPetsByStatus.schema; +``` + +The match helper should: + +- accept a static member chain whose last property is exactly `schema`, +- resolve the client binding for both named and inline roots, +- resolve `findPetsByStatus` through the existing `resolveOperationImport(...)` helper, +- record a `SchemaUsage` entry with the same `scopeKey` logic used by callback usages, +- add the client name to `transformedReferenceKeys` so the original client import or binding can be removed later, +- not create any callback import entries and not require `callbackNeedsRuntimeContext(...)`. + +The planner must still allow zero-arg inline factory calls for `.schema`, because schema access does not depend on runtime context or callback options. + +Update `getUsageScopeKey(...)` so it accepts a generic `NodePath` instead of only `NodePath`. That keeps the schema path and the callback path on the same keying rule without adding a second helper. + +- [x] **Step 3: Rewrite schema accesses before client cleanup and import insertion** + +Add a `rewriteSchemaAccesses(...)` pass to `mutate.ts` that runs alongside the existing callback rewrite passes: + +```ts +traverse(ast, { + MemberExpression(memberPath) { + const match = matchSchemaAccess(memberPath.node, clients); + if (!match) return; + + const usage = schemaUsageByKey.get( + [ + match.client.name, + match.serviceName, + match.operationName, + getUsageScopeKey(memberPath), + ].join(':') + ); + + if (!usage) return; + + memberPath.node.object = t.identifier(usage.operationImport.localName); + }, +}); +``` + +The practical effect is that both of these become `findPetsByStatus.schema`: + +```ts +api.pets.findPetsByStatus.schema; +createAPIClient().pets.findPetsByStatus.schema; +``` + +Keep the existing callback rewrite path unchanged. Schema accesses should only share the operation import cache and the dead-client cleanup path, not the runtime client helper import path. + +- [x] **Step 4: Run the focused test selection and update the snapshots** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "rewrites .schema from context-based createAPIClient calls|rewrites .schema from precreated clients" -u +``` + +Expected: both snapshots now show direct `findPetsByStatus.schema` access with no `createAPIClient` or `APIClient` import left behind. + +### Task 3: Prove the schema rewrite in the bundled e2e fixture without introducing new operations + +**Files:** + +- Modify: `e2e/projects/tree-shaking-bundlers/src/mixed-context-precreated-mirrors.ts` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` + +- [x] **Step 1: Add schema access to every existing client/operation pair in the mixed fixture** + +Extend the existing mixed scenario so every callback access in the fixture has a matching `.schema` access without introducing any new operation or fixture: + +```ts +export const result = [ + barrelFromRelativeApi.pets.getPets.useQuery(), + barrelFromRelativeApi.pets.getPets.schema, + barrelFromAliasApi.pets.getPets.useQuery(), + barrelFromAliasApi.pets.getPets.schema, + relativeFromRelativeApi.pets.createPet.useMutation(), + relativeFromRelativeApi.pets.createPet.schema, + relativeFromAliasApi.pets.createPet.useMutation(), + relativeFromAliasApi.pets.createPet.schema, + relativeExtFromRelativeApi.pets.createPet.useMutation(), + relativeExtFromRelativeApi.pets.createPet.schema, + relativeExtFromAliasApi.pets.createPet.useMutation(), + relativeExtFromAliasApi.pets.createPet.schema, + aliasFromRelativeApi.stores.getStores.useQuery(), + aliasFromRelativeApi.stores.getStores.schema, + aliasFromAliasApi.stores.getStores.useQuery(), + aliasFromAliasApi.stores.getStores.schema, + aliasDirectFromRelativeApi.stores.getStores.useQuery(), + aliasDirectFromRelativeApi.stores.getStores.schema, + aliasDirectFromAliasApi.stores.getStores.useQuery(), + aliasDirectFromAliasApi.stores.getStores.schema, + barrelPrecreatedFromRelativeApi.pets.getPets.useQuery(), + barrelPrecreatedFromRelativeApi.pets.getPets.schema, + barrelPrecreatedFromAliasApi.stores.getStores.useQuery(), + barrelPrecreatedFromAliasApi.stores.getStores.schema, + fileRelativePrecreatedApi.pets.createPet.useMutation(), + fileRelativePrecreatedApi.pets.createPet.schema, + fileAliasPrecreatedApi.stores.getStores.useQuery(), + fileAliasPrecreatedApi.stores.getStores.schema, + fileRelativeExtPrecreatedApi.pets.createPet.useMutation(), + fileRelativeExtPrecreatedApi.pets.createPet.schema, +]; +``` + +This keeps the existing callback coverage intact and reuses the already-present operations as the schema proof targets for both context-based and precreated clients. + +- [x] **Step 2: Update the shared mixed scenario tokens so the bundle assertion checks every schema rewrite** + +Add the schema proof tokens to the mixed scenario include list in `scripts/shared.mjs` next to the existing callback proof tokens. Keep the scenario definition in one place and avoid a post-processing patch. + +- [x] **Step 3: Add or update the source-map assertions for the schema lines** + +Extend `sourceMapAssertions` in `scripts/assert-dist.mjs` so the generated schema lines map back to the mixed fixture source. Use representative schema tokens for each operation family so the mixed fixture proves all three shapes: + +```ts +const sourceMapAssertions = { + 'barrel-context-relative': { + source: 'src/barrel-context-relative.ts', + token: 'qraftReactAPIClient(', + }, + 'barrel-precreated-relative': { + source: 'src/barrel-precreated-relative.ts', + token: 'qraftAPIClient(', + }, + 'mixed-context-precreated-mirrors': { + source: 'src/mixed-context-precreated-mirrors.ts', + tokens: ['getPets.schema', 'createPet.schema', 'getStores.schema'], + }, +}; +``` + +This makes the e2e check verify both the emitted bundle shape and the rewritten source positions for the schema accesses, while still staying on the existing mixed fixture. + +- [x] **Step 4: Run the package tests and the e2e fixture** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin test +yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +Expected: all three commands pass, and the e2e script still ends with `Tree-shaking bundle assertions passed.` + +- [x] **Step 5: Commit the schema support change** + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/src/lib/transform/types.ts packages/tree-shaking-plugin/src/lib/transform/plan.ts packages/tree-shaking-plugin/src/lib/transform/mutate.ts e2e/projects/tree-shaking-bundlers/src/mixed-context-precreated-mirrors.ts e2e/projects/tree-shaking-bundlers/scripts/shared.mjs e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs +git commit -m "feat: tree-shake schema access" +``` + +--- + +**Status:** ready for implementation. diff --git a/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-client-helper-selection-e2e.md b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-client-helper-selection-e2e.md new file mode 100644 index 000000000..1ade52365 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-client-helper-selection-e2e.md @@ -0,0 +1,152 @@ +# Qraft Tree-Shaking Client Helper Selection E2E Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prove the helper-selection split from `2026-05-10-qraft-tree-shaking-client-helper-selection-unit-tests.md` in the bundler fixture by adding a Node no-context helper case and a mixed-helper bundle case. + +**Architecture:** This plan is the follow-up to `docs/superpowers/plans/2026-05-10-qraft-tree-shaking-client-helper-selection-unit-tests.md`. It assumes the unit-test plan has already landed, so the bundler fixture can rely on the new `qraftAPIClient` / `qraftReactAPIClient` split and only needs to validate emitted bundle shape, scenario wiring, and source-map pins. The e2e coverage stays in this separate plan so the unit-only implementation can be reviewed and shipped independently first. + +**Tech Stack:** TypeScript, bundler e2e fixture, shell scripts, source maps, Yarn 4. + +--- + +### File Structure + +- `e2e/projects/tree-shaking-bundlers/package.json`: codegen entry for the no-context Node helper. +- `e2e/projects/tree-shaking-bundlers/src/node-api-helper-selection.ts`: no-context Node fixture using `createNodeAPIClient`. +- `e2e/projects/tree-shaking-bundlers/src/barrel-mixed-helper-selection.ts`: mixed helper fixture that keeps both `qraftAPIClient` and `qraftReactAPIClient` visible in one bundle. +- `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs`: scenario registration for the new Node and mixed bundle cases, plus `createNodeAPIClient` wiring in the transform config. +- `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs`: bundle-token expectations for `qraftAPIClient`-only and mixed helper output. + +### Task 1: Add the new fixture entries before changing the assertions + +**Files:** + +- Modify: `e2e/projects/tree-shaking-bundlers/package.json` +- Add: `e2e/projects/tree-shaking-bundlers/src/node-api-helper-selection.ts` +- Add: `e2e/projects/tree-shaking-bundlers/src/barrel-mixed-helper-selection.ts` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` + +- [ ] **Step 1: Add `createNodeAPIClient` to the fixture codegen command** + +Update the codegen command so it generates a real no-context helper alongside the existing context-bearing helpers: + +```json +"codegen": "openapi-qraft --plugin tanstack-query-react --plugin openapi-typescript ./openapi.yaml --clean -o src/generated-api --openapi-types-import-path '../schema.ts' --openapi-types-file-name schema.ts --explicit-import-extensions --create-api-client-fn createBarrelAPIClient filename:create-barrel-api-client context:BarrelAPIClientContext --create-api-client-fn createNodeAPIClient filename:create-node-api-client --create-api-client-fn createRelativeAPIClient filename:create-relative-api-client context:RelativeAPIClientContext --create-api-client-fn createRelativeExtAPIClient filename:create-relative-ts-api-client context:RelativeExtAPIClientContext --create-api-client-fn createAliasAPIClient filename:create-alias-api-client context:AliasAPIClientContext --create-api-client-fn createAliasDirectAPIClient filename:create-alias-direct-api-client context:AliasDirectAPIClientContext --create-api-client-fn createBarrelPrecreatedAPIClient filename:create-barrel-precreated-api-client --create-api-client-fn createRelativePrecreatedAPIClient filename:create-relative-precreated-api-client --create-api-client-fn createRelativeExtPrecreatedAPIClient filename:create-relative-ts-precreated-api-client --create-api-client-fn createAliasDirectPrecreatedAPIClient filename:create-alias-direct-precreated-api-client" +``` + +Add a Node fixture that exercises both the zero-arg and explicit-options forms of the no-context helper: + +```ts +// src/node-api-helper-selection.ts +import type { CreateAPIClientOptions } from '@openapi-qraft/react'; +import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; +import { createNodeAPIClient } from './generated-api'; + +const nodeOptions = { + queryClient: new QueryClient(), + baseUrl: 'http://localhost:3000', + requestFn, +} satisfies CreateAPIClientOptions; + +const nodeApiUtility = createNodeAPIClient(); +const nodeApi = createNodeAPIClient(nodeOptions); + +export const result = [ + nodeApiUtility.pets.findPetsByStatus.getQueryKey(), + nodeApi.pets.findPetsByStatus.invalidateQueries(), + nodeApi.pets.findPetsByStatus.setQueryData({ path: { petId: 1 } }, { id: 1 }), +]; +``` + +Add `createNodeAPIClient` to the `createAPIClientFn` export in `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` without a `context` field, so the transform treats it as a no-context factory and can emit `qraftAPIClient` for it. + +Add a second fixture that keeps the mixed helper split easy to read: + +```ts +// src/barrel-mixed-helper-selection.ts +import { createBarrelAPIClient } from './generated-api'; + +const api = createBarrelAPIClient(); + +export const result = [ + api.pets.findPetsByStatus.invalidateQueries(), + api.pets.findPetsByStatus.setQueryData({ path: { petId: 1 } }, { id: 1 }), + api.pets.getPets.useQuery(), +]; +``` + +Add both files to the `scenarios` array in `scripts/shared.mjs`. + +- [ ] **Step 2: Extend the scenario mode expectations for API-only output** + +Teach `assert-dist.mjs` about the new no-context mode so the Node-only bundle explicitly excludes `qraftReactAPIClient` and the mixed bundle proves both helpers can coexist: + +```js +const modeExpectations = { + context: () => ({ + include: [/qraftReactAPIClient(?:__|\()/], + exclude: [/qraftAPIClient(?:__|\()/], + }), + precreated: () => ({ + include: [/qraftAPIClient(?:__|\()/], + exclude: [/qraftReactAPIClient(?:__|\()/], + }), + mixed: () => ({ + include: [/qraftReactAPIClient(?:__|\()/, /qraftAPIClient(?:__|\()/], + exclude: [], + }), + apiOnly: () => ({ + include: [/qraftAPIClient(?:__|\()/], + exclude: [/qraftReactAPIClient(?:__|\()/, /APIClientContext/], + }), +}; +``` + +Add a source-map assertion for the Node-only scenario so the emitted `qraftAPIClient(` token maps back to `src/node-api-helper-selection.ts`. + +Add two source-map assertions for the mixed scenario so both `qraftAPIClient(` and `qraftReactAPIClient(` map back to `src/barrel-mixed-helper-selection.ts`. + +- [ ] **Step 3: Run the bundler matrix and confirm the new exact bundle shape** + +Run: + +```bash +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +This local runner copies `e2e/projects/tree-shaking-bundlers` into `/Users/radist/w/qraft-e2e`, regenerates the fixture with `npm run codegen`, builds the bundlers through `scripts/build.mjs`, and then runs `scripts/assert-dist.mjs` against the generated outputs. + +If you need faster local iteration inside the fixture, run the project directly: + +```bash +cd e2e/projects/tree-shaking-bundlers +npm run e2e:pre-build +npm exec tsc -- --noEmit +npm run build +npm run e2e:post-build +``` + +Expected: + +- `node-api-helper-selection` includes `qraftAPIClient(` and excludes `qraftReactAPIClient(` and `APIClientContext` +- `barrel-mixed-helper-selection` includes both helpers in the same bundle +- the existing context and precreated scenarios still pass unchanged + +- [ ] **Step 4: Commit the e2e coverage** + +```bash +git add e2e/projects/tree-shaking-bundlers/package.json e2e/projects/tree-shaking-bundlers/src/node-api-helper-selection.ts e2e/projects/tree-shaking-bundlers/src/barrel-mixed-helper-selection.ts e2e/projects/tree-shaking-bundlers/scripts/shared.mjs e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs +git commit -m "test: cover qraft API client helper selection in e2e" +``` + +### Final Verification + +After the unit plan is complete and the e2e plan has landed, run the local bundle check once more: + +```bash +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +If the bundle matrix stays green, the e2e plan is done and can be handed off for review. diff --git a/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-client-helper-selection-unit-tests.md b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-client-helper-selection-unit-tests.md new file mode 100644 index 000000000..f4b117cea --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-client-helper-selection-unit-tests.md @@ -0,0 +1,412 @@ +# Qraft Tree-Shaking Client Helper Selection Unit Tests Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `createAPIClientFn` tree-shaken clients emit `qraftAPIClient` whenever the used callbacks do not require React runtime, keep `qraftReactAPIClient` only for hook callbacks, and verify that behavior entirely with unit tests. + +**Architecture:** Move callback capability knowledge into `packages/tree-shaking-plugin/src/lib/transform/callbacks.ts` as a single metadata table with two booleans: `needsOptions` and `needsReactRuntime`. `needsOptions` decides whether a generated client needs the options object at all, while `needsReactRuntime` decides whether the runtime helper must be `qraftReactAPIClient`. `mutate.ts` will consume that metadata to choose the runtime helper per generated client binding and per inline rewrite, then reuse the same choice when deciding whether to import `APIClientContext` or the lean `qraftAPIClient` runtime. The existing `apiClient` precreated path stays unchanged. E2E coverage is intentionally excluded from this plan and will be handled separately. + +**Tech Stack:** TypeScript, Babel traverse/types, Vitest inline snapshots, Yarn 4. + +--- + +### File Structure + +- `packages/tree-shaking-plugin/src/lib/transform/callbacks.ts`: callback capability table and helper predicates. +- `packages/tree-shaking-plugin/src/lib/transform/callbacks.test.ts`: direct contract tests for callback metadata. +- `packages/tree-shaking-plugin/src/lib/transform/mutate.ts`: runtime helper selection, import emission, and inline/client rewrites. +- `packages/tree-shaking-plugin/src/core.test.ts`: snapshot regressions for baseline utility callbacks, options-bearing API callbacks, and React-hook callbacks. + +### Task 1: Make callback capabilities explicit in `callbacks.ts` + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/callbacks.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/callbacks.test.ts` + +- [ ] **Step 1: Write the failing metadata contract test** + +Add a focused test that makes the new split visible: + +```ts +import { describe, expect, it } from 'vitest'; +import { + callbackNeedsOptions, + callbackNeedsReactRuntime, + supportedCallbacks, +} from './callbacks.js'; + +describe('callback capability metadata', () => { + it('marks hook callbacks as React-runtime-bearing and utility callbacks as React-free', () => { + expect(supportedCallbacks.useQuery).toEqual({ + needsOptions: true, + needsReactRuntime: true, + }); + expect(supportedCallbacks.getQueryKey).toEqual({ + needsOptions: false, + needsReactRuntime: false, + }); + expect(supportedCallbacks.invalidateQueries).toEqual({ + needsOptions: true, + needsReactRuntime: false, + }); + expect(supportedCallbacks.setQueryData).toEqual({ + needsOptions: true, + needsReactRuntime: false, + }); + expect(supportedCallbacks.operationInvokeFn).toEqual({ + needsOptions: true, + needsReactRuntime: false, + }); + }); + + it('exposes helpers for both capability checks', () => { + expect(callbackNeedsReactRuntime('useQuery')).toBe(true); + expect(callbackNeedsReactRuntime('getQueryKey')).toBe(false); + expect(callbackNeedsOptions('getQueryKey')).toBe(false); + expect(callbackNeedsOptions('invalidateQueries')).toBe(true); + expect(callbackNeedsOptions('operationInvokeFn')).toBe(true); + }); +}); +``` + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/lib/transform/callbacks.test.ts +``` + +Expected: fail because the table still only tracks `needsRuntimeContext`. + +- [ ] **Step 2: Replace the old one-flag table with a two-flag capability table** + +Update the metadata shape and keep the existing callback entries, but give every row both flags: + +```ts +type CallbackMetadata = { + needsOptions: boolean; + needsReactRuntime: boolean; +}; + +export const supportedCallbacks = { + cancelQueries: { needsOptions: true, needsReactRuntime: false }, + ensureInfiniteQueryData: { needsOptions: true, needsReactRuntime: false }, + ensureQueryData: { needsOptions: true, needsReactRuntime: false }, + fetchInfiniteQuery: { needsOptions: true, needsReactRuntime: false }, + fetchQuery: { needsOptions: true, needsReactRuntime: false }, + getInfiniteQueryData: { needsOptions: true, needsReactRuntime: false }, + getInfiniteQueryKey: { needsOptions: false, needsReactRuntime: false }, + getInfiniteQueryState: { needsOptions: true, needsReactRuntime: false }, + getMutationCache: { needsOptions: true, needsReactRuntime: false }, + getMutationKey: { needsOptions: false, needsReactRuntime: false }, + getQueriesData: { needsOptions: true, needsReactRuntime: false }, + getQueryData: { needsOptions: true, needsReactRuntime: false }, + getQueryKey: { needsOptions: false, needsReactRuntime: false }, + getQueryState: { needsOptions: true, needsReactRuntime: false }, + invalidateQueries: { needsOptions: true, needsReactRuntime: false }, + isFetching: { needsOptions: true, needsReactRuntime: false }, + isMutating: { needsOptions: true, needsReactRuntime: false }, + operationInvokeFn: { needsOptions: true, needsReactRuntime: false }, + prefetchInfiniteQuery: { needsOptions: true, needsReactRuntime: false }, + prefetchQuery: { needsOptions: true, needsReactRuntime: false }, + refetchQueries: { needsOptions: true, needsReactRuntime: false }, + removeQueries: { needsOptions: true, needsReactRuntime: false }, + resetQueries: { needsOptions: true, needsReactRuntime: false }, + setInfiniteQueryData: { needsOptions: true, needsReactRuntime: false }, + setQueriesData: { needsOptions: true, needsReactRuntime: false }, + setQueryData: { needsOptions: true, needsReactRuntime: false }, + useInfiniteQuery: { needsOptions: true, needsReactRuntime: true }, + useIsFetching: { needsOptions: true, needsReactRuntime: true }, + useIsMutating: { needsOptions: true, needsReactRuntime: true }, + useMutation: { needsOptions: true, needsReactRuntime: true }, + useMutationState: { needsOptions: true, needsReactRuntime: true }, + useQueries: { needsOptions: true, needsReactRuntime: true }, + useQuery: { needsOptions: true, needsReactRuntime: true }, + useSuspenseInfiniteQuery: { needsOptions: true, needsReactRuntime: true }, + useSuspenseQueries: { needsOptions: true, needsReactRuntime: true }, + useSuspenseQuery: { needsOptions: true, needsReactRuntime: true }, +} as const satisfies Readonly>; +``` + +Keep the existing name guard and add two helpers: + +```ts +export function callbackNeedsOptions(callbackName: string): boolean { + if (!isSupportedCallbackName(callbackName)) return true; + return supportedCallbacks[callbackName].needsOptions; +} + +export function callbackNeedsReactRuntime(callbackName: string): boolean { + if (!isSupportedCallbackName(callbackName)) return true; + return supportedCallbacks[callbackName].needsReactRuntime; +} +``` + +If you want a pure selector for `mutate.ts`, add one more helper: + +```ts +export function clientNeedsReactRuntime( + callbackNames: readonly string[] +): boolean { + return callbackNames.some((callbackName) => + callbackNeedsReactRuntime(callbackName) + ); +} +``` + +- [ ] **Step 3: Rerun the focused metadata test** + +Run the same command again. + +Expected: pass. + +- [ ] **Step 4: Commit the metadata split** + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/callbacks.ts packages/tree-shaking-plugin/src/lib/transform/callbacks.test.ts +git commit -m "feat: split tree-shaking callback capabilities" +``` + +### Task 2: Select the runtime helper per generated client in `mutate.ts` + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Capture the current snapshot failures before changing the transform** + +Run the focused core tests that should flip from React helper to API helper: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts -t "groups callbacks per operation and imports operationInvokeFn directly|rewrites context-free callbacks from zero-arg createAPIClient calls|keeps APIClientContext when context-free and contextful callbacks share one client|optimizes inline explicit options clients|optimizes explicit options clients created inside callbacks|optimizes mutation callbacks across onMutate, onError, and onSuccess|aliases generated names for explicit options clients inside nested function scopes" +``` + +Expected: snapshot failures still showing `qraftReactAPIClient` in API-only branches. + +- [ ] **Step 2: Add a local runtime-helper selector and carry it through declaration emission** + +Introduce a tiny local type in `mutate.ts` so the import decision and the emitted call stay in sync: + +```ts +type RuntimeHelperKind = 'api' | 'react'; + +function selectRuntimeHelper( + callbackNames: readonly { callbackName: string }[] +): RuntimeHelperKind { + return callbackNames.some((callback) => + callbackNeedsReactRuntime(callback.callbackName) + ) + ? 'react' + : 'api'; +} +``` + +Use that selector in `createOptimizedClientDeclaration(...)` and in the code that inserts runtime imports so a single `createAPIClientFn` file can import both helpers when it needs both. Keep the `apiClient` precreated path unchanged. + +The emitted shapes should become: + +```ts +qraftAPIClient(findPetsByStatus, { + getQueryKey, +}); + +qraftAPIClient( + findPetsByStatus, + { + invalidateQueries, + setQueryData, + }, + apiContext! +); + +qraftAPIClient( + getPets, + { + operationInvokeFn, + }, + createAPIClientOptions() +); + +qraftReactAPIClient( + getPets, + { + useQuery, + }, + APIClientContext +); +``` + +The important part is that the runtime helper now follows the callback set, not the presence of a `createAPIClientFn` binding itself. + +- [ ] **Step 3: Update the inline rewrite branch to use the same selector** + +`rewriteInlineClientCalls(...)` should call the same helper decision for each inline usage, so a zero-arg utility-only call rewrites to `qraftAPIClient(...)` and does not drag `APIClientContext` into the file. + +- [ ] **Step 4: Refresh the core snapshots to the new exact emitted structure** + +Update the affected snapshots in `packages/tree-shaking-plugin/src/core.test.ts`: + +- `groups callbacks per operation and imports operationInvokeFn directly` +- `rewrites context-free callbacks from zero-arg createAPIClient calls` +- `keeps APIClientContext when context-free and contextful callbacks share one client` +- `optimizes inline explicit options clients` +- `optimizes explicit options clients created inside callbacks` +- `optimizes mutation callbacks across onMutate, onError, and onSuccess` +- `aliases generated names for explicit options clients inside nested function scopes` + +Representative new snapshots should look like this: + +Before transform (**not literally**): + +```ts +import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; +import { createAPIClient } from './api'; + +const apiContext = { + queryClient: new QueryClient(), + baseUrl: 'http://localhost:3000', + requestFn, +}; + +const api = createAPIClient(apiContext); +const apiReact = createAPIClient(); + +export function App() { + api.pets.findPetsByStatus.invalidateQueries(); + apiReact.pets.getPets.useQuery(); +} +``` + +After transform (**not literally**): + +```ts +"import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; +import { qraftAPIClient, qraftReactAPIClient } from \"@openapi-qraft/react\"; +import { invalidateQueries } from \"@openapi-qraft/react/callbacks/invalidateQueries\"; +import { setQueryData } from \"@openapi-qraft/react/callbacks/setQueryData\"; +import { findPetsByStatus } from \"./api/services/PetsService\"; +import { useQuery } from \"@openapi-qraft/react/callbacks/useQuery\"; +import { getPets } from \"./api/services/PetsService\"; +import { APIClientContext } from \"./api/APIClientContext\"; +import { useContext } from \"react\"; +const api_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + invalidateQueries, + setQueryData +}, { + // new, top level precreated options, normally passed to createAPIClient({...}) as arg + queryClient: new QueryClient(), + baseUrl: 'http://localhost:3000', + requestFn, +}); +const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery +}, APIClientContext); +export function App() { + api_pets_findPetsByStatus.invalidateQueries(); + api_pets_getPets.useQuery(); +}" +``` + +and: + +```ts +import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; +import { qraftAPIClient } from \"@openapi-qraft/react\"; +import { operationInvokeFn } from \"@openapi-qraft/react/callbacks/operationInvokeFn\"; +import { APIClientContext } from \"./api/APIClientContext\"; +import { useContext } from \"react\"; +import { findPetsByStatus } from \"./api/services/PetsService\"; +const apiContext = { // new, top level precreated options + queryClient: new QueryClient(), + baseUrl: 'http://localhost:3000', + requestFn, +}; +const api_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + operationInvokeFn +}, apiContext); +function App() { + void qraftAPIClient(findPetsByStatus, { + operationInvokeFn + }, apiContext)(); + const utilityClient_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + operationInvokeFn + }, apiContext); + void utilityClient_pets_findPetsByStatus(); + api_pets_findPetsByStatus(); +} +``` + +and: + +```ts +"import { qraftAPIClient, qraftReactAPIClient } from \"@openapi-qraft/react\"; +import { useContext } from \"react\"; +import { getQueryKey } from \"@openapi-qraft/react/callbacks/getQueryKey\"; +import { invalidateQueries } from \"@openapi-qraft/react/callbacks/invalidateQueries\"; +import { findPetsByStatus } from \"./api/services/PetsService\"; +import { useQuery } from \"@openapi-qraft/react/callbacks/useQuery\"; +import { getPets } from \"./api/services/PetsService\"; +import { APIClientContext } from \"./api/APIClientContext\"; +const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery +}, APIClientContext); +export function App() { + const apiContext = useContext(APIClientContext); + const api_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + getQueryKey, + invalidateQueries + }, apiContext!); + api_pets_findPetsByStatus.getQueryKey(); + api_pets_findPetsByStatus.invalidateQueries(); + api_pets_getPets.useQuery(); + api_pets_getPets.getQueryKey(); +}" +``` + +For the nested-options case, the nested-options snapshot should keep the outer `updatePet` client on `qraftReactAPIClient`, but the inner `getPetById` declaration inside `onMutate` and the other utility-only callbacks in `onError` / `onSuccess` should flip to `qraftAPIClient`. + +- [ ] **Step 5: Re-run the package test suite** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both pass after the snapshot refresh. + +- [ ] **Step 6: Commit the transform change** + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/mutate.ts packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/src/lib/transform/callbacks.ts packages/tree-shaking-plugin/src/lib/transform/callbacks.test.ts +git commit -m "feat: select qraft API client for non-react callbacks" +``` + +### Task 3: Final verification for the unit-test plan + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/callbacks.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/callbacks.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Run the full package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both commands pass with the unit-only split in place. + +- [ ] **Step 2: Hand off the unit-test plan** + +If the package tests stay green, the unit-test plan is done and the e2e follow-up can be executed separately. diff --git a/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-createapi-client-scope-split.md b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-createapi-client-scope-split.md new file mode 100644 index 000000000..da1b6816c --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-createapi-client-scope-split.md @@ -0,0 +1,254 @@ +# Qraft Tree-Shaking CreateAPIClientFn Scope Split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split `createAPIClientFn` output into scope-local tree-shake-optimal clients so each lexical scope gets only the helper set it actually needs, with utility-only scopes using `qraftAPIClient`, hook-bearing scopes using `qraftReactAPIClient`, and nested callback scopes remaining independently optimizable. + +**Architecture:** Callback capability metadata already lives in `packages/tree-shaking-plugin/src/lib/transform/callbacks.ts`, so this plan does not redesign that table. The refactor stays inside the transform pipeline: `plan.ts` continues to assign a stable `scopeKey` to each usage, while `mutate.ts` becomes responsible for materializing one optimized client binding per lexical scope instead of deduping sibling scopes by callback shape. `apiClient` mode is out of scope because it already has its own callback-call transformation path. No import-merging pass is added. + +**Tech Stack:** TypeScript, Babel traverse/types, Vitest inline snapshots, Yarn 4. + +--- + +### File Structure + +- `packages/tree-shaking-plugin/src/lib/transform/plan.ts`: keep scope-aware usage collection stable and expose enough data to split declarations by lexical scope. +- `packages/tree-shaking-plugin/src/lib/transform/mutate.ts`: emit one optimized client per scope bucket, choose `qraftAPIClient` vs `qraftReactAPIClient` per bucket, and keep nested callback scopes independent. +- `packages/tree-shaking-plugin/src/core.test.ts`: regression snapshots for sibling-scope splitting, mixed hook/utility scopes, and nested explicit-options clients. + +### Task 1: Lock the regression in `core.test.ts` + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Add the failing snapshot for sibling scopes** + +Add a focused regression that uses the `PetUpdateItem` / `PetUpdateForm` example and asserts that the same source operation is emitted as separate bindings in separate lexical scopes: + +```ts +it('splits explicit options clients across sibling callback scopes', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, APIClientContext } from './api'; +import { useContext } from 'react'; + +const api = createAPIClient(); + +function PetUpdateItem({ petId }: { petId: number }) { + return api.pets.updatePet.useIsMutating(api.pets.updatePet.getMutationKey()); +} + +function PetUpdateForm({ petId }: { petId: number }) { + const apiContext = useContext(APIClientContext); + const petParams = { path: { petId } }; + + api.pets.updatePet.useMutation(undefined, { + mutationKey: api.pets.updatePet.getMutationKey(), + async onMutate(variables) { + const getQueryData = () => api.pets.updatePet.getMutationKey(); + const apiClient_pets_getPetById = () => null; + const apiClient = createAPIClient(apiContext!); + + await apiClient.pets.getPetById.cancelQueries({ parameters: petParams }); + const prevPet = apiClient.pets.getPetById.getQueryData(petParams); + + apiClient.pets.getPetById.setQueryData(petParams, (old) => ({ + ...old, + ...variables.body, + })); + + return { prevPet, getQueryData, apiClient_pets_getPetById }; + }, + }); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { APIClientContext } from './api'; + import { useContext } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useIsMutating } from "@openapi-qraft/react/callbacks/useIsMutating"; + import { updatePet } from "./api/services/PetsService"; + import { getMutationKey } from "@openapi-qraft/react/callbacks/getMutationKey"; + import { useMutation } from "@openapi-qraft/react/callbacks/useMutation"; + import { cancelQueries } from "@openapi-qraft/react/callbacks/cancelQueries"; + import { getPetById } from "./api/services/PetsService"; + import { getQueryData as _getQueryData } from "@openapi-qraft/react/callbacks/getQueryData"; + import { setQueryData } from "@openapi-qraft/react/callbacks/setQueryData"; + const api_pets_updatePet1 = qraftReactAPIClient(updatePet, { + useIsMutating, + getMutationKey, + }, APIClientContext); + const api_pets_updatePet2 = qraftReactAPIClient(updatePet, { + getMutationKey, + useMutation + }, APIClientContext); + function PetUpdateItem({ + petId + }: { + petId: number; + }) { + return api_pets_updatePet1.useIsMutating(api_pets_updatePet1.getMutationKey()); + } + function PetUpdateForm({ + petId + }: { + petId: number; + }) { + const apiContext = useContext(APIClientContext); + const petParams = { + path: { + petId + } + }; + api_pets_updatePet2.useMutation(undefined, { + mutationKey: api_pets_updatePet2.getMutationKey(), + async onMutate(variables) { + const getQueryData = () => api_pets_updatePet2.getMutationKey(); + const apiClient_pets_getPetById = () => null; + const _apiClient_pets_getPetById = qraftAPIClient(getPetById, { + cancelQueries, + getQueryData: _getQueryData, + setQueryData + }, apiContext!); + await _apiClient_pets_getPetById.cancelQueries({ + parameters: petParams + }); + const prevPet = _apiClient_pets_getPetById.getQueryData(petParams); + _apiClient_pets_getPetById.setQueryData(petParams, old => ({ + ...old, + ...variables.body + })); + return { + prevPet, + getQueryData, + apiClient_pets_getPetById + }; + } + }); + }" + `); +}); +``` + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts -t "splits explicit options clients across sibling callback scopes" +``` + +Expected: fail until the transform emits separate scope-local clients. + +- [x] **Step 2: Commit the regression first** + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test: lock scope split regression for createAPIClientFn" +``` + +### Task 2: Emit one optimized client per lexical scope in `mutate.ts` + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` + +- [x] **Step 1: Add a scope-bucket helper and stop treating callback shape as a dedupe key** + +Make the transform group usages by lexical scope first, then materialize a client binding for each scope bucket. The emitted binding name should still stay scope-stable, but the grouping must not collapse sibling scopes just because they reuse the same operation. + +```ts +type ScopeUsageBucket = { + scopeKey: string; + usages: OperationUsage[]; +}; + +function groupUsagesByScope(usages: OperationUsage[]): ScopeUsageBucket[] { + const buckets = new Map(); + + for (const usage of usages) { + const next = buckets.get(usage.scopeKey) ?? []; + next.push(usage); + buckets.set(usage.scopeKey, next); + } + + return [...buckets.entries()].map(([scopeKey, scopeUsages]) => ({ + scopeKey, + usages: scopeUsages, + })); +} +``` + +Use that helper in the declaration emitter so each scope bucket gets its own `createOptimizedClientDeclaration(...)` calls, and remove any remaining code path that tries to reuse one declaration because two scopes happen to need the same callback set. + +- [x] **Step 2: Keep helper selection local to the emitted bucket** + +Inside `createOptimizedClientDeclaration(...)`, derive the runtime helper from the callback names present in that bucket only: + +```ts +const runtimeHelperKind = callbacks.some((callback) => + callbackNeedsReactRuntime(callback.callbackName) +) + ? 'react' + : 'api'; + +const runtimeImportLocalName = + usage.client.mode.type === 'precreated' || runtimeHelperKind === 'api' + ? runtimeLocalNames.api + : runtimeLocalNames.react; +``` + +That ensures a utility-only bucket emits `qraftAPIClient(...)` even if another scope in the same file still needs `qraftReactAPIClient(...)`. + +- [x] **Step 3: Preserve nested callback scopes as independent buckets** + +When the outer callback body creates its own `createAPIClient(...)` binding, nested callback bodies like `onMutate`, `onError`, and `onSuccess` must be evaluated separately, so a nested utility-only client can flip to `qraftAPIClient` without affecting the outer hook-bearing binding. + +Use the same `scopeKey` that `plan.ts` already derives from the nearest function parent so nested callback bodies and sibling top-level components do not share a declaration bucket. + +- [x] **Step 4: Verify the transform branch with the focused core snapshot** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts -t "splits explicit options clients across sibling callback scopes" +``` + +Expected: the snapshot now shows separate `api_pets_updatePet` and `_api_pets_updatePet` bindings, and the nested `getPetById` client uses `qraftAPIClient`. + +- [x] **Step 5: Commit the transform refactor** + +Combined with snapshot refresh in commit `a23a26b5`. + +### Task 3: Refresh the remaining unit snapshots and run package checks + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Refresh the other snapshots that depend on the new split** + +Updated snapshots: `optimizes explicit options clients created inside callbacks` and `splits explicit options clients across sibling callback scopes` — replaced `api_pets_updatePet1`/`api_pets_updatePet2` with `api_pets_updatePet`/`_api_pets_updatePet`. + +- [x] **Step 2: Run the focused package checks** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Both pass: 52 tests pass, typecheck clean. + +- [x] **Step 3: Commit the snapshot refresh** + +Committed as `a23a26b5`: `feat: use Babel UID for sibling scope client naming, drop manual index` diff --git a/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-imports-before-client-declaration.md b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-imports-before-client-declaration.md new file mode 100644 index 000000000..de7ae1448 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-imports-before-client-declaration.md @@ -0,0 +1,171 @@ +# Qraft Tree-Shaking Imports Before Client Declaration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every transform emit all required imports before the first generated client declaration, while keeping the existing source-map behavior and the current callback validity rules unchanged. + +**Architecture:** The current mutator mixes two different concerns: program-level import insertion and scope-level client declaration insertion. The refactor should split those concerns so imports are staged first, then client declarations are inserted into their original anchor scopes. That keeps the emitted source in a more conventional order without changing the rewritten user call sites, so the existing source-map regression should still validate the traceability contract. + +**Tech Stack:** TypeScript, Babel AST traversal/types, Vitest inline snapshots, Yarn 4. + +--- + +## File Structure + +- `packages/tree-shaking-plugin/src/lib/transform/mutate.ts`: move import emission ahead of generated client declarations and keep the ordering deterministic across context, explicit-options, and precreated modes. +- `packages/tree-shaking-plugin/src/core.test.ts`: pin the exact emitted order in the existing regression snapshot that currently shows imports after the generated declaration. +- `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` and `e2e/projects/tree-shaking-bundlers/src/*`: only touch these if a stable bundle-level assertion exists for the same ordering; otherwise keep e2e untouched because bundle ordering can be normalized by the bundler. + +--- + +### Task 1: Pin the current regression in the unit snapshot + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Update the explicit-options regression so the snapshot expects imports before the generated declaration** + +Keep the current test input, including the valid direct operation invoke on `createAPIClient({})`, but change the expected output to the standard order: + +```ts +expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPets } from "./api/services/PetsService"; + import { operationInvokeFn } from "@openapi-qraft/react/callbacks/operationInvokeFn"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + getQueryKey, + operationInvokeFn + }, {}); + api_pets_getPets.getQueryKey({}); + api_pets_getPets();" +`); +``` + +This snapshot is the minimal regression that proves the transform is no longer emitting `const api_pets_getPets = ...` before the helper imports. + +- [x] **Step 2: Keep the source-map regression unchanged** + +Leave `keeps a rewritten user call site traceable through an incoming source map` as-is. It already exercises the rewritten call site mapping, which is the part that could regress if the mutator order changes in a way that shifts original positions. + +- [x] **Step 3: Run the focused unit test file once to capture the new expectation** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +``` + +Expected: the explicit-options snapshot still fails until the mutator refactor is done. + +--- + +### Task 2: Split import staging from generated declaration insertion in `mutate.ts` + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` + +- [x] **Step 1: Stop emitting program imports from inside declaration creation** + +Refactor the client-declaration pipeline so the import nodes and the generated client statement(s) are staged independently. The important part is not the order of helper calls, but the final AST result: no generated client declaration may be committed to the program body before all of its helper imports are already present in the file. + +The implementation shape should look like this: + +```ts +const pendingImports: t.ImportDeclaration[] = []; +const pendingStatementInsertions: Array<{ + anchor: import('@babel/traverse').NodePath; + statements: t.Statement[]; +}> = []; + +insertImports( + ast, + usages, + inlineImports, + schemaUsages, + generatedInfoByImport, + runtimeLocalNames, + pendingImports +); +insertOptimizedClients( + ast, + usages, + generatedInfoByImport, + runtimeLocalNames, + pendingStatementInsertions +); + +const lastImportIndex = findLastImportIndex(body); +body.splice(lastImportIndex + 1, 0, ...dedupeDeclarations(pendingImports)); + +for (const { anchor, statements } of pendingStatementInsertions) { + anchor.insertAfter(dedupeDeclarations(statements)); +} +``` + +Keep the existing dedupe behavior, but apply it to the staged import list before the program-level splice. + +- [x] **Step 2: Preserve the current insertion anchors for client declarations** + +Do not change where declarations are attached: + +```ts +if (statementPath?.isVariableDeclaration()) { + statementPath.insertAfter(dedupeDeclarations(declarations)); +} +``` + +The only behavioral change should be that the helper imports are already present in the program before the first generated declaration appears. + +- [x] **Step 3: Keep callback validity and runtime-helper selection untouched** + +Do not broaden or narrow callback support in this task. `callbackNeedsRuntimeContext(...)`, the `qraftReactAPIClient`/`qraftAPIClient` selection logic, and the zero-arg vs explicit-options validity rules should stay exactly as they are. This change is about ordering, not semantics. + +- [x] **Step 4: Re-run the focused unit suite after the refactor** + +Run: + +```bash +yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +``` + +Expected: the regression snapshot now shows all imports before the first generated client declaration, and the source-map test still passes. + +--- + +### Task 3: Add e2e coverage only if bundle output keeps a stable textual order + +**Files:** +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/src/*` only if a stable scenario already exists for this shape +- Modify: `e2e/projects/tree-shaking-bundlers/package.json` only if the fixture needs a new scenario or codegen target to make the order check observable + +- [x] **Step 1: Evaluate whether the bundle artifact preserves the source-level order deterministically** + +If a scenario already produces a readable bundle where the relevant imports and generated declaration survive in a stable order, add a single assertion that checks the imports appear before the first generated client declaration for that scenario. Use the existing bundle assertion harness instead of adding a new test runner. + +If the shape only becomes visible after adding a dedicated fixture or codegen target, wire that into `e2e/projects/tree-shaking-bundlers/package.json` first, then reuse the same assert harness. Do not add a separate execution path: the existing matrix runner is the source of truth. + +If the bundler normalizes or reorders the emitted bundle in a way that makes this unstable, skip e2e for this change. The unit snapshot remains the source of truth for this ordering contract. + +- [x] **Step 2: Run the relevant e2e command only if the new assertion was added** + +Run: + +```bash +cd e2e && yarn e2e:tree-shaking-bundlers-local +``` + +This is the same local runner used in recent qraft plans. It copies `e2e/projects/tree-shaking-bundlers` into `/Users/radist/w/qraft-e2e`, regenerates the fixture through `npm run codegen`, builds all bundlers through `scripts/build.mjs`, and then runs `scripts/assert-dist.mjs` against the generated outputs. + +If the new assertion is hard to debug, a tighter inner-loop check from `e2e/projects/tree-shaking-bundlers` is: + +```bash +npm run codegen +node ./scripts/build.mjs +node ./scripts/assert-dist.mjs +``` + +That keeps the exact same fixture and assertion code, but lets you inspect the generated bundle files in place before the root runner copies them into `/Users/radist/w/qraft-e2e`. + +For this change, the e2e order assertion was not added because the bundle text is normalized differently by Vite, Rollup, Webpack, Rspack, and esbuild. The stable contract remains the unit snapshot plus the existing token and source-map checks. diff --git a/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-module-access.md b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-module-access.md new file mode 100644 index 000000000..d475dcf0a --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-module-access.md @@ -0,0 +1,1364 @@ +# Qraft Tree-Shaking Module Access Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace direct filesystem reads in the tree-shaking transform with a bundler-aware module access contract that resolves and loads generated modules through adapters. + +**Architecture:** Introduce a `QraftModuleAccess` boundary with `resolve()` and `load()` so `plan.ts` can inspect generated clients, re-export barrels, services indexes, and precreated-client export chains without importing `node:fs/promises`. Rollup/Vite, webpack, rspack, and esbuild adapters own their loading strategy; core transform only consumes module source and cleanly skips when source is unavailable. The public DX stays simple: normal users keep passing the same `createAPIClientFn` and `apiClient` options, while advanced users can override `moduleAccess` only when their bundler uses a custom source provider. + +**Tech Stack:** TypeScript, unplugin, Babel parser/traverse/types, Vitest, Yarn 4, multi-bundler e2e fixture. + +--- + +### File Structure + +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/common.ts` — replace the resolver-only contract with `QraftModuleAccess`, `QraftModuleAccessFactory`, and resolver/source-loader strategy helpers. +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/agnostic.ts` — expose `createAgnosticModuleAccess(...)` for unit tests and direct `transformQraftTreeShaking(...)` calls. +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/rollup-like.ts` — expose `createRollupLikeModuleAccess(...)` that uses Rollup/Vite resolution and module loading. +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/webpack-like.ts` — expose `createWebpackLikeModuleAccess(...)` that uses webpack `getResolve` and `loadModule`. +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/rspack.ts` — expose `createRspackModuleAccess(...)` with rspack resolution and source loading through the loader context when available. +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/esbuild.ts` — expose `createEsbuildModuleAccess(...)`; keep esbuild source loading adapter-local because esbuild exposes `build.resolve` but not an arbitrary `build.load` API. +- Modify: `packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.ts` — create a module-access instance instead of a resolver instance and pass it into core. +- Modify: `packages/tree-shaking-plugin/src/{vite,rollup,webpack,rspack,esbuild}.ts` — switch each entrypoint to the new factory names. +- Modify: `packages/tree-shaking-plugin/src/core.ts` — add `moduleAccess?: QraftModuleAccessOptions` option support, change the transform signature, and keep default agnostic behavior for unit tests. +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` — replace every `fs.readFile(...)` call with `moduleAccess.load(...)`; keep parsing local to `plan.ts`. +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts` — cover resolve and load behavior per adapter. +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` — update fixture helpers to provide in-memory module access and add a regression proving core fails/skips when a resolved module cannot be loaded instead of reading disk. +- Modify: `packages/tree-shaking-plugin/README.md` — document the module-access boundary and the optional advanced override. +- Modify only if needed: `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` and `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` — add a narrow barrel/provider regression only if existing scenarios do not already prove the contract across all bundlers. + +--- + +### Test Commands + +**Unit loop:** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/lib/resolvers/resolvers.test.ts +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts -t "does not read generated modules from the filesystem" +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +``` + +**Fast e2e loop inside the monorepo fixture:** + +Use this after source changes when `e2e/projects/tree-shaking-bundlers/node_modules` is already installed. + +```bash +# 1. Build the plugin package. +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build + +# 2. Sync the fresh plugin dist into the installed fixture package. +rm -rf e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cp -R packages/tree-shaking-plugin/dist \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist + +# 3. Build every bundler/scenario in place. +cd e2e/projects/tree-shaking-bundlers +npm run build + +# 4. Assert the emitted bundle shape and source maps. +npm run e2e:post-build +``` + +Expected: `npm run e2e:post-build` prints the fixture success message and exits `0`. + +**Full e2e loop through Verdaccio:** + +Use this before final completion because it validates publish/install behavior. The runner already builds publishable packages, removes `e2e/verdaccio-storage` once before publishing, publishes to Verdaccio, updates the copied fixture under `/Users/radist/w/qraft-e2e`, builds it, and unpublishes on cleanup. + +```bash +cd /Users/radist/WebstormProjects/qraft/e2e +corepack yarn e2e:tree-shaking-bundlers-local +``` + +Expected: the command exits `0` after building `tree-shaking-bundlers` from the copied project. If Verdaccio was already running with stale package state, stop it, then rerun the command; do not manually edit fixture `node_modules` during the full loop. + +--- + +### Task 1: Introduce `QraftModuleAccess` and Lock the Resolver Boundary + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/common.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/agnostic.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts` + +- [x] **Step 1: Add failing tests for module-access resolve/load composition** + +Append these tests to `packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts` inside the existing `describe('resolver composition', ...)` block: + +```ts +it('uses a custom module loader after custom resolution', async () => { + const resolve = vi.fn(async (specifier: string, importer: string) => { + expect(specifier).toBe('./api'); + expect(importer).toBe('/tmp/src/App.tsx'); + return '/tmp/src/api/index.ts'; + }); + const load = vi.fn(async (id: string) => { + expect(id).toBe('/tmp/src/api/index.ts'); + return 'export const marker = true;'; + }); + + const access = createAgnosticModuleAccess({ resolve, load }); + + await expect(access.resolve('./api', '/tmp/src/App.tsx')).resolves.toBe( + '/tmp/src/api/index.ts' + ); + await expect(access.load('/tmp/src/api/index.ts')).resolves.toBe( + 'export const marker = true;' + ); + expect(resolve).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(1); +}); + +it('returns null from load when no source loader is configured', async () => { + const access = createAgnosticModuleAccess({ + resolve: async () => '/tmp/src/api/index.ts', + }); + + await expect(access.load('/tmp/src/api/index.ts')).resolves.toBeNull(); +}); +``` + +Expected initial failure: TypeScript/Vitest cannot find `createAgnosticModuleAccess`. + +- [x] **Step 2: Run the resolver test to verify it fails** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/lib/resolvers/resolvers.test.ts -t "custom module loader" +``` + +Expected: FAIL with a missing export or missing identifier error for `createAgnosticModuleAccess`. + +- [x] **Step 3: Replace resolver-only types with module-access types** + +In `packages/tree-shaking-plugin/src/lib/resolvers/common.ts`, keep `QraftResolver` as a compatibility alias, then add the module-access types and source-loader strategy helpers: + +```ts +export type QraftResolver = ( + specifier: string, + importer: string +) => Promise | string | null; + +export type QraftSourceLoader = ( + resolvedId: string +) => Promise | string | null; + +export type QraftModuleAccess = { + resolve: QraftResolver; + load: QraftSourceLoader; +}; + +export type QraftModuleAccessOptions = { + resolve?: QraftResolver; + load?: QraftSourceLoader; +}; + +export type QraftModuleAccessFactory = ( + ctx: TRuntimeContext, + userAccess?: QraftModuleAccessOptions +) => QraftModuleAccess; +``` + +Leave `ResolveRequest`, `ResolveStrategy`, `createResolverChain(...)`, and `createUserResolverStrategy(...)` in place. Add loader strategy equivalents below them: + +```ts +export type LoadRequest = { + id: string; +}; + +export type LoadStrategy = ( + request: LoadRequest +) => Promise | string | null; + +export function createSourceLoaderChain( + strategies: LoadStrategy[] +): QraftSourceLoader { + const cache = new Map>(); + + return (id) => { + let pending = cache.get(id); + if (!pending) { + pending = loadWithStrategies(strategies, id); + cache.set(id, pending); + } + return pending; + }; +} + +async function loadWithStrategies( + strategies: LoadStrategy[], + id: string +): Promise { + for (const strategy of strategies) { + try { + const loaded = await strategy({ id }); + if (loaded !== null && loaded !== undefined) return loaded; + } catch { + // Try the next strategy. + } + } + + return null; +} + +export function createUserSourceLoaderStrategy( + userLoad?: QraftSourceLoader +): LoadStrategy { + return async ({ id }) => { + if (!userLoad) return null; + const loaded = await userLoad(id); + return loaded ?? null; + }; +} +``` + +- [x] **Step 4: Add the agnostic module-access factory** + +Replace the body of `packages/tree-shaking-plugin/src/lib/resolvers/agnostic.ts` with: + +```ts +import type { + QraftModuleAccess, + QraftModuleAccessOptions, + QraftResolver, +} from './common.js'; +import { + createResolverChain, + createSourceLoaderChain, + createUserResolverStrategy, + createUserSourceLoaderStrategy, +} from './common.js'; + +export function createAgnosticResolver( + userResolve?: QraftResolver +): QraftResolver { + return createResolverChain([createUserResolverStrategy(userResolve)]); +} + +export function createAgnosticModuleAccess( + userAccess: QraftModuleAccessOptions = {} +): QraftModuleAccess { + return { + resolve: createAgnosticResolver(userAccess.resolve), + load: createSourceLoaderChain([ + createUserSourceLoaderStrategy(userAccess.load), + ]), + }; +} +``` + +- [x] **Step 5: Update resolver test imports** + +In `packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts`, change: + +```ts +import { createAgnosticResolver } from './agnostic.js'; +``` + +to: + +```ts +import { + createAgnosticModuleAccess, + createAgnosticResolver, +} from './agnostic.js'; +``` + +- [x] **Step 6: Run resolver tests** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/lib/resolvers/resolvers.test.ts +``` + +Expected: PASS. + +- [x] **Step 7: Commit the boundary type change** + +```bash +git add packages/tree-shaking-plugin/src/lib/resolvers/common.ts \ + packages/tree-shaking-plugin/src/lib/resolvers/agnostic.ts \ + packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts +git commit -m "refactor(tree-shaking): introduce module access boundary" +``` + +--- + +### Task 2: Make Core and Plan Consume `QraftModuleAccess` + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/core.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Add a failing regression that proves core does not read generated files directly** + +In `packages/tree-shaking-plugin/src/core.test.ts`, add this test near the existing barrel/precreated factory tests: + +```ts +it('does not read generated modules from the filesystem when module access cannot load them', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')); + await writeFixtureFiles(root, { + ...PRECREATED_BASE_FILES, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + const resolvedApiFile = path.join(root, 'src/api/index.ts'); + + const result = await transformQraftTreeShakingImpl( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient({ queryClient: {} }); +api.pets.getPets.invalidateQueries(); +`, + sourceFile, + { + createAPIClientFn: [{ name: 'createAPIClient', module: './api' }], + moduleAccess: { + resolve: async (specifier, importer) => { + if (specifier === './api' && importer === sourceFile) { + return resolvedApiFile; + } + return null; + }, + load: async () => null, + }, + } + ); + + expect(result).toBeNull(); +}); +``` + +Expected initial failure: TypeScript rejects `moduleAccess` on options, or the transform returns generated code because `plan.ts` still reads `resolvedApiFile` from disk. + +- [x] **Step 2: Run the new regression and confirm it fails** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts -t "does not read generated modules from the filesystem" +``` + +Expected: FAIL for the reason above. + +- [x] **Step 3: Update the public options and transform signature** + +In `packages/tree-shaking-plugin/src/core.ts`, update imports: + +```ts +import type { + QraftModuleAccess, + QraftModuleAccessOptions, + QraftResolver, +} from './lib/resolvers/common.js'; +import { createAgnosticModuleAccess } from './lib/resolvers/agnostic.js'; +``` + +Add the option: + +```ts +export type QraftTreeShakeOptions = { + createAPIClientFn?: QraftFactoryConfig[]; + apiClient?: QraftPrecreatedClientConfig[]; + resolve?: QraftResolver; + moduleAccess?: QraftModuleAccessOptions; + include?: FilterPattern; + exclude?: FilterPattern; + debug?: boolean; +}; +``` + +Change the transform function parameters from resolver to module access: + +```ts +export async function transformQraftTreeShaking( + code: string, + id: string, + options: QraftTreeShakeOptions, + moduleAccess: QraftModuleAccess = createAgnosticModuleAccess({ + resolve: options.moduleAccess?.resolve ?? options.resolve, + load: options.moduleAccess?.load, + }), + inputSourceMap?: SourceMapInput +) { +``` + +Change the plan call: + +```ts +const plan = await createTransformPlan(code, id, options, moduleAccess); +``` + +- [x] **Step 4: Replace `QraftResolver` usage in `plan.ts`** + +In `packages/tree-shaking-plugin/src/lib/transform/plan.ts`, update imports: + +```ts +import type { QraftModuleAccess } from '../resolvers/common.js'; +import { createAgnosticModuleAccess } from '../resolvers/agnostic.js'; +``` + +Change `createTransformPlan(...)` signature: + +```ts +export async function createTransformPlan( + code: string, + id: string, + options: QraftTreeShakeOptions, + moduleAccess: QraftModuleAccess = createAgnosticModuleAccess({ + resolve: options.moduleAccess?.resolve ?? options.resolve, + load: options.moduleAccess?.load, + }) +): Promise { +``` + +Inside `createTransformPlan`, add: + +```ts +const resolver = moduleAccess.resolve; +``` + +This keeps existing resolver call sites compiling while the read paths are migrated. + +- [x] **Step 5: Replace direct generated-client reads with `moduleAccess.load`** + +Change `readGeneratedClientInfo(...)` signature from: + +```ts +async function readGeneratedClientInfo( + importerId: string, + clientFile: string, + factory: QraftFactoryConfig, + resolver: QraftResolver, + debug = false, + servicesDirName = 'services' +): Promise { +``` + +to: + +```ts +async function readGeneratedClientInfo( + importerId: string, + clientFile: string, + factory: QraftFactoryConfig, + moduleAccess: QraftModuleAccess, + debug = false, + servicesDirName = 'services' +): Promise { + const resolver = moduleAccess.resolve; +``` + +Replace its file read: + +```ts +let source: string; +try { + source = await fs.readFile(clientFile, 'utf8'); +} catch { + return skip('generated client file was not readable'); +} +``` + +with: + +```ts +const source = await moduleAccess.load(clientFile); +if (source === null) { + return skip('generated client source was not available'); +} +``` + +Update recursive calls and every caller to pass `moduleAccess` instead of `resolver`. + +- [x] **Step 6: Replace precreated export-chain reads with `moduleAccess.load`** + +Change `readExportedDeclarationChain(...)` signature to accept module access: + +```ts +async function readExportedDeclarationChain( + startFile: string, + exportName: string, + moduleAccess: QraftModuleAccess, + seen = new Set() +): Promise { + const resolver = moduleAccess.resolve; +``` + +Replace: + +```ts +let source: string; +try { + source = await fs.readFile(sourceFile, 'utf8'); +} catch { + return null; +} +``` + +with: + +```ts +const source = await moduleAccess.load(sourceFile); +if (source === null) return null; +``` + +Update recursive calls and every caller to pass `moduleAccess`. + +- [x] **Step 7: Replace services index reads with `moduleAccess.load`** + +Change `readServiceImportPaths(...)` signature: + +```ts +async function readServiceImportPaths( + clientFile: string, + servicesDir: string, + moduleAccess: QraftModuleAccess +): Promise> { + const resolver = moduleAccess.resolve; +``` + +Replace: + +```ts +let source: string; +try { + source = await fs.readFile(servicesIndexFile, 'utf8'); +} catch { + return {}; +} +``` + +with: + +```ts +const source = await moduleAccess.load(servicesIndexFile); +if (source === null) return {}; +``` + +Update the call in `readGeneratedClientInfo(...)`: + +```ts +const serviceImportPaths = await readServiceImportPaths( + clientFile, + servicesDir, + moduleAccess +); +``` + +- [x] **Step 8: Remove the production `fs` import from `plan.ts`** + +Delete this import from `packages/tree-shaking-plugin/src/lib/transform/plan.ts`: + +```ts +import fs from 'node:fs/promises'; +``` + +Then run: + +```bash +rg -n "node:fs|fs\\.readFile|readFile\\(" packages/tree-shaking-plugin/src/lib/transform/plan.ts +``` + +Expected: no matches in `plan.ts`. + +- [x] **Step 9: Update core test fixture helper to pass source-aware module access** + +In `packages/tree-shaking-plugin/src/core.test.ts`, update the wrapper helper so ordinary tests still load fixture files from the existing in-memory fs mock: + +```ts +async function transformQraftTreeShaking( + code: string, + id: string, + options: TransformOptions, + inputSourceMap?: SourceMapInput +) { + const fixtureRoot = path.dirname(path.dirname(id)); + const fixtureResolver = createFixtureResolver(fixtureRoot); + return transformQraftTreeShakingWithInputSourceMap( + code, + id, + { + ...options, + moduleAccess: { + resolve: fixtureResolver, + load: async (resolvedId) => fs.readFile(resolvedId, 'utf8'), + }, + }, + undefined, + inputSourceMap + ); +} +``` + +This keeps disk reads in test fixtures only; production `plan.ts` remains source-provider agnostic. + +- [x] **Step 10: Run the focused core regression** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts -t "does not read generated modules from the filesystem" +``` + +Expected: PASS. + +- [x] **Step 11: Run all plugin unit tests** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +``` + +Expected: PASS. + +- [x] **Step 12: Commit core module-access migration** + +```bash +git add packages/tree-shaking-plugin/src/core.ts \ + packages/tree-shaking-plugin/src/lib/transform/plan.ts \ + packages/tree-shaking-plugin/src/core.test.ts +git commit -m "refactor(tree-shaking): load generated modules through module access" +``` + +--- + +### Task 3: Implement Bundler Module-Access Adapters + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/rollup-like.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/webpack-like.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/rspack.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/esbuild.ts` +- Modify: `packages/tree-shaking-plugin/src/{vite,rollup,webpack,rspack,esbuild}.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts` + +- [x] **Step 1: Add adapter loading tests** + +In `packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts`, add these tests after the existing bundler resolver tests: + +```ts +it('loads source through the rollup-like context before custom loader fallback', async () => { + const ctx: BundlerResolveContext = { + resolve: vi.fn(async () => ({ id: '/tmp/api.ts', external: false })), + load: vi.fn(async (request: { id: string }) => { + expect(request).toEqual({ id: '/tmp/api.ts' }); + return { + code: 'export const fromRollup = true;', + }; + }), + }; + + const access = createRollupLikeModuleAccess(ctx); + await expect(access.resolve('./api', '/tmp/App.tsx')).resolves.toBe( + '/tmp/api.ts' + ); + await expect(access.load('/tmp/api.ts')).resolves.toBe( + 'export const fromRollup = true;' + ); +}); + +it('loads source through webpack loadModule', async () => { + const loadModule = vi.fn((request: string, callback: (...args: unknown[]) => void) => { + expect(request).toBe('/tmp/generated-api/index.ts'); + callback(null, Buffer.from('export const fromWebpack = true;'), null, {}); + }); + + const access = createWebpackLikeModuleAccess({ + getNativeBuildContext() { + return { + framework: 'webpack', + loaderContext: { + getResolve: () => async () => '/tmp/generated-api/index.ts', + loadModule, + }, + }; + }, + }); + + await expect(access.load('/tmp/generated-api/index.ts')).resolves.toBe( + 'export const fromWebpack = true;' + ); + expect(loadModule).toHaveBeenCalledTimes(1); +}); + +it('uses the custom source loader before esbuild file fallback', async () => { + const access = createEsbuildModuleAccess( + { + getNativeBuildContext() { + return { + framework: 'esbuild', + build: { + resolve: async () => ({ path: '/tmp/api.ts', errors: [] }), + }, + }; + }, + }, + { + load: async (id) => + id === '/tmp/api.ts' ? 'export const fromUserLoader = true;' : null, + } + ); + + await expect(access.load('/tmp/api.ts')).resolves.toBe( + 'export const fromUserLoader = true;' + ); +}); +``` + +Expected initial failure: missing `load` field in `BundlerResolveContext` and missing module-access factory exports. + +Also update imports at the top of `packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts`: + +```ts +import { + createAgnosticModuleAccess, + createAgnosticResolver, +} from './agnostic.js'; +import { + createEsbuildModuleAccess, + createEsbuildResolver, +} from './esbuild.js'; +import { + createRollupLikeModuleAccess, + createRollupLikeResolver, +} from './rollup-like.js'; +import { + createRspackModuleAccess, + createRspackResolver, +} from './rspack.js'; +import { + createWebpackLikeModuleAccess, + createWebpackLikeResolver, +} from './webpack-like.js'; +``` + +- [x] **Step 2: Run adapter tests and confirm failure** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/lib/resolvers/resolvers.test.ts -t "loads source" +``` + +Expected: FAIL with missing types/exports. + +- [x] **Step 3: Extend bundler context types** + +In `packages/tree-shaking-plugin/src/lib/resolvers/common.ts`, add the Rollup-like load type: + +```ts +export type RollupLikeLoad = ( + request: { id: string } +) => + | Promise<{ code?: string | null } | string | null | undefined> + | { code?: string | null } + | string + | null + | undefined; +``` + +Update `BundlerNativeBuildContext`: + +```ts +export type BundlerNativeBuildContext = { + framework?: string; + build?: EsbuildLikeBuild; + compiler?: unknown; + compilation?: unknown; + loaderContext?: unknown; + inputSourceMap?: unknown; +}; +``` + +Keep existing fields and update `BundlerResolveContext`: + +```ts +export type BundlerResolveContext = { + resolve?: RollupLikeResolve; + load?: RollupLikeLoad; + getNativeBuildContext?: () => BundlerNativeBuildContext | null; +}; +``` + +- [x] **Step 4: Implement Rollup/Vite module access** + +In `packages/tree-shaking-plugin/src/lib/resolvers/rollup-like.ts`, add imports: + +```ts +import { + createResolverChain, + createSourceLoaderChain, + createUserResolverStrategy, + createUserSourceLoaderStrategy, +} from './common.js'; +``` + +Add source loader strategy: + +```ts +function createRollupLoadStrategy(ctx: BundlerResolveContext): LoadStrategy { + return async ({ id }) => { + if (typeof ctx.load !== 'function') return null; + const loaded = await ctx.load({ id }); + if (typeof loaded === 'string') return loaded; + if (loaded && typeof loaded.code === 'string') return loaded.code; + return null; + }; +} +``` + +Add the factory: + +```ts +export function createRollupLikeModuleAccess( + ctx: BundlerResolveContext, + userAccess: QraftModuleAccessOptions = {} +): QraftModuleAccess { + return { + resolve: createResolverChain([ + createRollupResolveStrategy(ctx), + createUserResolverStrategy(userAccess.resolve), + ]), + load: createSourceLoaderChain([ + createRollupLoadStrategy(ctx), + createUserSourceLoaderStrategy(userAccess.load), + ]), + }; +} +``` + +Keep `createRollupLikeResolver(...)` as compatibility wrapper: + +```ts +export function createRollupLikeResolver( + ctx: BundlerResolveContext, + userResolve?: QraftResolver +): QraftResolver { + return createRollupLikeModuleAccess(ctx, { resolve: userResolve }).resolve; +} +``` + +- [x] **Step 5: Implement webpack module access** + +In `packages/tree-shaking-plugin/src/lib/resolvers/webpack-like.ts`, add a loader context type: + +```ts +type WebpackLoadModule = ( + request: string, + callback: ( + error: Error | null, + source: string | Buffer | null, + sourceMap: unknown, + module: unknown + ) => void +) => void; + +type WebpackLoaderRuntimeContext = { + getResolve?: (options?: { dependencyType?: string }) => WebpackResolveFn; + loadModule?: WebpackLoadModule; +}; +``` + +Add loader extraction helper: + +```ts +function getWebpackLoaderContext( + ctx: WebpackLoaderContextLike +): WebpackLoaderRuntimeContext | undefined { + return ctx.getNativeBuildContext?.()?.loaderContext as + | WebpackLoaderRuntimeContext + | undefined; +} +``` + +Use it in the existing resolve strategy, then add: + +```ts +function createWebpackLoadStrategy( + ctx: WebpackLoaderContextLike +): LoadStrategy { + return async ({ id }) => { + const loaderContext = getWebpackLoaderContext(ctx); + if (typeof loaderContext?.loadModule !== 'function') return null; + + return new Promise((resolve) => { + loaderContext.loadModule(id, (error, source) => { + if (error || source === null || source === undefined) { + resolve(null); + return; + } + resolve(Buffer.isBuffer(source) ? source.toString('utf8') : source); + }); + }); + }; +} +``` + +Export: + +```ts +export function createWebpackLikeModuleAccess( + ctx: WebpackLoaderContextLike, + userAccess: QraftModuleAccessOptions = {} +): QraftModuleAccess { + return { + resolve: createResolverChain([ + createWebpackResolveStrategy(ctx), + createUserResolverStrategy(userAccess.resolve), + ]), + load: createSourceLoaderChain([ + createWebpackLoadStrategy(ctx), + createUserSourceLoaderStrategy(userAccess.load), + ]), + }; +} +``` + +Keep `createWebpackLikeResolver(...)` as a compatibility wrapper. + +- [x] **Step 6: Implement rspack module access** + +In `packages/tree-shaking-plugin/src/lib/resolvers/rspack.ts`, mirror the webpack load strategy because rspack exposes webpack-compatible loader context in this plugin path: + +```ts +type RspackLoadModule = ( + request: string, + callback: ( + error: Error | null, + source: string | Buffer | null, + sourceMap: unknown, + module: unknown + ) => void +) => void; + +function createRspackLoadStrategy(ctx: BundlerResolveContext): LoadStrategy { + return async ({ id }) => { + const loaderContext = ctx.getNativeBuildContext?.()?.loaderContext as + | { loadModule?: RspackLoadModule } + | undefined; + if (typeof loaderContext?.loadModule !== 'function') return null; + + return new Promise((resolve) => { + loaderContext.loadModule(id, (error, source) => { + if (error || source === null || source === undefined) { + resolve(null); + return; + } + resolve(Buffer.isBuffer(source) ? source.toString('utf8') : source); + }); + }); + }; +} +``` + +Export `createRspackModuleAccess(...)` with the existing rspack resolve strategy plus the loader strategy, and keep `createRspackResolver(...)` as a wrapper. + +- [x] **Step 7: Implement esbuild module access with adapter-local file fallback** + +In `packages/tree-shaking-plugin/src/lib/resolvers/esbuild.ts`, import `fs` locally: + +```ts +import fs from 'node:fs/promises'; +``` + +Add file loader strategy: + +```ts +function createEsbuildFileLoadStrategy(): LoadStrategy { + return async ({ id }) => { + try { + return await fs.readFile(id, 'utf8'); + } catch { + return null; + } + }; +} +``` + +Export: + +```ts +export function createEsbuildModuleAccess( + ctx: BundlerResolveContext, + userAccess: QraftModuleAccessOptions = {} +): QraftModuleAccess { + return { + resolve: createResolverChain([ + createEsbuildResolveStrategy(ctx), + createUserResolverStrategy(userAccess.resolve), + ]), + load: createSourceLoaderChain([ + createUserSourceLoaderStrategy(userAccess.load), + createEsbuildFileLoadStrategy(), + ]), + }; +} +``` + +Keep `createEsbuildResolver(...)` as a wrapper. Add a comment above `createEsbuildFileLoadStrategy()`: + +```ts +// Esbuild exposes build.resolve but no arbitrary build.load API. Keep this +// fallback adapter-local; core transform must not read the filesystem directly. +``` + +**Implementation correction after real-bundler verification:** Rollup/Vite must not use `this.load(...)` from inside the transform hook for this source inspection. In the e2e fixture, that creates a Rollup cycle warning and can block module loading. The implemented Rollup-like loader uses Rollup's `PluginContext.fs.readFile(...)`, which honors `InputOptions.fs` and custom filesystem providers. Rspack first tries `loadModule(...)`, then uses the loader context `fs.readFile(...)`, which maps to Rspack's compilation input filesystem. These are bundler-owned filesystem abstractions, not hidden `node:fs` reads. Esbuild remains the only adapter-local ordinary-file fallback because esbuild exposes `build.resolve(...)` but not an arbitrary `build.load(...)` API. + +- [x] **Step 8: Update plugin factory to accept module access factories** + +In `packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.ts`, update types: + +```ts +import { type QraftModuleAccessFactory } from '../resolvers/common.js'; +``` + +Replace `QraftResolverFactory` with: + +```ts +export type QraftResolverFactory = + QraftModuleAccessFactory; +``` + +Rename the argument: + +```ts +export function createQraftTreeShakePlugin( + createModuleAccess: QraftModuleAccessFactory +) { +``` + +Update the handler: + +```ts +handler(this: any, code, id) { + const moduleAccess = createModuleAccess(this, { + resolve: options.moduleAccess?.resolve ?? options.resolve, + load: options.moduleAccess?.load, + }); + return transformQraftTreeShaking( + code, + id, + options, + moduleAccess, + this.inputSourceMap + ); +}, +``` + +- [x] **Step 9: Update entrypoint imports** + +Change each entrypoint: + +```ts +// vite.ts and rollup.ts +import { createRollupLikeModuleAccess } from './lib/resolvers/rollup-like.js'; +createQraftTreeShakePlugin( + createRollupLikeModuleAccess +) +``` + +```ts +// webpack.ts +import { createWebpackLikeModuleAccess } from './lib/resolvers/webpack-like.js'; +createQraftTreeShakePlugin( + createWebpackLikeModuleAccess +) +``` + +```ts +// rspack.ts +import { createRspackModuleAccess } from './lib/resolvers/rspack.js'; +createQraftTreeShakePlugin(createRspackModuleAccess) +``` + +```ts +// esbuild.ts +import { createEsbuildModuleAccess } from './lib/resolvers/esbuild.js'; +createQraftTreeShakePlugin(createEsbuildModuleAccess) +``` + +- [x] **Step 10: Run adapter tests** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/lib/resolvers/resolvers.test.ts +``` + +Expected: PASS. + +- [x] **Step 11: Run typecheck** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: PASS. + +- [x] **Step 12: Commit adapter work** + +```bash +git add packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.ts \ + packages/tree-shaking-plugin/src/lib/resolvers/common.ts \ + packages/tree-shaking-plugin/src/lib/resolvers/rollup-like.ts \ + packages/tree-shaking-plugin/src/lib/resolvers/webpack-like.ts \ + packages/tree-shaking-plugin/src/lib/resolvers/rspack.ts \ + packages/tree-shaking-plugin/src/lib/resolvers/esbuild.ts \ + packages/tree-shaking-plugin/src/vite.ts \ + packages/tree-shaking-plugin/src/rollup.ts \ + packages/tree-shaking-plugin/src/webpack.ts \ + packages/tree-shaking-plugin/src/rspack.ts \ + packages/tree-shaking-plugin/src/esbuild.ts \ + packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts +git commit -m "feat(tree-shaking): load module source through bundler adapters" +``` + +--- + +### Task 4: Document the New Developer Experience + +**Files:** + +- Modify: `packages/tree-shaking-plugin/README.md` +- Modify: `packages/tree-shaking-plugin/src/core.ts` + +- [x] **Step 1: Add README documentation for normal and advanced usage** + +In `packages/tree-shaking-plugin/README.md`, find the existing `createAPIClientFn` or resolver section and add: + +````md +### Module access + +The plugin resolves and inspects generated Qraft modules through the active +bundler adapter. Normal Vite, Rollup, webpack, Rspack, and esbuild users do not +need to configure this: + +```ts +qraftTreeShakeVite({ + createAPIClientFn: [ + { + name: 'createAPIClient', + module: './src/api', + context: 'APIClientContext', + contextModule: './src/api/APIClientContext', + }, + ], +}) +``` + +If a build uses virtual modules or a non-standard source provider that the +bundler adapter cannot load directly, provide `moduleAccess.load` as an +advanced escape hatch: + +```ts +qraftTreeShakeVite({ + createAPIClientFn: [{ name: 'createAPIClient', module: 'virtual:qraft-api' }], + moduleAccess: { + load: async (resolvedId) => { + return resolvedId === 'virtual:qraft-api' + ? `export { createAPIClient } from './actual-api'` + : null + }, + }, +}) +``` + +The transform core does not read generated modules from Node's filesystem. If a +resolved generated module cannot be loaded through module access, the plugin +skips that optimization and, with `debug: true`, prints the skipped module +reason. +```` + +- [x] **Step 2: Add an exported options type comment** + +In `packages/tree-shaking-plugin/src/core.ts`, add a short comment above `moduleAccess`: + +```ts + /** + * Advanced source-provider override. Normal bundler integrations provide + * this automatically; use it only for virtual modules or custom filesystems. + */ + moduleAccess?: QraftModuleAccessOptions; +``` + +- [x] **Step 3: Run docs-free typecheck** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: PASS. + +- [x] **Step 4: Commit docs** + +```bash +git add packages/tree-shaking-plugin/README.md packages/tree-shaking-plugin/src/core.ts +git commit -m "docs(tree-shaking): document module access override" +``` + +--- + +### Task 5: Verify Real Bundlers and Decide Whether E2E Fixture Needs a New Scenario + +**Files:** + +- Modify only if needed: `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` +- Modify only if needed: `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` + +- [x] **Step 1: Run the fast local fixture loop** + +Completed: rebuilt `@openapi-qraft/tree-shaking-plugin`, copied `dist` into the local fixture, ran `npm run build`, then ran `npm run e2e:post-build`; all bundler assertions passed. + +From repo root: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +rm -rf e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cp -R packages/tree-shaking-plugin/dist \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cd e2e/projects/tree-shaking-bundlers +npm run build +npm run e2e:post-build +``` + +Expected: PASS for Vite, Rollup, webpack, Rspack, and esbuild scenarios. + +- [x] **Step 2: If a bundler fails because its adapter cannot load generated modules, inspect only that bundler output** + +Completed: the fast fixture exposed a Rollup/Vite `this.load(...)` cycle and an Rspack miss. Rollup/Vite were moved to `PluginContext.fs.readFile(...)`; Rspack now falls back to `loaderContext.fs.readFile(...)` after `loadModule(...)`. + +Run one scenario at a time from `e2e/projects/tree-shaking-bundlers`: + +```bash +QRAFT_TREE_SHAKE_SCENARIO=barrel-context-relative npm run build +node ./scripts/assert-dist.mjs +``` + +Expected: the focused scenario either passes or points to a specific `NO TRANSFORM`/missing token. Fix the corresponding adapter, not the fixture assertion, unless the emitted shape is intentionally different and still equivalent. + +- [x] **Step 3: Add a fixture scenario only if existing coverage does not prove barrel source loading** + +Completed: no new fixture scenario was added. Existing `barrel-*` and `mixed-context-precreated-mirrors` scenarios already exercise barrel source loading and passed after the adapter fix. + +If the existing `barrel-*` scenarios already fail before the adapter fix and pass after it, do not add new fixture files. If no existing scenario exercises re-exported factory source loading after direct `fs` removal, add a scenario in `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` that imports a generated factory through a barrel while the config points at the direct generated module. + +Use this shape for the scenario entry if needed: + +```ts +import { createRelativeAPIClient } from './generated-api'; + +const api = createRelativeAPIClient(); + +export const barrelModuleAccessProof = + api.pets.getPets.getQueryKey({ path: {}, query: {} }); +``` + +Then assert the exact token in `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs`: + +```js +{ + scenario: 'barrel-module-access-proof', + includes: ['barrelModuleAccessProof', 'qraftAPIClient', 'getPets'], + excludes: ['createRelativeAPIClient', 'storesService'], +} +``` + +Keep this step skipped if existing scenarios already cover the behavior. + +- [x] **Step 4: Commit any e2e fixture changes** + +Completed: no fixture files changed, so no e2e fixture commit was created. + +If Step 3 changed fixture files: + +```bash +git add e2e/projects/tree-shaking-bundlers +git commit -m "test(tree-shaking): cover module-access barrel loading in bundlers" +``` + +If Step 3 made no changes, do not create an empty commit. + +- [x] **Step 5: Run the full Verdaccio e2e loop** + +Completed: `corepack yarn e2e:tree-shaking-bundlers-local` exited `0`; the copied fixture built all Vite, Rollup, webpack, Rspack, and esbuild scenarios and `Tree-shaking bundle assertions passed`. + +```bash +cd /Users/radist/WebstormProjects/qraft/e2e +corepack yarn e2e:tree-shaking-bundlers-local +``` + +Expected: PASS. This command is the slow path and is required before claiming the refactor is complete. + +--- + +### Task 6: Final Verification and Cleanup + +**Files:** + +- Inspect: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Inspect: `packages/tree-shaking-plugin/src/lib/resolvers/*.ts` +- Inspect: `packages/tree-shaking-plugin/src/core.ts` + +- [x] **Step 1: Prove core transform no longer imports filesystem APIs** + +Completed: grep returned no matches in `core.ts` or `plan.ts`. + +```bash +rg -n "node:fs|fs\\.readFile|readFile\\(" packages/tree-shaking-plugin/src/core.ts packages/tree-shaking-plugin/src/lib/transform/plan.ts +``` + +Expected: no matches. + +- [x] **Step 2: Confirm any remaining filesystem reads are adapter-local or test-only** + +Completed: remaining matches are tests, esbuild's documented adapter-local ordinary-file fallback, Rollup `ctx.fs.readFile(...)`, and Rspack loader-context `fs.readFile(...)`. + +```bash +rg -n "node:fs|fs\\.readFile|readFile\\(" packages/tree-shaking-plugin/src +``` + +Expected: matches are limited to tests and adapter-local code such as `src/lib/resolvers/esbuild.ts`; no match appears in `src/core.ts` or `src/lib/transform/plan.ts`. + +- [x] **Step 3: Run final package verification** + +Completed: package lint, test, and typecheck all exited `0`. + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: all commands exit `0`. + +- [x] **Step 4: Run final full e2e verification** + +Completed: `corepack yarn e2e:tree-shaking-bundlers-local` exited `0`. + +```bash +cd /Users/radist/WebstormProjects/qraft/e2e +corepack yarn e2e:tree-shaking-bundlers-local +``` + +Expected: exits `0`. + +- [x] **Step 5: Commit final cleanup if needed** + +Completed: no code cleanup was needed after full e2e; only this plan status update remains to commit. + +If final verification required cleanup: + +```bash +git add packages/tree-shaking-plugin e2e/projects/tree-shaking-bundlers +git commit -m "chore(tree-shaking): finalize module access cleanup" +``` + +If there are no changes after verification, do not create a commit. + +--- + +### Notes for Implementers + +- Keep code comments in English. +- Do not weaken bundle assertions to make a failing bundler pass. A failure after removing `fs` usually means that adapter `load()` cannot see the generated module source. +- `plan.ts` may still parse source code with Babel. The issue this plan fixes is the source provider boundary, not AST parsing itself. +- Keep `resolve?: QraftResolver` for compatibility during development, but treat `moduleAccess` as the stronger contract. If both are provided, `moduleAccess.resolve` wins in direct core calls; bundler entrypoints should pass `options.resolve` into their adapter as the user override. +- Esbuild is intentionally different: it has `build.resolve` but not a public arbitrary `build.load` API. Its fallback may read ordinary file paths inside `src/lib/resolvers/esbuild.ts`, but core transform must remain filesystem-free. diff --git a/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-resolution-and-zero-arg.md b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-resolution-and-zero-arg.md new file mode 100644 index 000000000..cb9b78008 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-qraft-tree-shaking-resolution-and-zero-arg.md @@ -0,0 +1,534 @@ +# Qraft Tree-Shaking: Barrel Resolution & Zero-Arg No-Context Factory Fix + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Fix two independent gaps in the tree-shaking plugin: (1) re-export barrel imports are not matched against a factory configured with a direct file path, and (2) zero-arg calls to no-context factories (`qraftAPIClient`-based) are never transformed, even for callbacks that require no query-client options (e.g. `getQueryKey`, `getMutationKey`). + +**Architecture:** Both bugs live in `plan.ts`. Bug 1 is in the import-matching loop that compares the resolved import path against `factoryResolvedIds` — it needs a barrel fallback that reads the resolved barrel file, finds the re-export of the factory name, resolves the target, and compares again. Bug 2 is a single predicate guard that skips _all_ usages of zero-arg no-context factory bindings: relaxing it to skip only when `callbackNeedsRuntimeContext(callbackName)` is true allows options-free callbacks (`getQueryKey`, `getMutationKey`, `getInfiniteQueryKey`) to flow through and emit `qraftAPIClient(op, { callback })` without a third argument. No changes to `mutate.ts` are required because `createOptimizedClientDeclaration` already omits the options argument when `needsOptions` is false. + +**Tech Stack:** TypeScript, Babel traverse/types, Vitest inline snapshots, Yarn 4. + +--- + +### How to run tests + +**Plugin unit tests** (no e2e, fast): + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +# single test by name pattern: +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts -t "my test name" +# update inline snapshots: +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts -t "my test name" --update-snapshots +``` + +**E2e: fast local iteration** (uses local plugin dist, no Verdaccio): + +The e2e fixture at `e2e/projects/tree-shaking-bundlers` depends on the _installed_ dist of `@openapi-qraft/tree-shaking-plugin` (not a workspace symlink). After changing plugin source, build and sync the dist before running the fixture: + +```bash +# 1. Build the plugin +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build + +# 2. Sync the fresh dist into the fixture's node_modules +cp -r packages/tree-shaking-plugin/dist/. \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist/ + +# 3. Run all 5 bundlers × all scenarios (from the fixture root) +cd e2e/projects/tree-shaking-bundlers +npm run build + +# 4. Assert all bundle outputs (still inside the fixture root) +npm run e2e:post-build +``` + +To also re-run codegen (only needed when changing the OpenAPI spec or `package.json` codegen args): + +```bash +npm run e2e:pre-build +``` + +**E2e: full end-to-end** (publishes packages to local Verdaccio, slower): + +```bash +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +--- + +### File Structure + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` — barrel re-export fallback in the import-matching loop; relax the zero-arg no-context skip guard. +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` — update two existing tests, add one barrel-resolution test. +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` — update `apiOnlyScenario` excludes, expand `node-api-helper-selection` includes; keep `createNodeAPIClient` module as `'./generated-api/create-node-api-client'`. +- No change to `e2e/projects/tree-shaking-bundlers/src/node-api-helper-selection.ts` — already has zero-arg usage after user rollback. + +--- + +### Task 0: Temporarily disable the zero-arg test to isolate the barrel fix + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +**Why this exists:** Two bugs coexist in the same plugin. Without isolation, when we write a _failing_ barrel-resolution test in Task 1 and run the full suite, the already-committed test `'does not transform zero-arg calls to a no-context factory'` (which currently asserts `result === null`) would _also_ start failing after the zero-arg guard is relaxed — creating noise that obscures which fix causes which result. We skip it here, do the barrel fix cleanly (Task 1), then reinstate and rewrite it as part of the zero-arg fix (Task 2). + +- [x] **Step 1: Mark the zero-arg–getQueryKey test as skipped** + +Find the test at approximately line 470 of `packages/tree-shaking-plugin/src/core.test.ts`: + +```ts +it('does not transform zero-arg calls to a no-context factory', async () => { +``` + +Change `it` to `it.skip`: + +```ts +it.skip('does not transform zero-arg calls to a no-context factory', async () => { +``` + +- [x] **Step 2: Verify the suite is clean** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +``` + +Expected: all non-skipped tests pass, the skipped test is listed as skipped. + +- [x] **Step 3: Commit the temporary skip** + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test: temporarily skip zero-arg no-context test (will revert after barrel fix)" +``` + +--- + +### Task 1: Fix barrel re-export resolution in `plan.ts` (TDD) + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` + +- [x] **Step 1: Write the failing test for barrel-import resolution** + +Add a new test immediately after the (now-skipped) `'does not transform zero-arg calls to a no-context factory'` test. The fixture has a separate `api-barrel.ts` that re-exports `createAPIClient` from `./api`. The plugin is configured with `module: './api'` (the factory's direct file), but the consumer imports from `'./api-barrel'` (the barrel). The test confirms the factory _is_ still transformed despite the indirection. + +```ts +it('transforms factory imported via a barrel when the module config points to the direct file', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')); + // PRECREATED_BASE_FILES puts the no-context factory at src/api/index.ts + await writeFixtureFiles(root, { + ...PRECREATED_BASE_FILES, + // a one-liner barrel that re-exports from the factory file + 'src/api-barrel.ts': `export { createAPIClient } from './api';`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api-barrel'; + +const api = createAPIClient({ queryClient: {} }); +api.pets.getPets.invalidateQueries(); +`, + sourceFile, + // module points to the direct factory file, but the consumer imports from the barrel + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(`...`); +}); +``` + +- [x] **Step 2: Run the new test to confirm it fails** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts \ + -t "transforms factory imported via a barrel" +``` + +Expected: FAIL — the plugin returns `null` because the barrel path doesn't match the configured direct-file path. + +- [x] **Step 3: Add `resolveBarrelReexportedFactory` helper to `plan.ts`** + +Add the following async helper **after** the existing `findFactoryReexport` function (around line 1318 in `plan.ts`): + +```ts +async function resolveBarrelReexportedFactory( + barrelFile: string, + importedName: string, + matchingFactories: QraftFactoryConfig[], + factoryResolvedIds: Map, + resolver: QraftResolver +): Promise { + let source: string; + try { + source = await fs.readFile(barrelFile, 'utf8'); + } catch { + return null; + } + + const barrelAst = parse(source, { + sourceType: 'module', + plugins: ['typescript'], + }); + const reexportSpecifier = findFactoryReexport(barrelAst, importedName); + if (!reexportSpecifier) return null; + + const resolved = await resolver(reexportSpecifier, barrelFile); + if (!resolved) return null; + const resolvedId = normalizeResolvedId(resolved); + + return ( + matchingFactories.find( + (factory) => factoryResolvedIds.get(factory) === resolvedId + ) ?? null + ); +} +``` + +- [x] **Step 4: Use the helper as a fallback in the import-matching loop** + +In `createTransformPlan`, find the block that ends with (approximately lines 180–184): + +```ts +const matched = matchingFactories.find( + (factory) => factoryResolvedIds.get(factory) === resolvedId +); +if (!matched) continue; +``` + +Replace with: + +```ts +let matched = matchingFactories.find( + (factory) => factoryResolvedIds.get(factory) === resolvedId +); +if (!matched) { + matched = + (await resolveBarrelReexportedFactory( + resolvedAbs, + importedName, + matchingFactories, + factoryResolvedIds, + resolver + )) ?? undefined; +} +if (!matched) continue; +``` + +- [x] **Step 5: Run the barrel-resolution test and update the inline snapshot** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts \ + -t "transforms factory imported via a barrel" --update-snapshots +``` + +Expected: PASS. The snapshot is now populated with the transformed code (no `createAPIClient` factory, direct `qraftAPIClient(getPets, { invalidateQueries }, ...)` call). + +- [x] **Step 6: Run the full unit test suite to verify no regressions** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +``` + +Expected: all tests pass (the zero-arg test is still skipped — that is expected). + +- [x] **Step 7: Commit** + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/plan.ts \ + packages/tree-shaking-plugin/src/core.test.ts +git commit -m "fix(tree-shaking): resolve factory through barrel re-exports in import matching" +``` + +--- + +### Task 2: Fix zero-arg no-context factory named-binding transformation (TDD) + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` + +**Context:** `PRECREATED_BASE_FILES` contains `src/api/index.ts` that uses `qraftAPIClient` (not `qraftReactAPIClient`), so `generatedInfo.contextName` is always `null` for it. A zero-arg call (`const api = createAPIClient()`) is classified as `mode: { type: 'context' }` in the plan phase. The guard at the usage-collection step currently skips **all** callbacks when `contextName` is null, even those like `getQueryKey` that require no options argument. + +- [x] **Step 0: Revert the temporary skip commit from Task 0** + +```bash +git revert HEAD --no-edit +``` + +This reinstates `'does not transform zero-arg calls to a no-context factory'` as `it(...)` (not `it.skip`). Confirm the suite still passes — the test currently expects `null`, which the code still returns before the fix below. + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +``` + +Expected: all tests pass (the zero-arg test expects `null` and is still satisfied). + +- [x] **Step 1: Rename the zero-arg test and change its expected outcome** + +Find the test at approximately line 470: + +``` +'does not transform zero-arg calls to a no-context factory' +``` + +Rename it and update its expectation — after the fix it **should** transform `getQueryKey`: + +```ts +it('transforms zero-arg no-options callbacks on a no-context factory', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')); + await writeFixtureFiles(root, { + ...PRECREATED_BASE_FILES, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets.getQueryKey(); +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + // getQueryKey needs no options — it must be transformed even for a zero-arg call + expect(result?.code).toMatchInlineSnapshot(`...`); +}); +``` + +- [x] **Step 2: Update the "mixed zero-arg + options" test title and snapshot placeholder** + +Find the test at approximately line 496: + +``` +'transforms options calls to a no-context factory while keeping zero-arg calls untouched' +``` + +Rename it and replace its `toMatchInlineSnapshot` argument with a placeholder so the update step fills it in: + +```ts +it('transforms both zero-arg no-options and options calls to a no-context factory', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')); + await writeFixtureFiles(root, { + ...PRECREATED_BASE_FILES, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const apiUtility = createAPIClient(); +const apiWithClient = createAPIClient({ queryClient: {} }); + +apiUtility.pets.getPets.getQueryKey(); +apiWithClient.pets.getPets.invalidateQueries(); +apiWithClient.pets.getPets.setQueryData(undefined, () => undefined); +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + // apiUtility zero-arg: getQueryKey needs no options → transformed, no third arg + // apiWithClient options: invalidateQueries + setQueryData → transformed with options + // Both const declarations are removed; createAPIClient import is removed + expect(result?.code).toMatchInlineSnapshot(`...`); +}); +``` + +- [x] **Step 3: Run the two updated tests to confirm they fail** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts \ + -t "transforms zero-arg no-options callbacks on a no-context factory" \ + -t "transforms both zero-arg no-options and options calls to a no-context factory" +``` + +Expected: FAIL — both return `null` / mismatched snapshots because the guard still skips all zero-arg no-context usages. + +- [x] **Step 4: Relax the skip guard in `plan.ts`** + +In `createTransformPlan`, inside the second `traverse(ast, { CallExpression(callPath) { ... } })` block, find (approximately line 341): + +```ts +if (match.client.mode.type === 'context' && !generatedInfo.contextName) { + return debugSkip(options, id, 'context client was not detected'); +} +``` + +Replace with: + +```ts +if ( + match.client.mode.type === 'context' && + !generatedInfo.contextName && + callbackNeedsRuntimeContext(match.callbackName) +) { + return debugSkip(options, id, 'context client was not detected'); +} +``` + +`callbackNeedsRuntimeContext` is already imported in `plan.ts` from `'./callbacks.js'` (equivalent to `callbackNeedsOptions`). This allows callbacks that need no options (`getQueryKey`, `getMutationKey`, `getInfiniteQueryKey`) to pass through even when `contextName` is null. The mutate phase already handles this correctly: `createOptimizedClientDeclaration` in `mutate.ts` only pushes a third argument when `callbackNeedsOptions` is true, so utility-only buckets emit `qraftAPIClient(op, { getQueryKey })` with no options arg. + +- [x] **Step 5: Run the two tests and update the snapshots** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test src/core.test.ts \ + -t "transforms zero-arg no-options callbacks on a no-context factory" \ + -t "transforms both zero-arg no-options and options calls to a no-context factory" \ + --update-snapshots +``` + +Expected: PASS. Verify the produced snapshots: + +For `'transforms zero-arg no-options callbacks on a no-context factory'`, the snapshot should resemble: + +``` +"import { qraftAPIClient } from "@openapi-qraft/react"; +import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; +import { getPets } from "./api/services/PetsService"; +const api_pets_getPets = qraftAPIClient(getPets, { + getQueryKey +}); +api_pets_getPets.getQueryKey();" +``` + +For `'transforms both zero-arg no-options and options calls to a no-context factory'`, confirm: + +- `import { createAPIClient }` is removed +- `const apiUtility` and `const apiWithClient` declarations are removed +- `apiUtility_pets_getPets = qraftAPIClient(getPets, { getQueryKey })` — **no third arg** +- `apiWithClient_pets_getPets = qraftAPIClient(getPets, { invalidateQueries, setQueryData }, { queryClient: {} })` + +- [x] **Step 6: Run the full unit test suite** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +``` + +Expected: all tests pass. + +- [x] **Step 7: Commit** + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/plan.ts \ + packages/tree-shaking-plugin/src/core.test.ts +git commit -m "fix(tree-shaking): transform zero-arg no-options callbacks on no-context factories" +``` + +--- + +### Task 3: Update e2e scenario assertions and verify the full bundler matrix + +**Files:** + +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` + +**Context:** +After both plugin fixes: + +- `node-api-helper-selection.ts` imports `createNodeAPIClient` from `'./generated-api'` (barrel), configured with `module: './generated-api/create-node-api-client'` (direct file). The barrel fix resolves this match. +- `nodeApiUtility = createNodeAPIClient()` zero-arg + `getQueryKey` → zero-arg fix transforms it. +- `nodeApi = createNodeAPIClient(nodeOptions)` options-based + `invalidateQueries`/`setQueryData` → barrel fix + existing options path transforms it. +- Both `createNodeAPIClient` factory references are eliminated → `allCallbacks` namespace import disappears from every bundle. + +For `barrel-mixed-helper-selection.ts`: + +- `createNodeAPIClient` is imported from `'./generated-api/create-node-api-client'` **directly** (not the barrel) — already matched before. The zero-arg fix enables `getQueryKey` to transform. + +- [x] **Step 1: Update `apiOnlyScenario` excludes and `node-api-helper-selection` includes in `shared.mjs`** + +```js +// Replace the apiOnlyScenario function: +const apiOnlyScenario = ({ name, entry, include, exclude }) => ({ + name, + mode: 'apiOnly', + entry, + include: unique([qraftAPIClientPattern, ...include]), + exclude: unique([ + qraftReactAPIClientPattern, + 'allCallbacks', // confirms the factory was fully eliminated + 'APIClientContext', + ...exclude, + ]), +}); + +// Replace the node-api-helper-selection scenario entry: +apiOnlyScenario({ + name: 'node-api-helper-selection', + entry: 'src/node-api-helper-selection.ts', + include: ['getQueryKey', 'invalidateQueries', 'setQueryData', 'getPets'], + exclude: ['createNodeAPIClient'], +}), +``` + +Leave `barrel-mixed-helper-selection` unchanged (already correct after user rollback). + +- [x] **Step 2: Build the plugin and sync dist to the fixture** + +```bash +# Build the plugin with the two fixes +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build + +# Sync the fresh dist into the fixture's node_modules +cp -r packages/tree-shaking-plugin/dist/. \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist/ +``` + +- [x] **Step 3: Build all bundler scenarios** + +```bash +cd e2e/projects/tree-shaking-bundlers +npm run build +``` + +Expected: all 5 bundlers × 13 scenarios build without errors. The `node-api-helper-selection` bundles must **not** contain `createNodeAPIClient` or `allCallbacks`. + +- [x] **Step 4: Run the bundle assertions** + +```bash +npm run e2e:post-build +``` + +Expected output: `Tree-shaking bundle assertions passed.` + +Key assertions to watch: + +- `node-api-helper-selection`: includes `qraftAPIClient(`, `getQueryKey`, `invalidateQueries`, `setQueryData`, `getPets`; excludes `qraftReactAPIClient(`, `allCallbacks`, `createNodeAPIClient`, `APIClientContext`. +- `barrel-mixed-helper-selection`: includes `qraftAPIClient(`, `qraftReactAPIClient(`, `useQuery`, `getQueryKey`, `BarrelAPIClientContext`. +- All other context/precreated/mixed scenarios pass unchanged. + +If the `node-api-helper-selection` source-map assertion fails (it checks that `qraftAPIClient(` maps back to `src/node-api-helper-selection.ts`), confirm both call sites (zero-arg client and options-based client) appear in the source map. + +- [x] **Step 5: Run the plugin typecheck** + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: no errors. + +- [x] **Step 6: Commit** + +```bash +git add e2e/projects/tree-shaking-bundlers/scripts/shared.mjs +git commit -m "fix(e2e): update node-api-helper-selection assertions after barrel and zero-arg fixes" +``` + +- [x] **Step 7: Optional — run the full e2e suite (slow, publishes to Verdaccio)** + +Only needed to confirm the published package behaves identically to the local dist: + +```bash +cd /Users/radist/WebstormProjects/qraft/e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +Expected: completes without assertion failures. diff --git a/docs/superpowers/plans/2026-05-11-tree-shaking-core-test-deduplication.md b/docs/superpowers/plans/2026-05-11-tree-shaking-core-test-deduplication.md new file mode 100644 index 000000000..0317cdeb3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-tree-shaking-core-test-deduplication.md @@ -0,0 +1,998 @@ +# Tree-Shaking Core Test Deduplication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reduce duplicated `core.test.ts` transform snapshots while adding the missing mixed-client-mode regressions. + +**Architecture:** Keep the cleanup test-only and keep `packages/tree-shaking-plugin/src/core.test.ts` as the only edited test file. First add the missing mixed-mode coverage so deduplication does not remove important cross-mode guarantees. Then merge or shrink overlapping tests by behavioral intent while preserving separate contracts for `createAPIClientFn` context clients, `createAPIClientFn` explicit-options clients, and precreated `apiClient` clients. + +**Tech Stack:** Vitest, inline snapshots, existing fixture helpers in `core.test.ts`, Babel transform snapshots, TypeScript. + +--- + +## File Structure + +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + - Add mixed-mode regression tests before removing duplicates. + - Merge overlapping transform snapshot tests. + - Keep source-map, resolver/moduleAccess, naming collision, and false-positive tests separate. +- Do not modify production transform files. +- Do not modify e2e fixtures. +- Do not split `core.test.ts` in this plan. Reordering/grouping inside the file is allowed only when it keeps the diff readable. + +## Failure Policy + +This plan is a test-suite refactor only. If a newly added or changed test fails because of an inline snapshot mismatch, update or inspect the snapshot as the task says. If a newly added or changed test fails for any other reason, do not fix production code in this plan. Mark that specific test with `it.skip(...)`, leave a short English comment above it explaining the uncovered behavior, and continue the deduplication work. Production fixes for those skipped regressions belong in a later implementation plan. + +## Coverage Map + +Keep these contracts distinct: + +- `createAPIClientFn` context-based: + - zero-arg client can become `qraftReactAPIClient(..., APIClientContext)` for contextful hooks. + - context-free callbacks and schema access can become operation-level imports without runtime context. +- `createAPIClientFn` explicit-options: + - named local clients and inline `createAPIClient(apiContext!)` calls are first-class transform targets. + - explicit-options clients may appear inside React effects, mutation callbacks, and nested scopes. + - top-level/non-React call sites are still a separate transform contract and must stay covered. +- `apiClient` precreated: + - imported precreated clients are resolved through configured client/factory/options metadata. + - precreated clients stay separate from context-generated factories. +- Infrastructure: + - source maps, resolver/moduleAccess behavior, collision-safe naming, partial transforms, and negative controls are not ordinary duplicate snapshots. + +### Task 1: Add Mixed `createAPIClientFn` Variant Coverage + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Add the failing test after `supports two factory functions that share the same generated services`** + +Insert this test immediately after the existing `supports two factory functions that share the same generated services` test: + +```ts + it('supports context-based and explicit-options createAPIClientFn clients in one file', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, APIClientContext } from './api'; +import { useContext, useEffect } from 'react'; + +const api = createAPIClient(); + +export function App() { + const apiContext = useContext(APIClientContext); + + api.pets.getPets.useQuery(); + useEffect(() => { + void createAPIClient(apiContext!).pets.findPetsByStatus.invalidateQueries(); + }, [apiContext]); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(); + }); +``` + +- [ ] **Step 2: Run the focused test and update the inline snapshot** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "supports context-based and explicit-options createAPIClientFn clients in one file" -u +``` + +Expected: PASS and Vitest writes the inline snapshot. + +- [ ] **Step 3: Inspect the generated snapshot** + +Confirm the snapshot includes all of these signals: + +```ts +import { APIClientContext } from './api'; +import { qraftReactAPIClient } from "@openapi-qraft/react"; +import { qraftAPIClient } from "@openapi-qraft/react"; +const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery +}, APIClientContext); +useEffect(() => { + void qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, apiContext!).invalidateQueries(); +}, [apiContext]); +``` + +If either helper path is missing, stop and investigate before continuing. + +- [ ] **Step 4: Run the full core test file** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 1** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test(tree-shaking): cover mixed createAPIClientFn variants" +``` + +### Task 2: Add All-Modes Mixed Coverage + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Add the failing test before `imports an operation directly for a precreated named API client`** + +Insert this test immediately before `imports an operation directly for a precreated named API client`: + +```ts + it('supports createAPIClientFn and precreated apiClient clients in one file', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { + createAPIClient as createContextAPIClient, + ContextAPIClientContext, +} from './context-api'; +import { APIClient } from './precreated-client'; +import { useContext, useEffect } from 'react'; + +const contextApi = createContextAPIClient(); + +export function App() { + const apiContext = useContext(ContextAPIClientContext); + + contextApi.pets.getPets.useQuery(); + useEffect(() => { + void createContextAPIClient(apiContext!).pets.findPetsByStatus.invalidateQueries(); + }, [apiContext]); + APIClient.stores.getStores.useQuery(); +} +`, + sourceFile, + { + createAPIClientFn: [ + { name: 'createAPIClient', module: './context-api' }, + ], + apiClient: [ + { + client: 'APIClient', + clientModule: './precreated-client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './precreated-api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './precreated-client-options', + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(); + }); +``` + +- [ ] **Step 2: Run the focused test and update the inline snapshot** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "supports createAPIClientFn and precreated apiClient clients in one file" -u +``` + +Expected: PASS and Vitest writes the inline snapshot. + +- [ ] **Step 3: Inspect the generated snapshot** + +Confirm the snapshot includes all of these signals: + +```ts +import { ContextAPIClientContext } from './context-api'; +import { qraftAPIClient } from "@openapi-qraft/react"; +import { qraftReactAPIClient } from "@openapi-qraft/react"; +import { getPets } from "./context-api/services/PetsService"; +import { findPetsByStatus } from "./context-api/services/PetsService"; +import { getStores } from "./precreated-api/services/StoresService"; +import { createAPIClientOptions } from "./precreated-client-options"; +const contextApi_pets_getPets = qraftReactAPIClient(getPets, { + useQuery +}, ContextAPIClientContext); +useEffect(() => { + void qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, apiContext!).invalidateQueries(); +}, [apiContext]); +const APIClient_stores_getStores = qraftAPIClient(getStores, { + useQuery +}, createAPIClientOptions()); +``` + +If precreated operations come from `./context-api` or context operations come from `./precreated-api`, stop and investigate before continuing. + +- [ ] **Step 4: Run the full core test file** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 2** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test(tree-shaking): cover mixed createAPIClientFn and apiClient modes" +``` + +### Task 3: Add Additional Mixed-Mode Edge Coverage + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Add a same-operation-through-three-modes test** + +Insert this test near the mixed-mode tests from Tasks 1 and 2: + +```ts + it('keeps same-operation rewrites separate across all client modes', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { + createAPIClient as createContextAPIClient, + ContextAPIClientContext, +} from './context-api'; +import { APIClient } from './precreated-client'; +import { useContext, useEffect } from 'react'; + +const contextApi = createContextAPIClient(); + +export function App() { + const apiContext = useContext(ContextAPIClientContext); + + contextApi.pets.getPets.useQuery(); + useEffect(() => { + void createContextAPIClient(apiContext!).pets.getPets.invalidateQueries(); + }, [apiContext]); + APIClient.pets.getPets.getQueryKey(); +} +`, + sourceFile, + { + createAPIClientFn: [ + { name: 'createAPIClient', module: './context-api' }, + ], + apiClient: [ + { + client: 'APIClient', + clientModule: './precreated-client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './precreated-api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './precreated-client-options', + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(); + }); +``` + +- [ ] **Step 2: Add a top-level mixed modes test** + +Insert this test near the mixed-mode tests: + +```ts + it('supports top-level createAPIClientFn and precreated apiClient clients in one file', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, ContextAPIClientContext } from './context-api'; +import { APIClient } from './precreated-client'; + +const api = createAPIClient(); +const apiContext = ContextAPIClientContext; + +api.pets.getPets.getQueryKey(); +createAPIClient(apiContext!).pets.findPetsByStatus.invalidateQueries(); +APIClient.stores.getStores.getQueryKey(); +`, + sourceFile, + { + createAPIClientFn: [ + { name: 'createAPIClient', module: './context-api' }, + ], + apiClient: [ + { + client: 'APIClient', + clientModule: './precreated-client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './precreated-api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './precreated-client-options', + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(); + }); +``` + +- [ ] **Step 3: Add a partial-transform mixed modes test** + +Insert this test near the existing partial-transform tests: + +```ts + it('keeps original clients independently for partial mixed-mode transforms', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './context-api'; +import { APIClient } from './precreated-client'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +console.log(api); + +APIClient.pets.getPets.useQuery(); +console.log(APIClient); +`, + sourceFile, + { + createAPIClientFn: [ + { name: 'createAPIClient', module: './context-api' }, + ], + apiClient: [ + { + client: 'APIClient', + clientModule: './precreated-client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './precreated-api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './precreated-client-options', + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(); + }); +``` + +- [ ] **Step 4: Add a collision-across-modes test** + +Insert this test near the existing collision tests: + +```ts + it('keeps generated names collision-safe across mixed client modes', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, ContextAPIClientContext } from './context-api'; +import { APIClient } from './precreated-client'; + +const api = createAPIClient(); +const apiContext = ContextAPIClientContext; + +// These bindings intentionally collide with generated names across modes. +const api_pets_getPets = () => null; +const APIClient_pets_getPets = () => null; + +api.pets.getPets.getQueryKey(); +createAPIClient(apiContext!).pets.getPets.invalidateQueries(); +APIClient.pets.getPets.getQueryKey(); +`, + sourceFile, + { + createAPIClientFn: [ + { name: 'createAPIClient', module: './context-api' }, + ], + apiClient: [ + { + client: 'APIClient', + clientModule: './precreated-client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './precreated-api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './precreated-client-options', + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(); + }); +``` + +- [ ] **Step 5: Run the new additional mixed-mode tests and update snapshots** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "keeps same-operation rewrites separate across all client modes|supports top-level createAPIClientFn and precreated apiClient clients in one file|keeps original clients independently for partial mixed-mode transforms|keeps generated names collision-safe across mixed client modes" -u +``` + +Expected: PASS and Vitest writes inline snapshots. + +If any test fails for a reason other than inline snapshot mismatch, apply the Failure Policy: mark only that test as `it.skip(...)`, keep a short English comment above it, and do not change production code. + +- [ ] **Step 6: Inspect the generated snapshots** + +Confirm the snapshots show these behaviors: + +- Same operation names are imported from the correct generated root for each mode. +- Top-level/non-React mixed usage rewrites without relying on React hooks. +- Partial mixed transforms preserve the original `api` and `APIClient` imports/bindings independently. +- Collision handling aliases generated names instead of removing user bindings. + +- [ ] **Step 7: Run and commit** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test(tree-shaking): cover mixed client mode edge cases" +``` + +Expected: Vitest PASS and commit succeeds. If skipped tests were required by the Failure Policy, include them in the commit and mention the skipped behaviors in the commit body. + +### Task 4: Merge Multi-Operation Context Snapshots + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Replace the two multi-operation tests with one combined test** + +Replace these existing tests: + +- `creates separate optimized clients for multiple operations from the same service` +- `creates separate optimized clients for operations from different services` + +with this combined test: + +```ts + it('creates separate optimized clients for multiple operations across services', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +api.pets.createPet.useMutation(); +api.stores.getStores.useQuery(); +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(); + }); +``` + +- [ ] **Step 2: Run the focused test and update the inline snapshot** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "creates separate optimized clients for multiple operations across services" -u +``` + +Expected: PASS and Vitest writes one snapshot containing `api_pets_getPets`, `api_pets_createPet`, and `api_stores_getStores`. + +- [ ] **Step 3: Verify removed test names are gone** + +Run: + +```bash +rg -n "creates separate optimized clients for multiple operations from the same service|creates separate optimized clients for operations from different services" packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: no output. + +- [ ] **Step 4: Run the full core test file** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 4** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test(tree-shaking): merge multi-operation client snapshots" +``` + +### Task 5: Merge Prefix Preservation Snapshots + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Replace named and inline prefix tests with one combined test** + +Replace these existing tests: + +- `preserves void and await prefixes for named client calls` +- `preserves void and await prefixes for inline client calls` + +with this combined top-level/non-React transform test: + +```ts + it('preserves void and await prefixes for named and inline client calls', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, APIClientContext } from './api'; + +const api = createAPIClient(); +const apiContext = APIClientContext; + +async function run() { + void api.pets.findPetsByStatus.invalidateQueries(); + await api.pets.findPetsByStatus.invalidateQueries(); + void createAPIClient(apiContext!).pets.findPetsByStatus.invalidateQueries(); + await createAPIClient(apiContext!).pets.findPetsByStatus.invalidateQueries(); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(); + }); +``` + +- [ ] **Step 2: Run the focused test and update the inline snapshot** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "preserves void and await prefixes for named and inline client calls" -u +``` + +Expected: PASS and Vitest writes one snapshot containing both named optimized calls and inline `qraftAPIClient(...).invalidateQueries()` calls with `void` and `await` in a top-level/non-React scenario. + +- [ ] **Step 3: Verify removed test names are gone** + +Run: + +```bash +rg -n "preserves void and await prefixes for named client calls|preserves void and await prefixes for inline client calls" packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: no output. + +- [ ] **Step 4: Run the full core test file** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 5** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test(tree-shaking): merge prefix preservation snapshots" +``` + +### Task 6: Consolidate Zero-Arg No-Context Callback Coverage + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Keep the context-based zero-arg test as the canonical local-scope regression** + +Find `rewrites context-free callbacks from zero-arg createAPIClient calls`. Keep this test because it proves: + +```ts +void createAPIClient().pets.findPetsByStatus.getQueryKey(); +const utilityClient = createAPIClient(); +void utilityClient.pets.findPetsByStatus.getQueryKey(); +api.pets.findPetsByStatus.getQueryKey(); +``` + +Do not remove this test in this task. + +- [ ] **Step 2: Merge no-context factory variants into one test** + +Replace these tests: + +- `transforms zero-arg no-options callbacks on a no-context factory` +- `transforms both zero-arg no-options and options calls to a no-context factory` + +with one test named: + +```ts + it('transforms zero-arg and options calls to a no-context factory', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...PRECREATED_BASE_FILES, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const apiUtility = createAPIClient(); +const apiWithClient = createAPIClient({ queryClient: {} }); + +apiUtility.pets.getPets.getQueryKey(); +apiWithClient.pets.getPets.invalidateQueries(); +apiWithClient.pets.getPets.setQueryData(undefined, () => undefined); +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(); + }); +``` + +- [ ] **Step 3: Run the focused test and update the inline snapshot** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "transforms zero-arg and options calls to a no-context factory" -u +``` + +Expected: PASS and Vitest writes one snapshot containing `apiUtility_pets_getPets` without options and `apiWithClient_pets_getPets` with `{ queryClient: {} }`. + +- [ ] **Step 4: Verify removed test names are gone** + +Run: + +```bash +rg -n "transforms zero-arg no-options callbacks on a no-context factory|transforms both zero-arg no-options and options calls to a no-context factory" packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: no output. + +- [ ] **Step 5: Run and commit** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test(tree-shaking): consolidate no-context factory snapshots" +``` + +Expected: Vitest PASS and commit succeeds. + +### Task 7: Shrink Explicit-Options Callback Duplication + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Remove the weaker explicit-options callback test if covered by stronger regressions** + +Inspect `optimizes explicit options clients created inside callbacks`. + +Remove it only if these tests are still present after Tasks 1 and 2: + +```bash +rg -n "supports context-based and explicit-options createAPIClientFn clients in one file|splits explicit options clients across sibling callback scopes|optimizes mutation callbacks across onMutate, onError, and onSuccess|aliases generated names for explicit options clients inside nested function scopes" packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: all four names are present. + +Then delete the whole `optimizes explicit options clients created inside callbacks` test block. + +- [ ] **Step 2: Run a focused replacement set** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "supports context-based and explicit-options createAPIClientFn clients in one file|splits explicit options clients across sibling callback scopes|optimizes mutation callbacks across onMutate, onError, and onSuccess|aliases generated names for explicit options clients inside nested function scopes" +``` + +Expected: PASS. + +- [ ] **Step 3: Verify the removed test name is gone** + +Run: + +```bash +rg -n "optimizes explicit options clients created inside callbacks" packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: no output. + +- [ ] **Step 4: Run and commit** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test(tree-shaking): remove duplicate explicit-options callback snapshot" +``` + +Expected: Vitest PASS and commit succeeds. + +### Task 8: Consolidate Precreated Options Import Coverage + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Keep direct and client-module option source tests** + +Keep these tests: + +```ts +it('imports precreated client options from a separate module', ...) +it('imports precreated client options from the same module as the client', ...) +``` + +They cover the two primary option-source contracts. + +- [ ] **Step 2: Remove the re-export duplicate if fixture-relative barrel coverage remains** + +Keep `imports precreated client options from a fixture-relative module` because it protects a distinct barrel import-path case. + +Remove `supports precreated client options re-exported through client.ts` if `imports precreated client options from the same module as the client` still proves importing options from `./client`. + +- [ ] **Step 3: Run focused precreated options tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "imports precreated client options from a separate module|imports precreated client options from a fixture-relative module|imports precreated client options from the same module as the client" +``` + +Expected: PASS. + +- [ ] **Step 4: Verify the removed test name is gone** + +Run: + +```bash +rg -n "supports precreated client options re-exported through client.ts" packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: no output. + +- [ ] **Step 5: Run and commit** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test(tree-shaking): trim duplicate precreated options snapshot" +``` + +Expected: Vitest PASS and commit succeeds. + +### Task 9: Final Verification and Summary + +**Files:** +- Verify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Run package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +``` + +Expected: PASS. + +- [ ] **Step 2: Run package typecheck** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Count test-case names before final summary** + +Run: + +```bash +rg -n "^ it\\(" packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: + +- New mixed-mode tests are present. +- Removed duplicate names are absent. +- Source-map, resolver/moduleAccess, collision, partial-transform, and negative-control tests are still present. +- Any `it.skip(...)` added under the Failure Policy is visible in the final summary. + +- [ ] **Step 4: Inspect final diff range** + +Run: + +```bash +git show --stat --oneline HEAD~8..HEAD +git diff HEAD~8..HEAD -- packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: + +- Only `packages/tree-shaking-plugin/src/core.test.ts` changed across implementation commits. +- The number of duplicate full inline snapshots is lower than before deduplication, even after the added mixed-mode snapshots. +- No production transform code changed. + +## Self-Review + +Spec coverage: + +- The plan implements the original deduplication spec, not only the later mixed-mode addition. +- The plan adds missing coverage before deleting duplicate snapshots, including same-operation, top-level mixed-mode, partial mixed-mode, and collision-across-modes regressions. +- The plan preserves separate contracts for context-based `createAPIClientFn`, explicit-options `createAPIClientFn`, and precreated `apiClient`. +- The plan leaves source-map, resolver/moduleAccess, naming collision, partial transform, and false-positive tests intact. +- The plan states that non-snapshot failures in new or changed tests should be skipped rather than fixed in production code. + +Placeholder scan: + +- No placeholder implementation steps remain. +- Every code-changing step includes exact test code or exact deletion criteria. +- Every verification step includes exact commands and expected results. + +Type consistency: + +- `createFixture(...)`, `getContextFixtureFiles(...)`, `PRECREATED_API_INDEX_TS`, `PRECREATED_BASE_FILES`, `SERVICES_INDEX_TS`, `PETS_SERVICE_TS`, `STORES_SERVICE_TS`, `DEFAULT_PRECREATED_CLIENT_OPTIONS_TS`, `writeFixtureFiles(...)`, `transformQraftTreeShaking(...)`, `fs`, `os`, and `path` already exist in `core.test.ts`. +- The all-modes test uses `ContextAPIClientContext` consistently as the generated context symbol. +- The precreated all-modes config uses `clientModule: './precreated-client'`, `createAPIClientFnModule: './precreated-api'`, and `createAPIClientFnOptionsModule: './precreated-client-options'`, matching the files created in the fixture. +- Task 5 intentionally stays top-level/non-React because React-like mixed-mode coverage is already handled by Tasks 1 and 2. diff --git a/docs/superpowers/plans/2026-05-12-tree-shaking-core-test-refactor.md b/docs/superpowers/plans/2026-05-12-tree-shaking-core-test-refactor.md new file mode 100644 index 000000000..d79662c30 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-tree-shaking-core-test-refactor.md @@ -0,0 +1,1511 @@ +# Tree-Shaking Core Test Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]` / `- [x]`) syntax for tracking. + +**Goal:** Split `packages/tree-shaking-plugin/src/core.test.ts` into focused test files with shared fixtures, then add representative coverage for currently weak callback classes, mixed client modes, context detection, import identity, and unsupported syntax. + +**Architecture:** Keep inline snapshots as the source of truth, but move shared setup into small helper files under `packages/tree-shaking-plugin/src/__tests__/core/`. Execute the work in two phases: first a mechanical split that preserves behavior, then a coverage pass that adds new representative regressions without creating a full Cartesian product. + +**Tech Stack:** TypeScript, Vitest, Babel-generated inline snapshots, existing fixture module access helpers, `@jridgewell/trace-mapping`, `@qraft/test-utils/vitestFsMock`. + +**Current status:** the old `packages/tree-shaking-plugin/src/core.test.ts` file has been deleted after the split. Run focused core transform tests from `packages/tree-shaking-plugin/src/__tests__/core/*.test.ts`. + +--- + +## File Structure + +- Create: `packages/tree-shaking-plugin/src/__tests__/core/harness.ts` + - Owns transform execution, source-map wiring, and fixture-root module access setup. +- Create: `packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts` + - Owns generated API fixture source strings, fixture file builders, resolver/load helpers, and filesystem writer. +- Create: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` + - Context-based and zero-arg `createAPIClientFn` behavior. +- Create: `packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts` + - Explicit-options `createAPIClientFn` behavior. +- Create: `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` + - Configured precreated `apiClient` behavior. +- Create: `packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts` + - Files using more than one client mode. +- Create: `packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts` + - `.schema`, operation import identity, aliasing, and helper import ordering. +- Create: `packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts` + - Resolver and module access behavior. +- Create: `packages/tree-shaking-plugin/src/__tests__/core/unsupported-and-safety.test.ts` + - Negative syntax and partial transform safety behavior. +- Create: `packages/tree-shaking-plugin/src/__tests__/core/source-maps.test.ts` + - Incoming source-map traceability. +- Delete: `packages/tree-shaking-plugin/src/core.test.ts` + - Delete only after all tests have moved and the package test command passes. + +## Existing Test Move Map + +Move tests by title exactly as follows. + +`create-api-client-fn.test.ts`: + +- `collects named and inline usages in one transform plan` +- `imports an operation directly for a context API client` +- `aliases an imported operation when a local binding uses the same name` +- `does not alias a top-level generated client because of an inner scope binding` +- `supports a custom context name from the generated factory import` +- `supports an explicit context module for the generated factory` +- `groups callbacks per operation and imports operationInvokeFn directly` +- `rewrites context-free callbacks from zero-arg createAPIClient calls` +- `transforms factory imported via a barrel when the module config points to the direct file` +- `transforms zero-arg and options calls to a no-context factory` +- `keeps APIClientContext when context-free and contextful callbacks share one client` +- `creates separate optimized clients for multiple operations across services` +- `handles the same operation called via named and inline clients in the same scope` +- `optimizes clients with a single object literal even without known option keys` +- `recognizes a custom factory name imported via a bare module specifier` +- `supports two factory functions that share the same generated services` + +`explicit-options.test.ts`: + +- `splits explicit options clients across sibling callback scopes` +- `optimizes inline explicit options clients` +- `optimizes mutation callbacks across onMutate, onError, and onSuccess` +- `aliases generated names for explicit options clients inside nested function scopes` +- `preserves void and await prefixes for named and inline client calls` + +`precreated-api-client.test.ts`: + +- `imports an operation directly for a precreated named API client` +- `keeps precreated optimized client names collision-safe inside shadowed callbacks` +- `supports a precreated default API client export` +- `imports precreated client options from a separate module` +- `imports precreated client options from a fixture-relative module` +- `imports precreated client options from the same module as the client` +- `skips a precreated client created by a local same-named factory` +- `skips a precreated client when the imported factory module does not match the configured one` +- `skips namespace and dynamic imports of precreated clients` +- `keeps a partially transformed precreated client import` + +`mixed-client-modes.test.ts`: + +- `keeps original clients independently for partial mixed-mode transforms` +- `supports context-based and explicit-options createAPIClientFn clients in one file` +- `keeps same-operation rewrites separate across all client modes` +- `supports top-level createAPIClientFn and precreated apiClient clients in one file` +- `supports createAPIClientFn and precreated apiClient clients in one file` +- `keeps generated names collision-safe across mixed client modes` + +`schema-and-imports.test.ts`: + +- `rewrites schema accesses from context-based and zero-arg createAPIClient calls` +- `rewrites schema accesses from precreated API clients directly to operations` + +`resolution-and-module-access.test.ts`: + +- `uses module access from options by default when creating a transform plan` +- `resolves a factory module through the fixture resolver when the bundler cannot` +- `does not read generated modules from the filesystem when moduleAccess.load returns null` +- `supports a legacy resolver 4th argument together with module access load options` +- `prefers module access resolve from options over a conflicting legacy resolver 4th argument` +- `does not match a same-named import that resolves to a different module` +- `returns null when the specifier cannot be resolved` +- `skips when createAPIClientFn is empty` + +`unsupported-and-safety.test.ts`: + +- `keeps the original client when an unsupported reference remains` +- `skips exported clients` + +`source-maps.test.ts`: + +- `keeps a rewritten user call site traceable through an incoming source map` + +## Task 1: Create Shared Test Helpers + +**Files:** +- Create: `packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts` +- Create: `packages/tree-shaking-plugin/src/__tests__/core/harness.ts` +- Read: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Create the test helper directory** + +Run: + +```bash +mkdir -p packages/tree-shaking-plugin/src/__tests__/core +``` + +Expected: directory exists. + +- [x] **Step 2: Add fixture source constants and builders** + +Create `packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts` with this structure. Copy the exact source strings and helper bodies from `packages/tree-shaking-plugin/src/core.test.ts`, then export them. + +```ts +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { QraftModuleAccess } from '../../lib/resolvers/common.js'; + +export const PRECREATED_API_INDEX_TS = ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; +import { services } from './services/index'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(options?: { queryClient: unknown }) { + return qraftAPIClient(services, defaultCallbacks, options); +} +`; + +export const SERVICES_INDEX_TS = ` +import { petsService } from './PetsService'; +import { storesService } from './StoresService'; + +export const services = { + pets: petsService, + stores: storesService, +} as const; +`; + +export const PETS_SERVICE_TS = ` +export const getPets = { schema: { method: 'get', url: '/pets' } }; +export const createPet = { schema: { method: 'post', url: '/pets' } }; +export const updatePet = { schema: { method: 'put', url: '/pets/{petId}' } }; +export const getPetById = { schema: { method: 'get', url: '/pets/{petId}' } }; +export const findPetsByStatus = { schema: { method: 'get', url: '/pets/findByStatus' } }; + +export const petsService = { + getPets, + createPet, + updatePet, + getPetById, + findPetsByStatus, +} as const; +`; + +export const STORES_SERVICE_TS = ` +export const getStores = { schema: { method: 'get', url: '/stores' } }; + +export const storesService = { + getStores, +} as const; +`; + +export const DEFAULT_PRECREATED_CLIENT_OPTIONS_TS = ` +export const createAPIClientOptions = () => ({ + queryClient: {} +}); +`; + +export function getContextFixtureFiles( + contextName: string, + contextModule: string, + importContext: boolean, + apiDirName = 'api' +) { + const apiRoot = `src/${apiDirName}`; + + return { + [`${apiRoot}/index.ts`]: `${importContext ? `import { ${contextName} } from '${contextModule}';\n` : ''}${contextApiIndexTsBody(contextName)}`, + [`${apiRoot}/${contextName}.ts`]: `\nexport const ${contextName} = {};\n`, + [`${apiRoot}/services/index.ts`]: SERVICES_INDEX_TS, + [`${apiRoot}/services/PetsService.ts`]: PETS_SERVICE_TS, + [`${apiRoot}/services/StoresService.ts`]: STORES_SERVICE_TS, + } as const; +} + +export function contextApiIndexTsBody(contextName: string) { + return ` +import { qraftReactAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; +import { services } from './services/index'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(callbacks = defaultCallbacks) { + return qraftReactAPIClient(services, callbacks, ${contextName}); +} +export function createExtraAPIClient(callbacks = defaultCallbacks) { + return qraftReactAPIClient(services, callbacks, ${contextName}); +} +`; +} + +export const PRECREATED_BASE_FILES = { + 'src/api/index.ts': PRECREATED_API_INDEX_TS, + 'src/api/services/index.ts': SERVICES_INDEX_TS, + 'src/api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, +} as const; + +export function createPrecreatedFixtureFiles( + clientTs: string, + extraFiles: Record = {} +) { + return { + ...PRECREATED_BASE_FILES, + 'src/client.ts': clientTs, + ...extraFiles, + } as const; +} + +export async function writeFixtureFiles( + root: string, + files: Record +) { + for (const [relativePath, content] of Object.entries(files)) { + const fullPath = path.join(root, relativePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content); + } +} + +export async function createFixtureModuleAccess( + fixtureRoot: string, + overrides: Partial = {} +): Promise { + return { + resolve: + overrides.resolve ?? + (async (specifier, importer) => + resolveFixtureModule(fixtureRoot, specifier, importer)), + load: + overrides.load ?? + (async (id) => { + try { + return await fs.readFile(id, 'utf8'); + } catch { + return null; + } + }), + }; +} + +export async function resolveFixtureModule( + fixtureRoot: string, + specifier: string, + importer: string +) { + if (!specifier.startsWith('.') && !specifier.startsWith('/')) { + return null; + } + + const candidateBase = specifier.startsWith('/') + ? specifier + : path.resolve(path.dirname(importer), specifier); + + const candidates = [ + candidateBase, + `${candidateBase}.ts`, + `${candidateBase}.tsx`, + `${candidateBase}.js`, + `${candidateBase}.jsx`, + `${candidateBase}.mts`, + `${candidateBase}.cts`, + path.join(candidateBase, 'index.ts'), + path.join(candidateBase, 'index.tsx'), + path.join(candidateBase, 'index.js'), + path.join(candidateBase, 'index.jsx'), + path.join(candidateBase, 'index.mts'), + path.join(candidateBase, 'index.cts'), + ]; + + for (const candidate of candidates) { + if (!candidate.startsWith(fixtureRoot)) continue; + + try { + const stat = await fs.stat(candidate); + if (stat.isFile()) return candidate; + } catch { + // Try the next candidate. + } + } + + return null; +} +``` + +If the copied helper from `core.test.ts` currently has synchronous return type for `createFixtureModuleAccess`, keep the original sync shape instead of forcing async. The important contract is that existing tests can import it without behavioral changes. + +- [x] **Step 3: Add transform harness** + +Create `packages/tree-shaking-plugin/src/__tests__/core/harness.ts`: + +```ts +import '@qraft/test-utils/vitestFsMock'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { SourceMapInput } from '@jridgewell/trace-mapping'; +import { transformQraftTreeShaking as transformQraftTreeShakingImpl } from '../../core.js'; +import { createTransformPlan } from '../../lib/transform/plan.js'; +import { + createFixtureModuleAccess, + getContextFixtureFiles, + writeFixtureFiles, +} from './fixtures.js'; + +export type TransformOptions = Parameters[2]; + +type FixtureOptions = { + contextName?: string; + contextModule?: string; + importContext?: boolean; + apiDirName?: string; +}; + +export async function transformQraftTreeShaking( + code: string, + id: string, + options: TransformOptions, + inputSourceMap?: SourceMapInput +) { + const fixtureRoot = getFixtureRootFromSourceFile(id); + const moduleAccess = createFixtureModuleAccess(fixtureRoot, { + resolve: options.moduleAccess?.resolve ?? options.resolve, + }); + + if (options.moduleAccess?.load) { + return transformQraftTreeShakingImpl( + code, + id, + options, + { + ...moduleAccess, + load: options.moduleAccess.load, + }, + inputSourceMap + ); + } + + return transformQraftTreeShakingImpl( + code, + id, + options, + moduleAccess, + inputSourceMap + ); +} + +export async function createFixture(options: FixtureOptions = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')); + const contextName = options.contextName ?? 'APIClientContext'; + const contextModule = options.contextModule ?? `./${contextName}`; + const importContext = options.importContext ?? true; + + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + contextName, + contextModule, + importContext, + options.apiDirName + ), + }); + + return root; +} + +function getFixtureRootFromSourceFile(id: string) { + const normalizedPath = path.normalize(id); + const parts = normalizedPath.split(path.sep); + const srcIndex = parts.lastIndexOf('src'); + + if (srcIndex > 0) { + const fixtureRoot = parts.slice(0, srcIndex).join(path.sep); + if (fixtureRoot) { + return fixtureRoot; + } + } + + return path.dirname(path.dirname(id)); +} + +export { createTransformPlan }; +``` + +This helper intentionally detects the fixture root by the `src` path segment before falling back to the legacy two-directory behavior. Later moved tests should compute source files with `path.join(fixture, 'src/App.tsx')` or a nested path under `src/**`. + +- [x] **Step 4: Run typecheck for helper compile errors** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: PASS. If it fails because a copied helper signature differs from current `core.test.ts`, align the new helper with the current code before continuing. + +- [x] **Step 5: Commit shared helpers** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/__tests__/core +git commit -m "test(tree-shaking): add core transform test helpers" +``` + +## Task 2: Move Context and CreateAPIClientFn Tests + +**Files:** +- Create: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/harness.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts` + +- [x] **Step 1: Create the destination test file with imports** + +Create `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { + createFixture, + createTransformPlan, + transformQraftTreeShaking, +} from './harness.js'; +import { + getContextFixtureFiles, + writeFixtureFiles, +} from './fixtures.js'; +``` + +Add imports only when the moved tests require them. Keep imports explicit and remove unused imports before committing. + +- [x] **Step 2: Move the plan-introspection test** + +Move `collects named and inline usages in one transform plan` from `core.test.ts` into this file under: + +```ts +describe('transformQraftTreeShaking createAPIClientFn clients', () => { + it('collects named and inline usages in one transform plan', async () => { + // moved body + }); +}); +``` + +Update helper calls to use `createFixture(...)` and exported fixture module access. Preserve the same assertions. + +- [x] **Step 3: Move zero-arg context and factory import tests** + +Move these tests into the same describe block: + +- `imports an operation directly for a context API client` +- `aliases an imported operation when a local binding uses the same name` +- `does not alias a top-level generated client because of an inner scope binding` +- `supports a custom context name from the generated factory import` +- `supports an explicit context module for the generated factory` +- `groups callbacks per operation and imports operationInvokeFn directly` +- `rewrites context-free callbacks from zero-arg createAPIClient calls` +- `transforms factory imported via a barrel when the module config points to the direct file` +- `transforms zero-arg and options calls to a no-context factory` +- `keeps APIClientContext when context-free and contextful callbacks share one client` +- `creates separate optimized clients for multiple operations across services` +- `handles the same operation called via named and inline clients in the same scope` +- `optimizes clients with a single object literal even without known option keys` +- `recognizes a custom factory name imported via a bare module specifier` +- `supports two factory functions that share the same generated services` + +Remove each moved test from `core.test.ts` in the same edit so it does not run twice. + +- [x] **Step 4: Apply naming cleanup while moving** + +Only inside moved fixture source strings, rename misleading options-like values. For example: + +```ts +const apiOptions = { queryClient: {} }; +createAPIClient(apiOptions).pets.findPetsByStatus.invalidateQueries(); +``` + +Do not rename intentionally collision-sensitive values such as `api_pets_getPets` unless the snapshot is not testing that collision. + +- [x] **Step 5: Run the moved file** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/__tests__/core/create-api-client-fn.test.ts +``` + +Expected: PASS. + +If snapshots fail only because import order or fixture naming changed intentionally, run the same command with `-u` and inspect the snapshot before committing: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/__tests__/core/create-api-client-fn.test.ts -u +``` + +- [x] **Step 6: Run full package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +``` + +Expected: PASS. + +- [x] **Step 7: Commit context/createAPIClientFn split** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts packages/tree-shaking-plugin/src/__tests__/core/harness.ts packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts +git commit -m "test(tree-shaking): split createAPIClientFn core tests" +``` + +## Task 3: Move Explicit-Options Tests + +**Files:** +- Create: `packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts` + +- [x] **Step 1: Create explicit-options test file** + +Create `packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { transformQraftTreeShaking } from './harness.js'; +import { getContextFixtureFiles, writeFixtureFiles } from './fixtures.js'; + +describe('transformQraftTreeShaking explicit options clients', () => { +}); +``` + +- [x] **Step 2: Move explicit-options tests** + +Move these tests from `core.test.ts` into the describe block and remove them from `core.test.ts`: + +- `splits explicit options clients across sibling callback scopes` +- `optimizes inline explicit options clients` +- `optimizes mutation callbacks across onMutate, onError, and onSuccess` +- `aliases generated names for explicit options clients inside nested function scopes` +- `preserves void and await prefixes for named and inline client calls` + +- [x] **Step 3: Clean options/context variable names** + +While moving, replace misleading `apiContext` names that are actually options objects with `apiOptions` or `queryClientOptions`. + +Keep this React context shape where the value comes from `useContext(...)`: + +```ts +const apiContext = useContext(APIClientContext); +``` + +Keep mutation fixtures realistic with `onMutate`, `onError`, and `onSuccess` where already present. + +- [x] **Step 4: Run the explicit-options file** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/__tests__/core/explicit-options.test.ts +``` + +Expected: PASS. Use `-u` only for inspected snapshot changes caused by naming cleanup. + +- [x] **Step 5: Run full package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +``` + +Expected: PASS. + +- [x] **Step 6: Commit explicit-options split** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts +git commit -m "test(tree-shaking): split explicit options core tests" +``` + +## Task 4: Move Precreated API Client Tests + +**Files:** +- Create: `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts` + +- [x] **Step 1: Create precreated test file** + +Create `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { transformQraftTreeShaking } from './harness.js'; +import { + DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + createPrecreatedFixtureFiles, + writeFixtureFiles, +} from './fixtures.js'; + +describe('transformQraftTreeShaking precreated apiClient clients', () => { +}); +``` + +- [x] **Step 2: Move precreated tests** + +Move these tests into the describe block and remove them from `core.test.ts`: + +- `imports an operation directly for a precreated named API client` +- `keeps precreated optimized client names collision-safe inside shadowed callbacks` +- `supports a precreated default API client export` +- `imports precreated client options from a separate module` +- `imports precreated client options from a fixture-relative module` +- `imports precreated client options from the same module as the client` +- `skips a precreated client created by a local same-named factory` +- `skips a precreated client when the imported factory module does not match the configured one` +- `skips namespace and dynamic imports of precreated clients` +- `keeps a partially transformed precreated client import` + +- [x] **Step 3: Preserve intentional collision comments** + +Keep existing English comments that explain shadowing or collision intent. If a moved fixture has intentionally strange local names, add this kind of short comment in the source string: + +```ts +// These locals intentionally shadow the generated optimized client name. +``` + +- [x] **Step 4: Run precreated test file** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/__tests__/core/precreated-api-client.test.ts +``` + +Expected: PASS. + +- [x] **Step 5: Run full package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +``` + +Expected: PASS. + +- [x] **Step 6: Commit precreated split** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts +git commit -m "test(tree-shaking): split precreated apiClient core tests" +``` + +## Task 5: Move Mixed-Mode Tests + +**Files:** +- Create: `packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts` + +- [x] **Step 1: Create mixed-mode test file** + +Create `packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { transformQraftTreeShaking } from './harness.js'; +import { + DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + PRECREATED_API_INDEX_TS, + PETS_SERVICE_TS, + SERVICES_INDEX_TS, + STORES_SERVICE_TS, + getContextFixtureFiles, + writeFixtureFiles, +} from './fixtures.js'; + +describe('transformQraftTreeShaking mixed client modes', () => { +}); +``` + +- [x] **Step 2: Move mixed-mode tests** + +Move these tests into the describe block and remove them from `core.test.ts`: + +- `keeps original clients independently for partial mixed-mode transforms` +- `supports context-based and explicit-options createAPIClientFn clients in one file` +- `keeps same-operation rewrites separate across all client modes` +- `supports top-level createAPIClientFn and precreated apiClient clients in one file` +- `supports createAPIClientFn and precreated apiClient clients in one file` +- `keeps generated names collision-safe across mixed client modes` + +- [x] **Step 3: Enforce realistic mixed-mode snippets** + +For React-like context usage, preserve this shape: + +```ts +const apiContext = useContext(ContextAPIClientContext); + +useEffect(() => { + void createAPIClient(apiContext!).pets.getPets.invalidateQueries(); +}, [apiContext]); +``` + +For top-level cases, keep top-level calls only where the title explicitly covers top-level behavior. + +- [x] **Step 4: Run mixed-mode test file** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/__tests__/core/mixed-client-modes.test.ts +``` + +Expected: PASS. + +- [x] **Step 5: Run full package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +``` + +Expected: PASS. + +- [x] **Step 6: Commit mixed-mode split** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts +git commit -m "test(tree-shaking): split mixed client mode tests" +``` + +## Task 6: Move Schema, Resolution, Safety, and Source-Map Tests + +**Files:** +- Create: `packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts` +- Create: `packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts` +- Create: `packages/tree-shaking-plugin/src/__tests__/core/unsupported-and-safety.test.ts` +- Create: `packages/tree-shaking-plugin/src/__tests__/core/source-maps.test.ts` +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` +- Modify: helper files under `packages/tree-shaking-plugin/src/__tests__/core/` + +- [x] **Step 1: Create schema/import test file** + +Create `packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts` and move: + +- `rewrites schema accesses from context-based and zero-arg createAPIClient calls` +- `rewrites schema accesses from precreated API clients directly to operations` + +Use imports from `harness.ts` and `fixtures.ts`. Remove the moved tests from `core.test.ts`. + +- [x] **Step 2: Create resolution/module-access test file** + +Create `packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts` and move: + +- `uses module access from options by default when creating a transform plan` +- `resolves a factory module through the fixture resolver when the bundler cannot` +- `does not read generated modules from the filesystem when moduleAccess.load returns null` +- `supports a legacy resolver 4th argument together with module access load options` +- `prefers module access resolve from options over a conflicting legacy resolver 4th argument` +- `does not match a same-named import that resolves to a different module` +- `returns null when the specifier cannot be resolved` +- `skips when createAPIClientFn is empty` + +Import `vi` from `vitest` if the moved tests still use spies. + +- [x] **Step 3: Create unsupported/safety test file** + +Create `packages/tree-shaking-plugin/src/__tests__/core/unsupported-and-safety.test.ts` and move: + +- `keeps the original client when an unsupported reference remains` +- `skips exported clients` + +Add the negative syntax coverage from Task 8 later. This task only moves existing tests. + +- [x] **Step 4: Create source-map test file** + +Create `packages/tree-shaking-plugin/src/__tests__/core/source-maps.test.ts` and move: + +- `keeps a rewritten user call site traceable through an incoming source map` + +Move the `TraceMap` / `originalPositionFor` imports from `core.test.ts` into this file. + +- [x] **Step 5: Run the four moved files** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run \ + src/__tests__/core/schema-and-imports.test.ts \ + src/__tests__/core/resolution-and-module-access.test.ts \ + src/__tests__/core/unsupported-and-safety.test.ts \ + src/__tests__/core/source-maps.test.ts +``` + +Expected: PASS. + +- [x] **Step 6: Run full package tests and typecheck** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both PASS. + +- [x] **Step 7: Commit final existing-test split** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/src/__tests__/core +git commit -m "test(tree-shaking): split remaining core transform tests" +``` + +## Task 7: Delete the Old Core Test File + +**Files:** +- Delete: `packages/tree-shaking-plugin/src/core.test.ts` + +- [x] **Step 1: Verify no tests remain in core.test.ts** + +Run: + +```bash +test ! -e packages/tree-shaking-plugin/src/core.test.ts || rg -n "^ it\\(" packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: exit code `0`, or no `it(...)` output if the file still exists during migration. If output remains, move those tests to the correct file before continuing. + +- [x] **Step 2: Delete the old file** + +Run: + +```bash +rm packages/tree-shaking-plugin/src/core.test.ts +``` + +- [x] **Step 3: Verify no imports point to core.test.ts** + +Run: + +```bash +rg -n "core\\.test" packages/tree-shaking-plugin/src package.json packages/tree-shaking-plugin +``` + +Expected: no required runtime/test import references. Documentation references are acceptable only if they describe historical commits; otherwise update them. + +- [x] **Step 4: Run full package verification** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both PASS. + +- [x] **Step 5: Commit file deletion** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts packages/tree-shaking-plugin/src/__tests__/core +git commit -m "test(tree-shaking): remove monolithic core test file" +``` + +## Task 8: Add Callback-Class Coverage + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts` + +- [x] **Step 1: Add context-client suspense and infinite hook coverage** + +In `create-api-client-fn.test.ts`, add a test titled: + +```ts +it('rewrites representative suspense and infinite hook callbacks for context clients', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const reactApi = createAPIClient(); + +export function App() { + reactApi.pets.getPets.useSuspenseQuery(); + reactApi.pets.findPetsByStatus.useInfiniteQuery(); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(); +}); +``` + +Run with `-u` after confirming the output uses `qraftReactAPIClient` and imports `useSuspenseQuery` and `useInfiniteQuery` from their callback modules. + +- [x] **Step 2: Add explicit-options fetch/prefetch/ensure coverage** + +In `explicit-options.test.ts`, add a test titled: + +```ts +it('rewrites fetch, prefetch, and ensure callbacks for explicit options clients', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const queryClientOptions = { queryClient: {} }; +const optionsApi = createAPIClient(queryClientOptions); + +async function loadPets() { + await optionsApi.pets.getPets.fetchQuery(); + await optionsApi.pets.findPetsByStatus.prefetchQuery(); + return optionsApi.pets.getPetById.ensureQueryData({ parameters: { petId: 1 } }); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(); +}); +``` + +Run with `-u` after confirming the output uses `qraftAPIClient` with `queryClientOptions` and imports `fetchQuery`, `prefetchQuery`, and `ensureQueryData`. + +- [x] **Step 3: Add precreated global state callback coverage** + +In `precreated-api-client.test.ts`, add a test titled: + +```ts +it('rewrites query-client state callbacks for precreated clients', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + await writeFixtureFiles( + fixture, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +APIClient.pets.getPets.getQueryState(); +APIClient.pets.getPets.isFetching(); +APIClient.pets.updatePet.isMutating(); +`, + sourceFile, + { + apiClient: [ + { + client: 'APIClient', + clientModule: './client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './client-options', + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(); +}); +``` + +Run with `-u` after confirming the output imports `getQueryState`, `isFetching`, and `isMutating`. + +- [x] **Step 4: Add mixed-mode callback-class coverage** + +In `mixed-client-modes.test.ts`, add a test titled: + +```ts +it('keeps callback-class rewrites separate across context and precreated modes', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')); + await writeFixtureFiles(root, { + ...getContextFixtureFiles('ContextAPIClientContext', './ContextAPIClientContext', true, 'context-api'), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, ContextAPIClientContext } from './context-api'; +import { APIClient } from './precreated-client'; +import { useContext, useEffect } from 'react'; + +const reactApi = createAPIClient(); + +export function App() { + const apiContext = useContext(ContextAPIClientContext); + reactApi.pets.getPets.useSuspenseQuery(); + useEffect(() => { + void createAPIClient(apiContext!).pets.findPetsByStatus.fetchQuery(); + }, [apiContext]); + APIClient.pets.getPets.getInfiniteQueryKey(); +} +`, + sourceFile, + { + createAPIClientFn: [{ name: 'createAPIClient', module: './context-api' }], + apiClient: [ + { + client: 'APIClient', + clientModule: './precreated-client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './precreated-api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './precreated-client-options', + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(); +}); +``` + +Run with `-u` after confirming context and precreated imports remain source-separated. + +- [x] **Step 5: Run callback coverage tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run \ + src/__tests__/core/create-api-client-fn.test.ts \ + src/__tests__/core/explicit-options.test.ts \ + src/__tests__/core/precreated-api-client.test.ts \ + src/__tests__/core/mixed-client-modes.test.ts +``` + +Expected: PASS. + +- [x] **Step 6: Run full verification** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both PASS. + +- [x] **Step 7: Commit callback coverage** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/__tests__/core +git commit -m "test(tree-shaking): cover representative callback classes" +``` + +## Task 9: Add Unsupported Syntax and Safety Coverage + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/unsupported-and-safety.test.ts` + +- [x] **Step 1: Add computed property safety test** + +Add: + +```ts +it('does not rewrite computed member access', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); +const serviceName = 'pets'; + +api[serviceName].getPets.useQuery(); +api.pets['getPets'].useQuery(); +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { createAPIClient } from './api'; + const api = createAPIClient(); + const serviceName = 'pets'; + api[serviceName].getPets.useQuery(); + api.pets['getPets'].useQuery();" + `); +}); +``` + +If Babel prints quote style differently, update the inline snapshot after confirming the source remains untransformed. + +- [x] **Step 2: Add destructuring alias safety test** + +Add: + +```ts +it('does not rewrite destructured client aliases', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); +const { pets } = api; + +pets.getPets.useQuery(); +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toContain('pets.getPets.useQuery()'); + expect(result?.code).toContain('const api = createAPIClient();'); +}); +``` + +This should remain a partial/no transform for the destructured call because it no longer has the static `client.service.operation.callback` shape. + +- [x] **Step 3: Add optional chaining behavior test** + +Add: + +```ts +it('rewrites static optional member chains when the client binding is clear', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api?.pets?.getPets?.useQuery(); +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(); +}); +``` + +Run this test before updating the snapshot. If it exposes a production bug in optional-chain rewriting, fix production only if the change is local to static member path handling. If not local, record a follow-up and keep the test active only when the team wants to fix it immediately. + +- [x] **Step 4: Run unsupported safety tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/__tests__/core/unsupported-and-safety.test.ts +``` + +Expected: PASS. + +- [x] **Step 5: Run full verification** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both PASS. + +- [x] **Step 6: Commit unsupported syntax coverage** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/__tests__/core/unsupported-and-safety.test.ts +git commit -m "test(tree-shaking): cover unsupported member syntax" +``` + +## Task 10: Add Context Detection and Import Identity Regressions + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts` + +- [x] **Step 1: Add aliased context import detection regression** + +In `create-api-client-fn.test.ts`, add: + +```ts +it('infers an aliased generated context from the qraftReactAPIClient third argument', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + await writeFixtureFiles(fixture, { + 'src/api/index.ts': ` +import { qraftReactAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; +import { APIClientContext as InternalContext } from './APIClientContext'; +import { services } from './services/index'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(callbacks = defaultCallbacks) { + return qraftReactAPIClient(services, callbacks, InternalContext); +} +`, + ...getContextFixtureFiles('APIClientContext', './APIClientContext', false), + }); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(); +}); +``` + +The expected output must import `InternalContext` or an alias-safe context binding from `./api/APIClientContext` and use `qraftReactAPIClient`. + +- [x] **Step 2: Add same operation import identity regression for schema** + +In `schema-and-imports.test.ts`, add: + +```ts +it('aliases same-named schema operation imports from different generated roots', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')); + await writeFixtureFiles(root, { + ...getContextFixtureFiles('ContextAPIClientContext', './ContextAPIClientContext', true, 'context-api'), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './context-api'; +import { APIClient } from './precreated-client'; + +const contextApi = createAPIClient(); + +contextApi.pets.getPets.schema; +APIClient.pets.getPets.schema; +`, + sourceFile, + { + createAPIClientFn: [{ name: 'createAPIClient', module: './context-api' }], + apiClient: [ + { + client: 'APIClient', + clientModule: './precreated-client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './precreated-api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './precreated-client-options', + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(); +}); +``` + +The expected output must import both `getPets` operations with an alias for one of them. + +- [x] **Step 3: Run focused context/import identity tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run \ + src/__tests__/core/create-api-client-fn.test.ts \ + src/__tests__/core/schema-and-imports.test.ts \ + src/__tests__/core/mixed-client-modes.test.ts +``` + +Expected: PASS. + +- [x] **Step 4: Run full verification** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both PASS. + +- [x] **Step 5: Commit context/import identity regressions** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/__tests__/core +git commit -m "test(tree-shaking): cover context detection and import identity" +``` + +## Task 11: Final Suite Audit + +**Files:** +- Verify: `packages/tree-shaking-plugin/src/__tests__/core/*.ts` +- Verify: `packages/tree-shaking-plugin/src/core.test.ts` +- Verify: `packages/tree-shaking-plugin/src/lib/transform/callbacks.ts` + +- [x] **Step 1: Verify there is no monolithic test file** + +Run: + +```bash +test ! -e packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: exit code `0`. + +- [x] **Step 2: Verify skipped tests are policy-approved** + +Run: + +```bash +rg -n "it\\.skip|describe\\.skip" packages/tree-shaking-plugin/src/__tests__/core +``` + +Expected: only the policy-approved mixed-root schema import identity skip, or no output. + +- [x] **Step 3: Verify callback coverage improved** + +Run: + +```bash +node - <<'NODE' +const fs = require('fs'); +const callbacks = fs.readFileSync('packages/tree-shaking-plugin/src/lib/transform/callbacks.ts', 'utf8'); +const tests = fs.readdirSync('packages/tree-shaking-plugin/src/__tests__/core') + .filter((file) => file.endsWith('.test.ts')) + .map((file) => fs.readFileSync(`packages/tree-shaking-plugin/src/__tests__/core/${file}`, 'utf8')) + .join('\n'); +const names = [...callbacks.matchAll(/^ (\\w+): /gm)].map((match) => match[1]); +for (const name of names) { + const count = [...tests.matchAll(new RegExp(`\\\\b${name}\\\\b`, 'g'))].length; + console.log(`${name}: ${count}`); +} +NODE +``` + +Expected: the callbacks added in Task 8 have non-zero counts. + +- [x] **Step 4: Run final verification** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both PASS. + +- [x] **Step 5: Inspect diff summary** + +Run: + +```bash +git diff --stat HEAD~10..HEAD -- packages/tree-shaking-plugin/src/__tests__/core packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: + +- old `core.test.ts` deleted; +- new focused test files created; +- helper files created; +- no production transform files changed unless a new regression required a narrow production fix. + +- [x] **Step 6: Commit final audit fixes when cleanup edits exist** + +When Step 1 through Step 5 required small cleanup edits, commit them: + +```bash +git add packages/tree-shaking-plugin/src/__tests__/core packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test(tree-shaking): finalize core test suite split" +``` + +When no files changed, do not create an empty commit. + +## Self-Review + +Spec coverage: + +- Target file structure is implemented by Tasks 1 through 7. +- Shared helpers are implemented by Task 1. +- Existing test groups are moved by Tasks 2 through 6. +- Old `core.test.ts` removal is covered by Task 7. +- Callback-class coverage is covered by Task 8. +- Unsupported syntax coverage is covered by Task 9. +- Context detection and operation import identity coverage is covered by Task 10. +- Verification is covered by Task 11; the only remaining skip is the documented mixed-root schema import identity production gap accepted by policy. + +Placeholder scan: + +- No placeholder markers or open-ended implementation placeholders are intentionally left in the plan. +- Every task names exact files and exact commands. +- New test skeletons include concrete source snippets and expected verification behavior. + +Type consistency: + +- Helper imports use `.js` specifiers, matching existing ESM TypeScript style in the package. +- `TransformOptions` is derived from the production transform signature. +- Fixture helper names match the design spec and current `core.test.ts` helper names. diff --git a/docs/superpowers/plans/2026-05-12-tree-shaking-mixed-client-identity.md b/docs/superpowers/plans/2026-05-12-tree-shaking-mixed-client-identity.md new file mode 100644 index 000000000..4a950c939 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-tree-shaking-mixed-client-identity.md @@ -0,0 +1,476 @@ +# Tree-Shaking Mixed Client Identity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the two skipped mixed-mode regressions pass by giving planner and mutator keys a source-aware client identity. + +**Architecture:** Add a stable `clientSourceKey` to discovered client bindings and reuse it in operation grouping, usage lookup, local optimized client naming, and mutator lookup paths. This keeps same-named operations from different generated roots independent without redesigning the transform pipeline. Then unskip the two mixed-mode tests and let their existing future snapshots become the active contract. + +**Tech Stack:** TypeScript, Babel AST/traverse/types, Vitest inline snapshots, existing `@openapi-qraft/tree-shaking-plugin` workspace commands. + +--- + +## File Structure + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` + - Add `clientSourceKey` to `ClientBinding`. +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` + - Create a source-aware key helper. + - Assign `clientSourceKey` for context, options, and precreated clients. + - Use `clientSourceKey` in operation grouping and usage lookup keys. +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` + - Use `clientSourceKey` in named-call rewrite lookup and scope-split detection. +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + - Unskip the two mixed-mode regressions. + - Refresh inline snapshots only where the implementation changes equivalent formatting or UID aliases. + +Do not change public plugin options, generated API fixtures, e2e projects, or callback metadata. + +### Task 1: Add Client Source Identity to Types and Planner + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Test: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Add `clientSourceKey` to `ClientBinding`** + +In `packages/tree-shaking-plugin/src/lib/transform/types.ts`, update `ClientBinding`: + +```ts +export type ClientBinding = { + name: string; + clientSourceKey: string; + createImportPath: string; + factory: QraftFactoryConfig; + bindingNode: t.Node; + declarationScope: Scope; + localInitPath?: import('@babel/traverse').NodePath; + mode: + | { type: 'context' } + | { type: 'options'; optionsExpression: t.Expression } + | { + type: 'precreated'; + optionsImportPath: string; + optionsExportName: string; + }; +}; +``` + +- [ ] **Step 2: Add a helper in `plan.ts`** + +Near the existing `getGeneratedInfoKey(...)` helper in `packages/tree-shaking-plugin/src/lib/transform/plan.ts`, add: + +```ts +function getClientSourceKey( + createImportPath: string, + factory: QraftFactoryConfig, + mode: ClientBinding['mode'] +) { + const generatedInfoKey = getGeneratedInfoKey(createImportPath, factory); + + if (mode.type === 'precreated') { + return [ + 'precreated', + generatedInfoKey, + mode.optionsImportPath, + mode.optionsExportName, + ].join('::'); + } + + return [mode.type, generatedInfoKey].join('::'); +} +``` + +- [ ] **Step 3: Populate `clientSourceKey` for context clients** + +In the zero-argument `clients.push(...)` branch in `createTransformPlan(...)`, create the mode object once and pass it into the helper: + +```ts +const mode = { type: 'context' } as const; +clients.push({ + name: variablePath.node.id.name, + clientSourceKey: getClientSourceKey(createImportPath, createImport.factory, mode), + createImportPath, + factory: createImport.factory, + bindingNode: variablePath.node.id, + declarationScope: variablePath.parentPath.scope, + localInitPath: variablePath, + mode, +}); +``` + +- [ ] **Step 4: Populate `clientSourceKey` for explicit-options clients** + +In the one-expression argument branch, create the mode object once: + +```ts +const mode = { + type: 'options', + optionsExpression: t.cloneNode(args[0], true), +} as const; +clients.push({ + name: variablePath.node.id.name, + clientSourceKey: getClientSourceKey(createImportPath, createImport.factory, mode), + createImportPath, + factory: createImport.factory, + bindingNode: variablePath.node.id, + declarationScope: variablePath.parentPath.scope, + localInitPath: variablePath, + mode, +}); +``` + +- [ ] **Step 5: Populate `clientSourceKey` for precreated clients** + +In `findPrecreatedClients(...)`, where the precreated `ClientBinding` is returned, create the precreated mode object once and include `clientSourceKey`: + +```ts +const mode = { + type: 'precreated', + optionsImportPath: resolvePrecreatedOptionsImportPath( + id, + optionsSourceFile, + match.config.createAPIClientFnOptionsModule ?? match.config.clientModule + ), + optionsExportName: match.config.createAPIClientFnOptions, +} as const; + +return { + name: match.localName, + clientSourceKey: getClientSourceKey(factoryFile, factoryConfig, mode), + createImportPath: factoryFile, + factory: factoryConfig, + bindingNode: match.localNode, + declarationScope: programScope, + mode, +}; +``` + +Use the exact local variable names already present in `findPrecreatedClients(...)`; do not invent new resolution logic if the function already has the resolved factory file and options import path. + +- [ ] **Step 6: Run typecheck to expose missing `clientSourceKey` assignments** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: PASS. If it fails with missing `clientSourceKey`, update the remaining `ClientBinding` construction sites only. + +- [ ] **Step 7: Commit Task 1** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts packages/tree-shaking-plugin/src/lib/transform/plan.ts +git commit -m "fix(tree-shaking): track client source identity" +``` + +### Task 2: Use Source Identity in Planner Keys + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Test: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Update named usage operation keys** + +In the first `CallExpression` traversal, replace operation and usage key construction that starts with `match.client.name` with `match.client.clientSourceKey`. + +The operation key should become: + +```ts +const operationKey = [ + match.client.clientSourceKey, + match.client.name, + match.serviceName, + match.operationName, + scopeKey, +].join(':'); +``` + +The usage map key should become: + +```ts +const key = [ + match.client.clientSourceKey, + match.client.name, + match.serviceName, + match.operationName, + match.callbackName, + scopeKey, +].join(':'); +``` + +Keep `match.client.name` in the key to preserve separate local clients from the same source. + +- [ ] **Step 2: Update schema source keys where named clients are involved** + +In `collectSchemaUsage(...)`, change the named-client `sourceKey` from only `match.client.name` to a source-aware value: + +```ts +const sourceKey = + match.kind === 'named' + ? `${match.client.clientSourceKey}:${match.client.name}` + : match.createImportPath; +``` + +Keep inline schema source keys as `match.createImportPath`. + +- [ ] **Step 3: Update `localClientNamesByOperation` consumers in `assignScopeLocalClientNames(...)`** + +Find `assignScopeLocalClientNames(...)` in `plan.ts`. Any key inside that function that is based on `usage.client.name + service + operation` must include `usage.client.clientSourceKey`. + +Use this key shape: + +```ts +[ + usage.client.clientSourceKey, + usage.client.name, + usage.serviceName, + usage.operationName, + usage.scopeKey, +].join(':') +``` + +- [ ] **Step 4: Run focused skipped tests without unskipping yet** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/__tests__/core/mixed-client-modes.test.ts -t "keeps same-operation rewrites separate across all client modes|supports createAPIClientFn and precreated apiClient clients in one file" +``` + +Expected: Vitest reports these tests as skipped because they still use `it.skip(...)`. This command is only a sanity check that the file still loads. + +- [ ] **Step 5: Run typecheck** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 2** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/plan.ts +git commit -m "fix(tree-shaking): use client source in usage keys" +``` + +### Task 3: Use Source Identity in Mutator Keys + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` +- Test: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Update `rewriteNamedClientCalls(...)` lookup key** + +In `rewriteNamedClientCalls(...)`, include `usage.client.clientSourceKey` when creating `usageByKey`: + +```ts +const usageByKey = new Map( + usages.map((usage) => [ + [ + usage.client.clientSourceKey, + usage.client.name, + usage.serviceName, + usage.operationName, + usage.callbackName, + usage.scopeKey, + ].join(':'), + usage, + ]) +); +``` + +Use the same key when reading from `usageByKey`: + +```ts +const usage = usageByKey.get( + [ + match.client.clientSourceKey, + match.client.name, + match.serviceName, + match.operationName, + match.callbackName, + getUsageScopeKey(callPath), + ].join(':') +); +``` + +- [ ] **Step 2: Update `hasScopeSplitUsage(...)`** + +In `hasScopeSplitUsage(...)`, include `usage.client.clientSourceKey`: + +```ts +const key = [ + usage.client.clientSourceKey, + usage.client.name, + usage.serviceName, + usage.operationName, +].join(':'); +``` + +- [ ] **Step 3: Check declaration dedupe behavior** + +Read `dedupeDeclarations(...)`. Do not change it unless tests prove it drops distinct source-aware optimized clients. The expected fix should make local names distinct before dedupe, rather than making dedupe source-aware. + +- [ ] **Step 4: Run focused currently-skipped tests by temporarily targeting their names** + +Do not edit `it.skip` yet. Run the full file load: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/__tests__/core/mixed-client-modes.test.ts +``` + +Expected: PASS with two skipped tests. + +- [ ] **Step 5: Commit Task 3** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/mutate.ts +git commit -m "fix(tree-shaking): use client source in mutator keys" +``` + +### Task 4: Unskip Mixed-Mode Regressions and Refresh Snapshots + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Unskip both regressions** + +In `packages/tree-shaking-plugin/src/core.test.ts`, change: + +```ts +it.skip('keeps same-operation rewrites separate across all client modes', ...) +it.skip('supports createAPIClientFn and precreated apiClient clients in one file', ...) +``` + +to: + +```ts +it('keeps same-operation rewrites separate across all client modes', ...) +it('supports createAPIClientFn and precreated apiClient clients in one file', ...) +``` + +Remove the two comments that say production still mishandles the cases. + +- [ ] **Step 2: Run focused snapshot update** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin vitest run src/core.test.ts -t "keeps same-operation rewrites separate across all client modes|supports createAPIClientFn and precreated apiClient clients in one file" -u +``` + +Expected: both tests PASS. + +If snapshots differ from the current expected future snapshots only by Babel UID names or import ordering, keep the generated snapshots if they still preserve: + +- context and precreated operation imports from different roots; +- distinct optimized client declarations; +- context branch rewritten to `qraftReactAPIClient` where `useQuery` requires React runtime; +- precreated branch rewritten to `qraftAPIClient(..., createAPIClientOptions())`; +- inline explicit-options call still passing `apiContext!`. + +- [ ] **Step 3: Verify no skipped tests remain from this fix** + +Run: + +```bash +rg -n "it\\.skip\\('keeps same-operation rewrites separate across all client modes'|it\\.skip\\('supports createAPIClientFn and precreated apiClient clients in one file'" packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: no output. + +- [ ] **Step 4: Run full package tests and typecheck** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both PASS. + +- [ ] **Step 5: Commit Task 4** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.test.ts +git commit -m "test(tree-shaking): enable mixed client identity regressions" +``` + +### Task 5: Final Review + +**Files:** +- Verify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Verify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Verify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` +- Verify: `packages/tree-shaking-plugin/src/core.test.ts` + +- [ ] **Step 1: Run final package checks** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: both PASS. + +- [ ] **Step 2: Inspect skipped tests** + +Run: + +```bash +rg -n "it\\.skip" packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: no output unless unrelated skips were added after this plan was written. If there is output for either mixed identity regression, the task is incomplete. + +- [ ] **Step 3: Inspect final diff** + +Run: + +```bash +git diff --stat HEAD~4..HEAD +git diff HEAD~4..HEAD -- packages/tree-shaking-plugin/src/lib/transform/types.ts packages/tree-shaking-plugin/src/lib/transform/plan.ts packages/tree-shaking-plugin/src/lib/transform/mutate.ts packages/tree-shaking-plugin/src/core.test.ts +``` + +Expected: + +- production changes are limited to transform types/planner/mutator; +- tests only unskip and refresh the two mixed identity regressions; +- no public config, e2e, or generated API fixture changes. + +## Self-Review + +Spec coverage: + +- The plan fixes both skipped regressions. +- The plan uses a source-aware identity instead of a test-specific workaround. +- The plan keeps public plugin options and e2e fixtures out of scope. + +Placeholder scan: + +- No placeholder implementation steps remain. +- Every edit step identifies exact files and concrete code shape. +- Every verification step includes exact commands and expected results. + +Type consistency: + +- `ClientBinding.clientSourceKey` is added in `types.ts` and populated at every construction site in `plan.ts`. +- Planner and mutator use the same identity components: `clientSourceKey`, local client name, service, operation, callback where needed, and scope where needed. +- Existing `getGeneratedInfoKey(...)` remains the factory/context identity base. diff --git a/docs/superpowers/plans/2026-05-15-tree-shaking-transform-boundaries.md b/docs/superpowers/plans/2026-05-15-tree-shaking-transform-boundaries.md new file mode 100644 index 000000000..805c38515 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-tree-shaking-transform-boundaries.md @@ -0,0 +1,597 @@ +# Tree-Shaking Transform Boundaries Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Align `@openapi-qraft/tree-shaking-plugin` tests and transform behavior with the approved `createAPIClientFn` / `apiClient` boundary contract. + +**Architecture:** Keep the existing plan/mutate split. Tighten transform planning so generated services ownership decides whether a factory is eligible, and make runtime helper selection depend on client mode plus explicit tree-shaking context config instead of callback type alone. + +**Tech Stack:** TypeScript, Babel AST, Vitest inline snapshots, Yarn workspace scripts. + +--- + +## File Structure + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` + - Extend `ClientBinding` metadata so the mutate phase can distinguish explicit context-enabled generated factories from no-context/options factories. +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` + - Preserve the existing `services` import requirement. + - Record whether a `createAPIClientFn` config explicitly supplies context. + - Keep `services: none` factories unresolved, including explicit `services` and operation arguments. +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` + - Select `qraftReactAPIClient` only for context-mode generated clients with explicit context config and hook callbacks. + - Use `qraftAPIClient` for explicit-options generated clients and every `apiClient` pre-created client. +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` + - Update existing context tests to pass explicit `context` config where they expect `qraftReactAPIClient`. + - Add red tests for explicit-options hook usage emitting `qraftAPIClient`. + - Add `services: none` operation-argument skip coverage. +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` + - Add `services: none` pre-created client skip coverage. + - Keep hook pre-created output pinned to `qraftAPIClient`. +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts` + - Pin one mixed-mode snapshot where context hook uses `qraftReactAPIClient`, explicit-options hook uses `qraftAPIClient`, and pre-created hook uses `qraftAPIClient`. +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts` + - Add `services: none` schema skip coverage for unresolved generated factories. + +## Task 1: Pin `createAPIClientFn` Runtime Helper Boundaries + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` + +- [x] **Step 1: Add a failing explicit-options hook test** + +Add this test near the existing context/argument boundary tests: + +```ts + it('uses qraftAPIClient for hook callbacks on explicit runtime options clients without configured context', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const requestOptions = { requestFn: async () => new Response() }; +const api = createAPIClient(requestOptions); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { createAPIClientFn: [{ name: 'createAPIClient', module: './api' }] } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + const requestOptions = { + requestFn: async () => new Response() + }; + const api_pets_getPets = qraftAPIClient(getPets, { + useQuery + }, requestOptions); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); +``` + +- [x] **Step 2: Verify the explicit-options hook test fails** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts -t "explicit runtime options clients" +``` + +Expected: FAIL because current output imports or emits `qraftReactAPIClient` for `useQuery`. + +- [x] **Step 3: Add an explicit context-config test or update an existing context test** + +Update the existing `"imports an operation directly for a context API client"` options object to make the context contract explicit: + +```ts + { + createAPIClientFn: [ + { + name: 'createAPIClient', + module: './api', + context: 'APIClientContext', + }, + ], + } +``` + +Expected snapshot remains: + +```ts +const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery +}, APIClientContext); +``` + +- [x] **Step 4: Add a services-none operation argument skip test** + +Add this test next to the existing explicit services argument skip test: + +```ts + it('skips generic generated factories that receive a single operation as an argument', async () => { + const fixture = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(fixture, { + 'src/api/createAPIClient.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { getQueryKey } from '@openapi-qraft/react/callbacks/index'; + +const defaultCallbacks = { getQueryKey } as const; + +export function createAPIClient(operation, callbacks = defaultCallbacks) { + return qraftAPIClient(operation, callbacks); +} +`, + 'src/api/services/PetsService.ts': PETS_SERVICE_TS, + }); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api/createAPIClient'; +import { getPets } from './api/services/PetsService'; + +const api = createAPIClient(getPets); + +export function App() { + return api.getQueryKey(); +} +`, + sourceFile, + { + createAPIClientFn: [ + { name: 'createAPIClient', module: './api/createAPIClient' }, + ], + } + ); + + expect(result).toBeNull(); + }); +``` + +- [x] **Step 5: Run focused createAPIClientFn tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts +``` + +Expected: explicit-options hook test still fails until Task 2; skip tests pass. + +## Task 2: Implement Runtime Helper Selection Contract + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` + +- [x] **Step 1: Extend `ClientBinding` with explicit context metadata** + +In `types.ts`, update the `ClientBinding` shape to include: + +```ts + hasExplicitContext: boolean; +``` + +The property belongs at the top level of `ClientBinding`, next to `factory`, because it applies to both `context` and `options` modes discovered from the same generated factory config. + +- [x] **Step 2: Populate `hasExplicitContext` for local createAPIClientFn clients** + +In `plan.ts`, when pushing a `ClientBinding` for a local generated factory client, set: + +```ts + hasExplicitContext: Boolean(createImport.factory.context), +``` + +Do this in both client creation branches: + +```ts +if (args.length === 0) { + const mode = { type: 'context' } as const; + clients.push({ + name: variablePath.node.id.name, + clientSourceKey: getClientSourceKey( + createImportPath, + createImport.factory, + mode + ), + createImportPath, + factory: createImport.factory, + hasExplicitContext: Boolean(createImport.factory.context), + bindingNode: variablePath.node.id, + declarationScope: variablePath.parentPath.scope, + localInitPath: variablePath, + mode, + }); + return; +} +``` + +```ts +if (args.length === 1 && isExpression(args[0])) { + const mode = { + type: 'options', + optionsExpression: t.cloneNode(args[0], true), + } as const; + clients.push({ + name: variablePath.node.id.name, + clientSourceKey: getClientSourceKey( + createImportPath, + createImport.factory, + mode + ), + createImportPath, + factory: createImport.factory, + hasExplicitContext: Boolean(createImport.factory.context), + bindingNode: variablePath.node.id, + declarationScope: variablePath.parentPath.scope, + localInitPath: variablePath, + mode, + }); +} +``` + +- [x] **Step 3: Populate `hasExplicitContext` for pre-created clients** + +In `findPrecreatedClients(...)`, set: + +```ts + hasExplicitContext: false, +``` + +when pushing the pre-created `ClientBinding`. Pre-created clients never select `qraftReactAPIClient`. + +- [x] **Step 4: Change runtime helper selection in `mutate.ts`** + +Replace the runtime helper selection for optimized client declarations with a mode-aware helper: + +```ts +function selectOptimizedClientRuntimeHelper( + usage: OperationUsage, + callbacks: Array<{ callbackName: string }> +): RuntimeHelperKind { + if (usage.client.mode.type !== 'context') return 'api'; + if (!usage.client.hasExplicitContext) return 'api'; + return selectRuntimeHelper(callbacks); +} +``` + +Then change `createOptimizedClientDeclaration(...)` from: + +```ts + const runtimeHelperKind = selectRuntimeHelper(callbacks); +``` + +to: + +```ts + const runtimeHelperKind = selectOptimizedClientRuntimeHelper( + usage, + callbacks + ); +``` + +Keep this existing line unchanged: + +```ts + const runtimeImportLocalName = + usage.client.mode.type === 'precreated' || runtimeHelperKind === 'api' + ? runtimeLocalNames.api + : runtimeLocalNames.react; +``` + +- [x] **Step 5: Run the focused failing test** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts -t "explicit runtime options clients" +``` + +Expected: PASS. + +- [x] **Step 6: Run createAPIClientFn tests and update snapshots intentionally** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts +``` + +Expected: Some existing context tests may fail because they relied on inferred context instead of explicit config. Update only tests that should be context-configured by adding `context: 'APIClientContext'` or the fixture-specific context name to their `createAPIClientFn` config. Do not update snapshots to `qraftReactAPIClient` for no-context explicit-options clients. + +## Task 3: Pin `apiClient` Services Ownership Boundaries + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` + +- [x] **Step 1: Add services-none pre-created client skip test** + +Add this test near existing pre-created skip/safety tests: + +```ts + it('skips a precreated client whose generated factory does not import services', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + 'src/api/index.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(services, options) { + return qraftAPIClient(services, defaultCallbacks, options); +} +`, + 'src/api/services/PetsService.ts': ` +export const getPets = { schema: { method: 'get', url: '/pets' } }; +`, + 'src/client-options.ts': ` +export const createAPIClientOptions = () => ({ queryClient: {} }); +`, + 'src/client.ts': ` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; +import { getPets } from './api/services/PetsService'; + +export const APIClient = createAPIClient({ pets: { getPets } }, createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +APIClient.pets.getPets.useQuery(); +`, + sourceFile, + { + apiClient: [ + { + client: 'APIClient', + clientModule: './client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './client-options', + }, + ], + } + ); + + expect(result).toBeNull(); + }); +``` + +- [x] **Step 2: Run focused pre-created tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/precreated-api-client.test.ts -t "does not import services|precreated named API client" +``` + +Expected: PASS. The existing named pre-created hook test should continue emitting `qraftAPIClient`. + +## Task 4: Pin Mixed-Mode Helper Isolation + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts` + +- [x] **Step 1: Add or update a mixed-mode test for helper selection** + +Add a new test or extend the existing `"keeps callback-class rewrites separate across context and precreated modes"` test so the input includes all three calls: + +```ts +const contextApi = createAPIClient(); +const explicitOptions = { requestFn: async () => new Response() }; +const explicitApi = createAPIClient(explicitOptions); + +export function App() { + contextApi.pets.getPets.useQuery(); + explicitApi.pets.findPetsByStatus.useQuery(); + APIClient.stores.getStores.useQuery(); +} +``` + +Expected emitted shape: + +```ts +const contextApi_pets_getPets = qraftReactAPIClient(getPets, { + useQuery +}, ContextAPIClientContext); +const explicitApi_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + useQuery +}, explicitOptions); +const APIClient_stores_getStores = qraftAPIClient(getStores, { + useQuery +}, createAPIClientOptions()); +``` + +The test config for the context factory must include explicit context: + +```ts +createAPIClientFn: [ + { + name: 'createAPIClient', + module: './context-api', + context: 'ContextAPIClientContext', + }, +], +``` + +- [x] **Step 2: Run focused mixed-mode tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/mixed-client-modes.test.ts +``` + +Expected: PASS after snapshot updates that match the approved helper selection contract. + +## Task 5: Pin Schema Skip Boundary + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts` + +- [x] **Step 1: Add schema skip imports** + +Ensure the file imports the fixture helpers it needs: + +```ts +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { PETS_SERVICE_TS, writeFixtureFiles } from './fixtures.js'; +``` + +- [x] **Step 2: Add schema skip test** + +Add: + +```ts + it('skips schema access for generic factories that do not import services', async () => { + const fixture = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(fixture, { + 'src/api/createAPIClient.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; + +export function createAPIClient(services) { + return qraftAPIClient(services, {}); +} +`, + 'src/api/services/PetsService.ts': PETS_SERVICE_TS, + }); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api/createAPIClient'; +import { getPets } from './api/services/PetsService'; + +const api = createAPIClient({ pets: { getPets } }); +api.pets.getPets.schema; +`, + sourceFile, + { + createAPIClientFn: [ + { name: 'createAPIClient', module: './api/createAPIClient' }, + ], + } + ); + + expect(result).toBeNull(); + }); +``` + +- [x] **Step 3: Run schema tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/schema-and-imports.test.ts +``` + +Expected: PASS. + +## Task 6: Full Verification And Commit + +**Files:** +- All modified files from previous tasks. + +- [x] **Step 1: Run full package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run +``` + +Expected: all tree-shaking-plugin tests pass. + +- [x] **Step 2: Run typecheck** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: no TypeScript errors. + +- [x] **Step 3: Run lint** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +``` + +Expected: no ESLint errors. + +- [x] **Step 4: Run diff whitespace check** + +Run: + +```bash +git diff --check +``` + +Expected: no output. + +- [x] **Step 5: Review final diff** + +Run: + +```bash +git diff -- packages/tree-shaking-plugin/src/lib/transform packages/tree-shaking-plugin/src/__tests__/core +``` + +Expected: + +- tests pin services ownership and helper selection boundaries; +- `qraftReactAPIClient` appears only for explicit context-configured generated factory hook transforms; +- explicit-options generated factory hook transforms use `qraftAPIClient`; +- pre-created hook transforms use `qraftAPIClient`; +- `services: none` generated factories are skipped. + +- [x] **Step 6: Commit implementation** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/lib/transform/plan.ts \ + packages/tree-shaking-plugin/src/lib/transform/mutate.ts \ + packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts +git commit -m "test: pin tree-shaking transform boundaries" +``` + +Expected: one implementation commit after the already committed design/spec. + +## Self-Review + +- Spec coverage: This plan covers `createAPIClientFn`, `apiClient`, mixed helper selection, schema boundary, and verification from the approved spec. +- Placeholder scan: No placeholder markers or unspecified implementation steps remain. +- Type consistency: `hasExplicitContext` is introduced in `ClientBinding`, populated in plan creation, and consumed by mutate runtime helper selection. diff --git a/docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md b/docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md new file mode 100644 index 000000000..422754f8b --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md @@ -0,0 +1,1829 @@ +# Tree-Shaking Plugin Pipeline Architecture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor `@openapi-qraft/tree-shaking-plugin` around an explicit transform contract, normalized entrypoints, strict generated-source metadata inspection, and diagnostics policy while preserving existing tree-shaking capabilities. + +**Architecture:** Keep `core.ts` as orchestration, introduce focused transform modules for diagnostics, entrypoint normalization, source gating, and generated metadata inspection, then route the existing planner/mutator through those normalized facts. The first implementation deletes config/model and generated-source inspection debt without requiring a full `TransformEditPlan` rewrite. + +**Tech Stack:** TypeScript, Babel AST, Vitest inline snapshots, unplugin, Yarn workspace scripts. + +--- + +## Transform Criteria Matrix + +Before changing code, keep this matrix as the implementation contract. If a +snapshot differs from this contract, update the transform/snapshot to the +contract instead of preserving the accidental printed shape. + +| Plugin term | Source shape | Runtime input in emitted code | Runtime helper | Optimized when | Excluded when | +| --- | --- | --- | --- | --- | --- | +| Context-based generated client | `const api = createAPIClient()` where generated source returns `qraftReactAPIClient(..., Context)` | `Context` for context-backed hooks; no input for context-free helper buckets | `qraftReactAPIClient` for context-backed hook surfaces; `qraftAPIClient` for context-free helper buckets | configured generated factory resolves, source loads, factory statically owns `services`, operation source is resolved, usage is a static member chain | factory does not own services, operation source is unresolved, required context cannot be resolved, or usage is unsupported | +| Explicit-options generated client | `const api = createAPIClient(optionsExpression)` or inline `createAPIClient(optionsExpression)` | original `optionsExpression` | `qraftAPIClient` | same generated factory ownership proof as above, and usage is a supported static callback/schema access | options are not represented by one expression, services/operation are supplied by caller instead of owned by generated source, or usage is unsupported | +| Pre-created client | imported configured client export, for example `nodeAPIClient.pets.getPets.useQuery()` | configured `optionsFactory()` call | `qraftAPIClient` | client export resolves, export is created by configured factory, options factory is known, underlying generated factory owns `services`, operation source is resolved | client export missing, factory binding mismatch, namespace/dynamic import, underlying factory has no static services ownership, or operation source is unresolved | +| Schema access | `.schema` on any optimizable generated/pre-created operation | none | none | operation source is resolved from owned services | operation source is unresolved or service ownership is not proven | + +Concrete implementation checks: + +- only configured entrypoints may be optimized; +- generated factory usage requires static ownership proof for `services`; +- `createAPIClient(optionsExpression)` is explicit-options usage and must emit + `qraftAPIClient(operation, callbacks, optionsExpression)`; +- pre-created clients must emit + `qraftAPIClient(operation, callbacks, optionsFactory())`; +- `.schema` access must emit direct `operation.schema` without runtime helpers; +- caller-supplied services/operation factories, computed access, destructuring, + optional chains, namespace imports, dynamic imports, and exported local client + declarations stay untransformed. + +## E2E Verification Strategy + +Do not postpone all end-to-end verification until the final cleanup task. Run an +e2e guard after each implementation milestone that can affect emitted bundle +shape, generated-source resolution, or real bundler integration. + +Use two e2e loops: + +**Fast in-place fixture loop.** Use this after a milestone when +`e2e/projects/tree-shaking-bundlers/node_modules` is already installed and the +goal is to verify real bundle output quickly: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +rm -rf e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cp -R packages/tree-shaking-plugin/dist \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cd e2e/projects/tree-shaking-bundlers +npm run codegen +npm run build +npm run e2e:post-build +``` + +Expected: `Tree-shaking bundle assertions passed.` + +**Full Verdaccio loop.** Use this before ending a session that changed the +published package surface, package build output, bundler adapters, or e2e +fixture assertions: + +```bash +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +This copies the fixture into `/Users/radist/w/qraft-e2e`, publishes workspace +packages to the local registry, installs the copied project from that registry, +builds all bundlers, and runs the same bundle/source-map assertions. + +Default milestone gates: + +- after Task 2: fast loop if diagnostics/entrypoint wiring reached `core.ts`; +- after Task 4: fast loop because source gating and generated metadata affect + real resolver/module-access behavior; +- after Task 6: fast loop at minimum, full Verdaccio loop before handing off the + session result; +- after Task 7: full Verdaccio loop if README/e2e/package-surface work changed + anything not covered by the previous full loop. + +## Session Execution Plans + +Use these smaller plans for separate implementation sessions: + +- Session 1: `docs/superpowers/plans/2026-05-16-tree-shaking-session-1-diagnostics-config-normalization.md` + - diagnostics policy and public config normalization; +- Session 1.5: `docs/superpowers/plans/2026-05-16-tree-shaking-session-1-5-public-config-alignment.md` + - breaking public config alignment to `entrypoints` with discriminated kinds; +- Session 2: `docs/superpowers/plans/2026-05-16-tree-shaking-session-2-source-gate-generated-metadata.md` + - pre-parse source gate and generated metadata inspection; +- Session 3: `docs/superpowers/plans/2026-05-16-tree-shaking-session-3-planner-mutator-normalized-model.md` + - planner/mutator rewrite through normalized runtime inputs and diagnostics + enforcement; +- Session 4: `docs/superpowers/plans/2026-05-16-tree-shaking-session-4-debt-docs-final-verification.md` + - debt deletion, README/test-guide docs, and final verification. + +Session 1 was implemented before the final public config naming decision. Treat +Session 1.5 as the source of truth for public config shape: new work must use +`entrypoints` with `kind: 'clientFactory' | 'precreatedClient'`. Older +`createAPIClientFn` / `apiClient` snippets in historical task bodies describe +the pre-alignment implementation state and must be translated through Session +1.5 when reused. + +## File Structure + +- Create: `packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts` + - Owns `DiagnosticsLevel`, `QraftTreeShakeError`, structured diagnostic reasons, and reporting policy. +- Create: `packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts` + - Unit tests for default error behavior, warn/off policy, and ordinary silent skips. +- Create: `packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts` + - Converts public `entrypoints` config into normalized `ClientEntrypoint[]`. +- Create: `packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts` + - Unit tests for config normalization. +- Create: `packages/tree-shaking-plugin/src/lib/transform/source-gate.ts` + - Owns `shouldInspectSource(...)`, the lightweight pre-parse gate. +- Create: `packages/tree-shaking-plugin/src/lib/transform/source-gate.test.ts` + - Unit tests for include/exclude/id/source signal behavior. +- Create: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts` + - Owns generated factory/precreated source inspection: resolve, load, re-export traversal, static services ownership, service import paths, context metadata, and options factory metadata. +- Create: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts` + - Unit tests for generated-source inspection and strict skip reasons. +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` + - Re-export or host normalized transform types shared by the new modules and the existing planner/mutator. +- Modify: `packages/tree-shaking-plugin/src/core.ts` + - Wire diagnostics, entrypoint normalization, pre-parse gate, and generated metadata cache into transform orchestration. +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` + - Consume normalized entrypoints and generated metadata; remove direct deep reads of public config fields where possible. +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` + - Prefer normalized runtime input over `hasExplicitContext` and legacy mode branching where possible in this phase. +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/*.test.ts` + - Preserve transform semantics, update old soft-skip tests to explicit `diagnostics: 'off'`, and add contract tests for default error diagnostics. +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md` + - Update test routing if new transform helper tests change the suite ownership story. +- Modify: `packages/tree-shaking-plugin/README.md` + - Document `diagnostics?: 'error' | 'warn' | 'off'` once implementation lands. + +## Task 1: Add Diagnostics Contract + +**Files:** +- Create: `packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/core.ts` + +- [ ] **Step 0: Re-read the criteria matrix** + +Before writing tests, re-read the `Transform Criteria Matrix` section in this +plan and the corresponding section in +`docs/superpowers/specs/2026-05-16-tree-shaking-plugin-pipeline-architecture-design.md`. + +Expected: + +- context-based zero-arg generated clients preserve context semantics; +- explicit-options generated clients preserve the original options expression + and use `qraftAPIClient`; +- pre-created clients preserve the configured options factory call and use + `qraftAPIClient`; +- caller-supplied services/operation factories remain excluded from + tree-shaking. + +- [ ] **Step 1: Add diagnostics unit tests first** + +Create `packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { + createDiagnosticReporter, + QraftTreeShakeError, +} from './diagnostics.js'; + +describe('tree-shaking diagnostics', () => { + it('throws unresolved transform candidates by default', () => { + const reporter = createDiagnosticReporter({}); + + expect(() => + reporter.unresolved({ + layer: 'generated-metadata', + code: 'generated-services-import-missing', + message: 'Generated factory does not statically import services.', + entrypointKey: 'generatedFactory:createAPIClient:./api', + }) + ).toThrow(QraftTreeShakeError); + }); + + it('warns and continues when diagnostics is warn', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reporter = createDiagnosticReporter({ diagnostics: 'warn' }); + + expect( + reporter.unresolved({ + layer: 'generated-metadata', + code: 'entrypoint-source-unavailable', + message: 'Generated source was unavailable.', + entrypointKey: 'generatedFactory:createAPIClient:./api', + }) + ).toBeNull(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + '[openapi-qraft/tree-shaking-plugin] entrypoint-source-unavailable' + ) + ); + + warn.mockRestore(); + }); + + it('stays silent when diagnostics is off', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reporter = createDiagnosticReporter({ diagnostics: 'off' }); + + expect( + reporter.unresolved({ + layer: 'generated-metadata', + code: 'operation-source-unresolved', + message: 'Operation source was not resolved.', + }) + ).toBeNull(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('ordinary skips never throw or warn', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reporter = createDiagnosticReporter({}); + + expect( + reporter.ordinarySkip({ + layer: 'gate', + code: 'source-gate-no-signals', + message: 'Source contains no configured entrypoint signals.', + }) + ).toBeNull(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); +``` + +- [ ] **Step 2: Run diagnostics tests to verify they fail** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/diagnostics.test.ts +``` + +Expected: FAIL because `diagnostics.ts` does not exist. + +- [ ] **Step 3: Add diagnostics types to `types.ts`** + +In `packages/tree-shaking-plugin/src/lib/transform/types.ts`, add: + +```ts +export type DiagnosticsLevel = 'error' | 'warn' | 'off'; + +export type DiagnosticLayer = + | 'gate' + | 'entrypoint' + | 'generated-metadata' + | 'usage-collection'; + +export type DiagnosticReason = { + layer: DiagnosticLayer; + code: string; + message: string; + entrypointKey?: string; +}; +``` + +Update `QraftTreeShakeOptions` in the same file: + +```ts +export type QraftTreeShakeOptions = { + createAPIClientFn?: QraftFactoryConfig[]; + apiClient?: QraftPrecreatedClientConfig[]; + resolve?: QraftResolver; + moduleAccess?: QraftModuleAccessOptions; + include?: FilterPattern; + exclude?: FilterPattern; + diagnostics?: DiagnosticsLevel; +}; +``` + +Do not keep a legacy boolean diagnostics alias as a compatibility option; diagnostics are configured only through `diagnostics?: 'error' | 'warn' | 'off'`. + +- [ ] **Step 4: Mirror the public option in `core.ts`** + +In `packages/tree-shaking-plugin/src/core.ts`, add `DiagnosticsLevel` to the imports from `types.ts` after Task 2 moves public types there. Until then, add a local type: + +```ts +export type DiagnosticsLevel = 'error' | 'warn' | 'off'; +``` + +Update `QraftTreeShakeOptions`: + +```ts +export type QraftTreeShakeOptions = { + createAPIClientFn?: QraftFactoryConfig[]; + apiClient?: QraftPrecreatedClientConfig[]; + resolve?: QraftResolver; + moduleAccess?: QraftModuleAccessOptions; + include?: FilterPattern; + exclude?: FilterPattern; + diagnostics?: DiagnosticsLevel; +}; +``` + +- [ ] **Step 5: Implement `diagnostics.ts`** + +Create `packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts`: + +```ts +import type { + DiagnosticReason, + DiagnosticsLevel, + QraftTreeShakeOptions, +} from './types.js'; + +export class QraftTreeShakeError extends Error { + readonly reason: DiagnosticReason; + + constructor(reason: DiagnosticReason) { + super(formatDiagnosticReason(reason)); + this.name = 'QraftTreeShakeError'; + this.reason = reason; + } +} + +export type DiagnosticReporter = { + ordinarySkip(reason: DiagnosticReason): null; + unresolved(reason: DiagnosticReason): null; +}; + +export function createDiagnosticReporter( + options: Pick +): DiagnosticReporter { + const diagnostics = normalizeDiagnosticsLevel(options); + + return { + ordinarySkip() { + return null; + }, + unresolved(reason) { + if (diagnostics === 'error') { + throw new QraftTreeShakeError(reason); + } + + if (diagnostics === 'warn') { + console.warn(formatDiagnosticReason(reason)); + } + + return null; + }, + }; +} + +function normalizeDiagnosticsLevel( + options: Pick +): DiagnosticsLevel { + if (options.diagnostics) return options.diagnostics; + return 'error'; +} + +function formatDiagnosticReason(reason: DiagnosticReason): string { + const entrypoint = reason.entrypointKey + ? ` entrypoint=${reason.entrypointKey}` + : ''; + + return `[openapi-qraft/tree-shaking-plugin] ${reason.code} (${reason.layer})${entrypoint}: ${reason.message}`; +} +``` + +- [ ] **Step 6: Run diagnostics tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/diagnostics.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit diagnostics contract** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/core.ts \ + packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts \ + packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts +git commit -m "feat: add tree-shaking diagnostics policy" +``` + +Expected: one commit with diagnostics types and tests. + +## Task 2: Normalize Public Config Into Entrypoints + +**Files:** +- Create: `packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` + +- [ ] **Step 1: Add entrypoint normalization tests** + +Create `packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { normalizeEntrypoints } from './entrypoints.js'; + +describe('normalizeEntrypoints', () => { + it('normalizes createAPIClientFn configs', () => { + expect( + normalizeEntrypoints({ + createAPIClientFn: [ + { + name: 'createReactAPIClient', + module: './api', + context: 'APIClientContext', + contextModule: './api/APIClientContext', + }, + ], + }) + ).toEqual([ + { + kind: 'generatedFactory', + key: 'generatedFactory:createReactAPIClient:./api', + factory: { + exportName: 'createReactAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: './api/APIClientContext', + }, + }, + ]); + }); + + it('normalizes precreated apiClient configs with explicit options module', () => { + expect( + normalizeEntrypoints({ + apiClient: [ + { + client: 'nodeAPIClient', + clientModule: './client', + createAPIClientFn: 'createNodeAPIClient', + createAPIClientFnModule: './api', + createAPIClientFnOptions: 'createNodeAPIClientOptions', + createAPIClientFnOptionsModule: './client-options', + }, + ], + }) + ).toEqual([ + { + kind: 'precreatedClient', + key: 'precreatedClient:nodeAPIClient:./client:createNodeAPIClient:./api:createNodeAPIClientOptions:./client-options', + client: { + exportName: 'nodeAPIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ]); + }); + + it('normalizes precreated options module fallback to client module', () => { + const [entrypoint] = normalizeEntrypoints({ + apiClient: [ + { + client: 'nodeAPIClient', + clientModule: './client', + createAPIClientFn: 'createNodeAPIClient', + createAPIClientFnModule: './api', + createAPIClientFnOptions: 'createNodeAPIClientOptions', + }, + ], + }); + + expect(entrypoint).toMatchObject({ + kind: 'precreatedClient', + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client', + }, + }); + }); +}); +``` + +- [ ] **Step 2: Run entrypoint tests to verify they fail** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/entrypoints.test.ts +``` + +Expected: FAIL because `entrypoints.ts` does not exist. + +- [ ] **Step 3: Add normalized types** + +In `packages/tree-shaking-plugin/src/lib/transform/types.ts`, add: + +```ts +export type ImportTarget = { + exportName: string; + moduleSpecifier: string; +}; + +export type ReactContextConfig = { + exportName: string; + moduleSpecifier: string | null; +}; + +export type GeneratedFactoryEntrypoint = { + kind: 'generatedFactory'; + key: string; + factory: ImportTarget; + reactContext: ReactContextConfig | null; +}; + +export type PrecreatedClientEntrypoint = { + kind: 'precreatedClient'; + key: string; + client: ImportTarget; + factory: ImportTarget; + optionsFactory: ImportTarget; +}; + +export type ClientEntrypoint = + | GeneratedFactoryEntrypoint + | PrecreatedClientEntrypoint; +``` + +- [ ] **Step 4: Implement `entrypoints.ts`** + +Create `packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts`: + +```ts +import type { + ClientEntrypoint, + QraftPrecreatedClientConfig, + QraftTreeShakeOptions, +} from './types.js'; + +export function normalizeEntrypoints( + options: Pick +): ClientEntrypoint[] { + return [ + ...(options.createAPIClientFn ?? []).map((factory) => ({ + kind: 'generatedFactory' as const, + key: composeGeneratedFactoryEntrypointKey(factory.name, factory.module), + factory: { + exportName: factory.name, + moduleSpecifier: factory.module, + }, + reactContext: factory.context + ? { + exportName: factory.context, + moduleSpecifier: factory.contextModule ?? null, + } + : null, + })), + ...(options.apiClient ?? []).map((config) => + normalizePrecreatedEntrypoint(config) + ), + ]; +} + +function normalizePrecreatedEntrypoint( + config: QraftPrecreatedClientConfig +): ClientEntrypoint { + const optionsModule = + config.createAPIClientFnOptionsModule ?? config.clientModule; + + return { + kind: 'precreatedClient', + key: [ + 'precreatedClient', + config.client, + config.clientModule, + config.createAPIClientFn, + config.createAPIClientFnModule, + config.createAPIClientFnOptions, + optionsModule, + ].join(':'), + client: { + exportName: config.client, + moduleSpecifier: config.clientModule, + }, + factory: { + exportName: config.createAPIClientFn, + moduleSpecifier: config.createAPIClientFnModule, + }, + optionsFactory: { + exportName: config.createAPIClientFnOptions, + moduleSpecifier: optionsModule, + }, + }; +} + +function composeGeneratedFactoryEntrypointKey( + exportName: string, + moduleSpecifier: string +) { + return ['generatedFactory', exportName, moduleSpecifier].join(':'); +} +``` + +- [ ] **Step 5: Run entrypoint tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/entrypoints.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit entrypoint normalization** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts \ + packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts +git commit -m "refactor: normalize tree-shaking entrypoints" +``` + +Expected: one commit introducing normalized entrypoint types and tests. + +## Milestone A: Diagnostics And Config Normalization E2E Gate + +Run this gate if Task 1 or Task 2 changed `core.ts`, public config types, plugin +exports, or any code path that the bundled fixture can execute. + +Preferred command: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +rm -rf e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cp -R packages/tree-shaking-plugin/dist \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cd e2e/projects/tree-shaking-bundlers +npm run codegen +npm run build +npm run e2e:post-build +``` + +Expected: `Tree-shaking bundle assertions passed.` + +If Task 1 and Task 2 stayed entirely inside helper modules that are not wired +into `core.ts` yet, document that the e2e gate was intentionally skipped and +will run after Task 4. + +## Task 3: Add The Pre-Parse Source Gate + +**Files:** +- Create: `packages/tree-shaking-plugin/src/lib/transform/source-gate.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/source-gate.test.ts` +- Modify: `packages/tree-shaking-plugin/src/core.ts` + +- [ ] **Step 1: Add source gate tests** + +Create `packages/tree-shaking-plugin/src/lib/transform/source-gate.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { normalizeEntrypoints } from './entrypoints.js'; +import { shouldInspectSource } from './source-gate.js'; + +describe('shouldInspectSource', () => { + it('skips when no entrypoints are configured', () => { + expect( + shouldInspectSource({ + code: 'const value = 1;', + id: '/repo/src/App.tsx', + entrypoints: [], + include: undefined, + exclude: undefined, + }) + ).toBe(false); + }); + + it('skips non-source and node_modules ids', () => { + const entrypoints = normalizeEntrypoints({ + createAPIClientFn: [{ name: 'createAPIClient', module: './api' }], + }); + + expect( + shouldInspectSource({ + code: 'createAPIClient().pets.getPets.useQuery();', + id: '/repo/src/App.css', + entrypoints, + include: undefined, + exclude: undefined, + }) + ).toBe(false); + + expect( + shouldInspectSource({ + code: 'createAPIClient().pets.getPets.useQuery();', + id: '/repo/node_modules/pkg/index.ts', + entrypoints, + include: undefined, + exclude: undefined, + }) + ).toBe(false); + }); + + it('requires a configured entrypoint signal and member-chain hint', () => { + const entrypoints = normalizeEntrypoints({ + createAPIClientFn: [{ name: 'createAPIClient', module: './api' }], + }); + + expect( + shouldInspectSource({ + code: "import { createAPIClient } from './api'; const api = createAPIClient();", + id: '/repo/src/App.tsx', + entrypoints, + include: undefined, + exclude: undefined, + }) + ).toBe(false); + + expect( + shouldInspectSource({ + code: "import { createAPIClient } from './api'; createAPIClient().pets.getPets.useQuery();", + id: '/repo/src/App.tsx', + entrypoints, + include: undefined, + exclude: undefined, + }) + ).toBe(true); + }); + + it('honors include and exclude filters', () => { + const entrypoints = normalizeEntrypoints({ + createAPIClientFn: [{ name: 'createAPIClient', module: './api' }], + }); + + expect( + shouldInspectSource({ + code: "import { createAPIClient } from './api'; createAPIClient().pets.getPets.useQuery();", + id: '/repo/src/App.tsx', + entrypoints, + include: /src\/App/, + exclude: /App/, + }) + ).toBe(false); + + expect( + shouldInspectSource({ + code: "import { createAPIClient } from './api'; createAPIClient().pets.getPets.useQuery();", + id: '/repo/src/App.tsx', + entrypoints, + include: /src\/App/, + exclude: undefined, + }) + ).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run source gate tests to verify they fail** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/source-gate.test.ts +``` + +Expected: FAIL because `source-gate.ts` does not exist. + +- [ ] **Step 3: Implement `source-gate.ts`** + +Create `packages/tree-shaking-plugin/src/lib/transform/source-gate.ts`: + +```ts +import type { ClientEntrypoint, FilterPattern } from './types.js'; + +type ShouldInspectSourceInput = { + code: string; + id: string; + entrypoints: ClientEntrypoint[]; + include: FilterPattern | undefined; + exclude: FilterPattern | undefined; +}; + +const MEMBER_CHAIN_HINTS = [ + '.schema', + '.useQuery', + '.useSuspenseQuery', + '.useInfiniteQuery', + '.useMutation', + '.useIsFetching', + '.useIsMutating', + '.useMutationState', + '.getQueryKey', + '.getInfiniteQueryKey', + '.getMutationKey', + '.getQueryData', + '.setQueryData', + '.invalidateQueries', + '.fetchQuery', + '.prefetchQuery', + '.ensureQueryData', +] as const; + +export function shouldInspectSource({ + code, + id, + entrypoints, + include, + exclude, +}: ShouldInspectSourceInput): boolean { + if (entrypoints.length === 0) return false; + if (!shouldTransformId(id, include, exclude)) return false; + if (!hasEntrypointSignal(code, entrypoints)) return false; + if (!MEMBER_CHAIN_HINTS.some((hint) => code.includes(hint))) return false; + return true; +} + +function shouldTransformId( + id: string, + include: FilterPattern | undefined, + exclude: FilterPattern | undefined +) { + if (id.includes('/node_modules/')) return false; + if (!/\.[cm]?[jt]sx?$/.test(id)) return false; + if (matchesPattern(id, exclude)) return false; + if (include && !matchesPattern(id, include)) return false; + return true; +} + +function hasEntrypointSignal(code: string, entrypoints: ClientEntrypoint[]) { + return entrypoints.some((entrypoint) => { + if (entrypoint.kind === 'generatedFactory') { + return ( + code.includes(entrypoint.factory.exportName) || + code.includes(entrypoint.factory.moduleSpecifier) + ); + } + + return ( + code.includes(entrypoint.client.exportName) || + code.includes(entrypoint.client.moduleSpecifier) + ); + }); +} + +function matchesPattern( + id: string, + pattern: FilterPattern | undefined +): boolean { + if (!pattern) return false; + if (Array.isArray(pattern)) + return pattern.some((item) => matchesPattern(id, item)); + if (typeof pattern === 'string') return id.includes(pattern); + return pattern.test(id); +} +``` + +- [ ] **Step 4: Wire source gate into `core.ts`** + +In `packages/tree-shaking-plugin/src/core.ts`, import: + +```ts +import { normalizeEntrypoints } from './lib/transform/entrypoints.js'; +import { shouldInspectSource } from './lib/transform/source-gate.js'; +``` + +Replace the initial option checks inside `transformQraftTreeShaking(...)`: + +```ts + if (!shouldTransformId(id, options)) return null; + + const factoryOptions = options.createAPIClientFn ?? []; + const precreatedOptions = options.apiClient ?? []; + if (factoryOptions.length === 0 && precreatedOptions.length === 0) { + return debugSkip(options, id, 'no API clients configured'); + } +``` + +with: + +```ts + const entrypoints = normalizeEntrypoints(options); + if ( + !shouldInspectSource({ + code, + id, + entrypoints, + include: options.include, + exclude: options.exclude, + }) + ) { + return null; + } +``` + +Do not delete `shouldTransformId(...)`, `matchesPattern(...)`, or `debugSkip(...)` yet if TypeScript still needs them. Remove them only after `core.ts` compiles without those helpers. + +- [ ] **Step 5: Run source gate and current focused core tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/source-gate.test.ts src/__tests__/core/resolution-and-module-access.test.ts +``` + +Expected: PASS or focused failures where previous unresolved cases now follow the diagnostics contract. If `resolution-and-module-access.test.ts` expected legacy diagnostic output, update that test to the new silent ordinary skip contract. + +- [ ] **Step 6: Commit source gate** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.ts \ + packages/tree-shaking-plugin/src/lib/transform/source-gate.ts \ + packages/tree-shaking-plugin/src/lib/transform/source-gate.test.ts +git commit -m "refactor: add tree-shaking source gate" +``` + +Expected: one commit for the source gate. + +## Task 4: Extract Generated Metadata Inspection + +**Files:** +- Create: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` + +- [ ] **Step 1: Add generated metadata tests** + +Create `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts`: + +```ts +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + createFixtureModuleAccess, + getContextFixtureFiles, + PETS_SERVICE_TS, + PRECREATED_API_INDEX_TS, + SERVICES_INDEX_TS, + writeFixtureFiles, +} from '../../__tests__/core/fixtures.js'; +import { normalizeEntrypoints } from './entrypoints.js'; +import { inspectGeneratedEntrypoints } from './generated-metadata.js'; + +async function mkFixture(files: Record) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-metadata-')); + await writeFixtureFiles(root, files); + return root; +} + +describe('inspectGeneratedEntrypoints', () => { + it('reads generated factory metadata with static services ownership', async () => { + const root = await mkFixture({ + ...getContextFixtureFiles('APIClientContext', './APIClientContext', true), + }); + const sourceFile = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + createAPIClientFn: [ + { + name: 'createAPIClient', + module: './api', + context: 'APIClientContext', + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId: sourceFile, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + expect(result.metadataByEntrypointKey.get(entrypoints[0].key)).toMatchObject({ + factoryFile: path.join(root, 'src/api/index.ts'), + servicesDir: './services', + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: './APIClientContext', + }, + }); + }); + + it('returns an unresolved reason when generated source is unavailable', async () => { + const root = await mkFixture({}); + const sourceFile = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + createAPIClientFn: [{ name: 'createAPIClient', module: './api' }], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId: sourceFile, + entrypoints, + moduleAccess: { + resolve: async () => path.join(root, 'src/api/index.ts'), + load: async () => null, + }, + }); + + expect(result.metadataByEntrypointKey.get(entrypoints[0].key)).toBeNull(); + expect(result.reasons).toContainEqual( + expect.objectContaining({ + layer: 'generated-metadata', + code: 'entrypoint-source-unavailable', + entrypointKey: entrypoints[0].key, + }) + ); + }); + + it('returns an unresolved reason for factories without static services imports', async () => { + const root = await mkFixture({ + 'src/api/createAPIClient.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; +export function createAPIClient(services) { + return qraftAPIClient(services, {}); +} +`, + 'src/api/services/PetsService.ts': PETS_SERVICE_TS, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + createAPIClientFn: [ + { name: 'createAPIClient', module: './api/createAPIClient' }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId: sourceFile, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + expect(result.metadataByEntrypointKey.get(entrypoints[0].key)).toBeNull(); + expect(result.reasons).toContainEqual( + expect.objectContaining({ + code: 'generated-services-import-missing', + }) + ); + }); + + it('validates precreated clients against the configured factory', async () => { + const root = await mkFixture({ + 'src/api/index.ts': PRECREATED_API_INDEX_TS, + 'src/api/services/index.ts': SERVICES_INDEX_TS, + 'src/api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/client-options.ts': `export const createAPIClientOptions = () => ({ queryClient: {} });`, + 'src/client.ts': ` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + apiClient: [ + { + client: 'APIClient', + clientModule: './client', + createAPIClientFn: 'createAPIClient', + createAPIClientFnModule: './api', + createAPIClientFnOptions: 'createAPIClientOptions', + createAPIClientFnOptionsModule: './client-options', + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId: sourceFile, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + expect(result.metadataByEntrypointKey.get(entrypoints[0].key)).toMatchObject({ + factoryFile: path.join(root, 'src/api/index.ts'), + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }); + }); +}); +``` + +- [ ] **Step 2: Run generated metadata tests to verify they fail** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/generated-metadata.test.ts +``` + +Expected: FAIL because `generated-metadata.ts` does not exist. + +- [ ] **Step 3: Add metadata result types** + +In `packages/tree-shaking-plugin/src/lib/transform/types.ts`, add: + +```ts +export type GeneratedClientMetadata = { + entrypoint: ClientEntrypoint; + factoryFile: string; + servicesDir: string; + serviceImportPaths: Record; + reactContext: ReactContextConfig | null; + optionsFactory?: ImportTarget; +}; + +export type GeneratedMetadataResult = { + metadataByEntrypointKey: Map; + reasons: DiagnosticReason[]; +}; +``` + +- [ ] **Step 4: Move generated-source helpers from `plan.ts` into `generated-metadata.ts`** + +Create `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts` by moving the current helper responsibilities out of `plan.ts`: + +```ts +import type { QraftModuleAccess } from '../resolvers/common.js'; +import type { + ClientEntrypoint, + DiagnosticReason, + GeneratedClientMetadata, + GeneratedMetadataResult, +} from './types.js'; + +type InspectGeneratedEntrypointsInput = { + importerId: string; + entrypoints: ClientEntrypoint[]; + moduleAccess: QraftModuleAccess; +}; + +export async function inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess, +}: InspectGeneratedEntrypointsInput): Promise { + const metadataByEntrypointKey = new Map< + string, + GeneratedClientMetadata | null + >(); + const reasons: DiagnosticReason[] = []; + + for (const entrypoint of entrypoints) { + const inspected = + entrypoint.kind === 'generatedFactory' + ? await inspectGeneratedFactoryEntrypoint( + importerId, + entrypoint, + moduleAccess + ) + : await inspectPrecreatedClientEntrypoint( + importerId, + entrypoint, + moduleAccess + ); + + metadataByEntrypointKey.set(entrypoint.key, inspected.metadata); + if (inspected.reason) reasons.push(inspected.reason); + } + + return { metadataByEntrypointKey, reasons }; +} +``` + +Then move the existing implementations behind these internal functions: + +- `resolveFactoryModule(...)`; +- `readGeneratedClientInfo(...)`; +- `findFactoryReexport(...)`; +- `readServiceImportPaths(...)`; +- `readExportedDeclarationChain(...)`; +- `readTopLevelImportBindings(...)`; +- `matchesConfiguredBinding(...)`; +- helper functions needed by those routines. + +Preserve existing behavior first. Do not rewrite traversal logic in this step except to return `DiagnosticReason` instead of calling `debugSkip(...)`. + +- [ ] **Step 5: Keep legacy planner compiling through an adapter** + +In `plan.ts`, temporarily import the new inspector: + +```ts +import { inspectGeneratedEntrypoints } from './generated-metadata.js'; +import { normalizeEntrypoints } from './entrypoints.js'; +``` + +At the start of `createTransformPlan(...)`, compute: + +```ts + const entrypoints = normalizeEntrypoints(options); + const generatedMetadata = await inspectGeneratedEntrypoints({ + importerId: id, + entrypoints, + moduleAccess, + }); +``` + +For this task, it is acceptable to keep the old `generatedInfoByImport` maps and old helpers if removing them would make the patch too broad. The required end state for this task is: + +- new `generated-metadata.test.ts` passes; +- old core tests still pass; +- generated metadata inspection is callable independently. + +If duplicated helpers remain after this task, mark the duplicated helper deletion in Task 5 rather than mixing it into this extraction. + +- [ ] **Step 6: Run generated metadata and core tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/generated-metadata.test.ts src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit metadata boundary extraction** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts \ + packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts \ + packages/tree-shaking-plugin/src/lib/transform/plan.ts +git commit -m "refactor: extract generated metadata inspection" +``` + +Expected: one commit with the metadata boundary and tests. + +## Milestone B: Source Gate And Generated Metadata E2E Gate + +Run the fast in-place fixture loop after Task 4. This milestone touches the +plugin's decision to parse source and the generated-module inspection boundary, +so unit tests are not enough. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +rm -rf e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cp -R packages/tree-shaking-plugin/dist \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cd e2e/projects/tree-shaking-bundlers +npm run codegen +npm run build +npm run e2e:post-build +``` + +Expected: `Tree-shaking bundle assertions passed.` + +If this fails only for one bundler, inspect that bundler's generated output +inside `e2e/projects/tree-shaking-bundlers/dist` before changing assertions. +Do not weaken bundle assertions until the root cause is understood. + +## Task 5: Route Planner Through Normalized Entrypoints And Metadata + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts` + +- [ ] **Step 1: Add a focused planner regression for metadata-driven context** + +In `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts`, add this test near the context tests: + +```ts + it('uses generated context metadata when config omits context but source proves it', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); +api.pets.getPets.useQuery(); +`, + sourceFile, + { + createAPIClientFn: [{ name: 'createAPIClient', module: './api' }], + } + ); + + expect(result?.code).toContain('qraftReactAPIClient'); + expect(result?.code).toContain('APIClientContext'); + }); +``` + +This pins the design decision that generated metadata can prove context when reliable; explicit config is not the only source of truth. + +Also add this explicit-options regression near the existing explicit-options +tests if it is not already present: + +```ts + it('preserves explicit options clients as qraftAPIClient rewrites even when generated factory is context-capable', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const apiOptions = { requestFn: async () => new Response() }; +const api = createAPIClient(apiOptions); +api.pets.getPets.useQuery(); +`, + sourceFile, + { + createAPIClientFn: [ + { + name: 'createAPIClient', + module: './api', + context: 'APIClientContext', + }, + ], + } + ); + + expect(result?.code).toContain('qraftAPIClient'); + expect(result?.code).not.toContain('qraftReactAPIClient'); + expect(result?.code).toContain('apiOptions'); + }); +``` + +- [ ] **Step 2: Run the focused regression** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts -t "generated context metadata" +``` + +Expected: FAIL if the current helper selection only trusts explicit config. PASS is acceptable if current code already satisfies the contract. + +- [ ] **Step 3: Extend `ClientBinding` with normalized runtime input** + +In `packages/tree-shaking-plugin/src/lib/transform/types.ts`, add: + +```ts +export type RuntimeInput = + | { kind: 'none' } + | { kind: 'context'; context: ReactContextConfig } + | { kind: 'optionsExpression'; expression: t.Expression } + | { kind: 'optionsFactoryCall'; target: ImportTarget }; +``` + +Update `ClientBinding`: + +```ts +export type ClientBinding = { + name: string; + clientSourceKey: string; + createImportPath: string; + factory: QraftFactoryConfig; + bindingNode: t.Node; + declarationScope: Scope; + runtimeInput: RuntimeInput; + localInitPath?: import('@babel/traverse').NodePath; + mode: + | { type: 'context' } + | { type: 'options'; optionsExpression: t.Expression } + | { + type: 'precreated'; + optionsImportPath: string; + optionsExportName: string; + }; +}; +``` + +Remove `hasExplicitContext` only after all compile errors in this task are fixed. + +- [ ] **Step 4: Populate `runtimeInput` for local generated clients** + +In `plan.ts`, when pushing zero-argument generated factory clients, use generated metadata: + +```ts +const generatedInfo = generatedInfoByImport.get( + getGeneratedInfoKey(createImportPath, createImport.factory) +); +const runtimeInput = + generatedInfo?.contextName && generatedInfo.contextImportPath + ? { + kind: 'context' as const, + context: { + exportName: generatedInfo.contextName, + moduleSpecifier: generatedInfo.contextImportPath, + }, + } + : { kind: 'none' as const }; +``` + +Then include `runtimeInput` in the pushed `ClientBinding`. + +For one-argument generated factory clients: + +```ts +const runtimeInput = { + kind: 'optionsExpression' as const, + expression: t.cloneNode(args[0], true), +}; +``` + +For precreated clients: + +```ts +const runtimeInput = { + kind: 'optionsFactoryCall' as const, + target: { + exportName: match.config.createAPIClientFnOptions, + moduleSpecifier: match.optionsImportPath, + }, +}; +``` + +- [ ] **Step 5: Update helper selection to use `runtimeInput`** + +In `mutate.ts`, change `selectOptimizedClientRuntimeHelper(...)` to: + +```ts +function selectOptimizedClientRuntimeHelper( + usage: OperationUsage, + callbacks: Array<{ callbackName: string }> +): RuntimeHelperKind { + if (usage.client.runtimeInput.kind !== 'context') return 'api'; + return selectRuntimeHelper(callbacks); +} +``` + +Then update the context/options/precreated argument emission in `createOptimizedClientDeclaration(...)`: + +```ts +if (usage.client.runtimeInput.kind === 'context') { + if (needsOptions) { + args.push(t.identifier(usage.client.runtimeInput.context.exportName)); + } +} else if (usage.client.runtimeInput.kind === 'optionsExpression') { + args.push(t.cloneNode(usage.client.runtimeInput.expression, true)); +} else if (usage.client.runtimeInput.kind === 'optionsFactoryCall') { + args.push( + t.callExpression( + t.identifier(usage.client.runtimeInput.target.exportName), + [] + ) + ); +} +``` + +- [ ] **Step 6: Remove `hasExplicitContext`** + +Delete `hasExplicitContext` from `ClientBinding` and every object literal that sets it. + +Run: + +```bash +rg -n "hasExplicitContext" packages/tree-shaking-plugin/src +``` + +Expected: no output. + +- [ ] **Step 7: Run core transform suites** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/explicit-options.test.ts src/__tests__/core/precreated-api-client.test.ts src/__tests__/core/mixed-client-modes.test.ts src/__tests__/core/schema-and-imports.test.ts +``` + +Expected: PASS after intentional snapshot updates. If snapshots change, verify these semantic signals: + +- context zero-arg hook usage preserves context runtime; +- explicit options usage passes the original options expression; +- precreated usage calls configured options factory; +- schema usage imports no runtime helper; +- unsupported references keep original clients alive. + +- [ ] **Step 8: Commit normalized planner wiring** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/lib/transform/plan.ts \ + packages/tree-shaking-plugin/src/lib/transform/mutate.ts \ + packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts +git commit -m "refactor: route tree-shaking through normalized runtime inputs" +``` + +Expected: one commit removing `hasExplicitContext` and routing helper/argument selection through `runtimeInput`. + +## Task 6: Enforce Diagnostics In Core Transform Behavior + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts` + +- [ ] **Step 1: Add core diagnostics behavior tests** + +In `packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts`, add: + +```ts + it('throws by default when a configured transform candidate cannot load generated source', async () => { + const sourceFile = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), 'src/App.tsx'); + + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; +createAPIClient().pets.getPets.useQuery(); +`, + sourceFile, + { + createAPIClientFn: [{ name: 'createAPIClient', module: './api' }], + moduleAccess: { + resolve: async () => '/virtual/api/index.ts', + load: async () => null, + }, + } + ) + ).rejects.toMatchObject({ + name: 'QraftTreeShakeError', + reason: expect.objectContaining({ + code: 'entrypoint-source-unavailable', + }), + }); + }); + + it('skips unresolved transform candidates when diagnostics is off', async () => { + const sourceFile = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), 'src/App.tsx'); + + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; +createAPIClient().pets.getPets.useQuery(); +`, + sourceFile, + { + diagnostics: 'off', + createAPIClientFn: [{ name: 'createAPIClient', module: './api' }], + moduleAccess: { + resolve: async () => '/virtual/api/index.ts', + load: async () => null, + }, + } + ) + ).resolves.toBeNull(); + }); +``` + +If the file does not already import `fs`, `os`, or `path`, add: + +```ts +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +``` + +- [ ] **Step 2: Run diagnostics behavior tests to verify they fail** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/resolution-and-module-access.test.ts -t "diagnostics|cannot load generated source" +``` + +Expected: FAIL until `core.ts` reports unresolved metadata reasons. + +- [ ] **Step 3: Report generated metadata reasons from `core.ts` or `plan.ts`** + +Where `inspectGeneratedEntrypoints(...)` is called, create a reporter: + +```ts +const diagnostics = createDiagnosticReporter(options); +``` + +For each metadata reason that corresponds to an entrypoint used in the source, call: + +```ts +diagnostics.unresolved(reason); +``` + +Do not throw for entrypoints that have no source signal in the current file. Use the source gate and matched import/use collection to distinguish ordinary no-signal skips from unresolved candidates. + +- [ ] **Step 4: Update existing soft-skip tests to explicit off policy** + +Any existing test that intentionally expects `result` to be `null` for a configured source candidate with unresolved generated ownership must add `diagnostics: 'off'`. + +Update these known tests: + +- `create-api-client-fn.test.ts` + - `skips generic generated factories that receive services as an argument` + - `skips generated factories that receive an operation argument without services imports` +- `precreated-api-client.test.ts` + - `skips a precreated client whose generated factory has no static services import` + - `skips a precreated client when the imported factory module does not match the configured one` +- `schema-and-imports.test.ts` + - `skips schema access for generic factories that do not import services` + +Example config update: + +```ts +{ + diagnostics: 'off', + createAPIClientFn: [ + { name: 'createAPIClient', module: './api/createAPIClient' }, + ], +} +``` + +- [ ] **Step 5: Run diagnostics and skip tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/resolution-and-module-access.test.ts src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts src/__tests__/core/schema-and-imports.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit diagnostics behavior** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.ts \ + packages/tree-shaking-plugin/src/lib/transform/plan.ts \ + packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts +git commit -m "feat: enforce tree-shaking diagnostics policy" +``` + +Expected: one commit implementing default error diagnostics for unresolved candidates. + +## Milestone C: Planner, Mutator, And Diagnostics E2E Gate + +Run the fast in-place fixture loop after Task 6 because this milestone directly +changes emitted helper selection, runtime inputs, imports, and unresolved +candidate behavior. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +rm -rf e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cp -R packages/tree-shaking-plugin/dist \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cd e2e/projects/tree-shaking-bundlers +npm run codegen +npm run build +npm run e2e:post-build +``` + +Expected: `Tree-shaking bundle assertions passed.` + +Before ending the implementation session, run the full Verdaccio loop unless the +session is being intentionally paused for a known failing milestone: + +```bash +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +Expected: the copied fixture under `/Users/radist/w/qraft-e2e` builds and ends +with the same bundle assertion success message. + +## Task 7: Documentation And Full Verification + +**Files:** +- Modify: `packages/tree-shaking-plugin/README.md` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md` + +- [ ] **Step 1: Update README configuration docs** + +In `packages/tree-shaking-plugin/README.md`, under configuration options, add: + +```md +- `diagnostics` - controls unresolved transform candidates: + - `'error'` (default) throws when configured source looks transformable but + generated metadata or operation ownership cannot be proven. + - `'warn'` prints a warning and skips the candidate. + - `'off'` skips unresolved candidates silently. +``` + +Ensure the README documents only `diagnostics`; no legacy boolean diagnostics alias is part of the supported public or internal config surface. + +- [ ] **Step 2: Update core test guide if ownership changed** + +Open `packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md`. + +If new diagnostics behavior or metadata tests changed test ownership, add: + +```md +- `resolution-and-module-access.test.ts` + - Use for diagnostics behavior when generated modules cannot be resolved or loaded through module access. +``` + +If no core test ownership changed, leave this file untouched. + +- [ ] **Step 3: Run package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run +``` + +Expected: all tree-shaking-plugin tests pass. + +- [ ] **Step 4: Run package typecheck** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: no TypeScript errors. + +- [ ] **Step 5: Run package lint** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +``` + +Expected: no ESLint errors. + +- [ ] **Step 6: Run whitespace check** + +Run: + +```bash +git diff --check +``` + +Expected: no output. + +- [ ] **Step 7: Run final full e2e guard if needed** + +If Milestone C already ran the full Verdaccio loop after the last code change, +record that result here and do not rerun it just for README-only edits. If code, +package build output, e2e fixture assertions, or public package surface changed +after Milestone C, run the full loop again. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +Expected: `Tree-shaking bundle assertions passed.` + +If build fails because workspace build dependencies are stale, run: + +```bash +corepack yarn workspace @openapi-qraft/rollup-config build +corepack yarn workspace @qraft/test-utils build +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +Expected: the same e2e success message. + +- [ ] **Step 8: Commit docs and final cleanup** + +Run: + +```bash +git add packages/tree-shaking-plugin/README.md \ + packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md +git commit -m "docs: document tree-shaking diagnostics" +``` + +If `AGENTS.md` did not change, use: + +```bash +git add packages/tree-shaking-plugin/README.md +git commit -m "docs: document tree-shaking diagnostics" +``` + +Expected: one docs commit. + +## Self-Review + +- Spec coverage: The plan covers transform contract enforcement, diagnostics, entrypoint normalization, source gating, generated metadata inspection, normalized runtime inputs, test updates, README docs, package verification, and e2e verification. +- Scope control: The plan does not implement a generated manifest, automatic dev/build detection, optional-chain transforms, computed-property transforms, public generated-client type changes, or a full `TransformEditPlan` rewrite. +- Capability preservation: Generated factory config, pre-created client config, options factory config, generated React context config, schema rewrites, explicit options rewrites, and strict services ownership rules remain supported. +- Risk checkpoints: Each major layer has focused tests before implementation and a commit after passing verification. diff --git a/docs/superpowers/plans/2026-05-16-tree-shaking-session-1-5-public-config-alignment.md b/docs/superpowers/plans/2026-05-16-tree-shaking-session-1-5-public-config-alignment.md new file mode 100644 index 000000000..ebe517100 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-tree-shaking-session-1-5-public-config-alignment.md @@ -0,0 +1,432 @@ +# Tree-Shaking Session 1.5 Public Config Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the old public `createAPIClientFn` / `apiClient` config shape with a single `entrypoints` public API that matches the normalized entrypoint model. + +**Architecture:** Keep `normalizeEntrypoints()` as the only config boundary, but change its input from legacy flat fields to discriminated entrypoint objects. This is a breaking public config change and should happen before Session 2 wires source gating and generated metadata into runtime behavior. + +**Tech Stack:** TypeScript, Vitest, README docs, tree-shaking-bundlers e2e fixture. + +--- + +## Source Documents + +- Master plan: `docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md` +- Design spec: `docs/superpowers/specs/2026-05-16-tree-shaking-plugin-pipeline-architecture-design.md` +- Session 1 prerequisite: `docs/superpowers/plans/2026-05-16-tree-shaking-session-1-diagnostics-config-normalization.md` + +Use the master plan as the source for implementation context, but treat this +plan as the source of truth for public config naming: + +- Session 1 Task 2: `Normalize Public Config Into Entrypoints` +- Milestone A: `Diagnostics And Config Normalization E2E Gate` + +Translate any old public config snippets from the master plan or Session 1 +through this plan's `entrypoints` contract before implementing them. + +## Public Contract + +Use one `entrypoints` array with explicit module-export targets: + +```ts +type ModuleExportTarget = { + exportName: string; + moduleSpecifier: string; +}; + +type ReactContextTarget = { + exportName: string; + moduleSpecifier?: string; +}; + +type QraftClientFactoryEntrypointConfig = { + kind: 'clientFactory'; + factory: ModuleExportTarget; + reactContext?: ReactContextTarget; +}; + +type QraftPrecreatedClientEntrypointConfig = { + kind: 'precreatedClient'; + client: ModuleExportTarget; + factory: ModuleExportTarget; + optionsFactory: ModuleExportTarget; +}; + +type QraftEntrypointConfig = + | QraftClientFactoryEntrypointConfig + | QraftPrecreatedClientEntrypointConfig; + +type QraftTreeShakeOptions = { + entrypoints?: QraftEntrypointConfig[]; + diagnostics?: DiagnosticsLevel; +}; +``` + +Example: + +```ts +qraftTreeShakeVite({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createReactAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: './api/APIClientContext', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'nodeAPIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: './create-node-api-client', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], +}); +``` + +Naming decisions: + +- `entrypoints` is the only public top-level collection because both modes are + plugin entrypoints into app/generated code; +- `kind: 'clientFactory'` replaces public `createAPIClientFn` naming because + users configure a client factory import, not a generator implementation detail; +- `kind: 'precreatedClient'` keeps the established runtime concept; +- `factory`, `client`, `reactContext`, and `optionsFactory` mirror the internal + normalized model. + +Normalization rules: + +- `entrypoints[].kind === 'clientFactory'` maps to internal `kind: 'generatedFactory'`; +- `clientFactory.factory` maps directly to `GeneratedFactoryEntrypoint.factory`; +- `clientFactory.reactContext` maps to `GeneratedFactoryEntrypoint.reactContext`; +- if `reactContext.moduleSpecifier` is omitted, normalize it to `null`; +- if `reactContext` is omitted, normalize it to `null`; +- `entrypoints[].kind === 'precreatedClient'` maps to internal `kind: 'precreatedClient'`; +- `precreatedClient.client`, `.factory`, and `.optionsFactory` map directly to the normalized precreated entrypoint; +- do not carry raw public config as `legacyConfig`; +- do not keep `createAPIClientFn` / `apiClient` compatibility aliases in the final public type for this branch. + +## Files + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/core.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/*.test.ts` +- Modify: `packages/tree-shaking-plugin/README.md` +- Modify: `e2e/projects/tree-shaking-bundlers/**/*.ts` +- Modify: `e2e/projects/tree-shaking-bundlers/**/*.mjs` +- Modify: `docs/superpowers/plans/2026-05-16-tree-shaking-session-2-source-gate-generated-metadata.md` +- Modify: `docs/superpowers/plans/2026-05-16-tree-shaking-session-3-planner-mutator-normalized-model.md` + +## Task 1: Update Public Types And Normalizer Tests + +- [x] **Step 1: Add the new public target types** + +In `packages/tree-shaking-plugin/src/lib/transform/types.ts`, replace the public config types with: + +```ts +export type ModuleExportTarget = { + exportName: string; + moduleSpecifier: string; +}; + +export type ReactContextTarget = { + exportName: string; + moduleSpecifier?: string; +}; + +export type QraftClientFactoryEntrypointConfig = { + kind: 'clientFactory'; + factory: ModuleExportTarget; + reactContext?: ReactContextTarget; +}; + +export type QraftPrecreatedClientEntrypointConfig = { + kind: 'precreatedClient'; + client: ModuleExportTarget; + factory: ModuleExportTarget; + optionsFactory: ModuleExportTarget; +}; + +export type QraftEntrypointConfig = + | QraftClientFactoryEntrypointConfig + | QraftPrecreatedClientEntrypointConfig; +``` + +Keep the normalized internal `ReactContextConfig` as: + +```ts +export type ReactContextConfig = { + exportName: string; + moduleSpecifier: string | null; +}; +``` + +- [x] **Step 2: Rename option fields in `QraftTreeShakeOptions`** + +In `packages/tree-shaking-plugin/src/lib/transform/types.ts`, replace: + +```ts +createAPIClientFn?: QraftFactoryConfig[]; +apiClient?: QraftPrecreatedClientConfig[]; +``` + +with: + +```ts +entrypoints?: QraftEntrypointConfig[]; +``` + +Make the same public option change in `packages/tree-shaking-plugin/src/core.ts`. + +- [x] **Step 3: Update normalizer tests first** + +In `packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts`, change the fixtures to the new public shape. + +Client factory case: + +```ts +normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createReactAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: './api/APIClientContext', + }, + }, + ], +}); +``` + +Precreated case: + +```ts +normalizeEntrypoints({ + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'nodeAPIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], +}); +``` + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/entrypoints.test.ts +``` + +Expected: FAIL until `normalizeEntrypoints()` reads `options.entrypoints` and the discriminated shapes. + +- [x] **Step 4: Update `normalizeEntrypoints()`** + +In `packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts`: + +- read `options.entrypoints ?? []`; +- for `kind: 'clientFactory'`, produce internal `kind: 'generatedFactory'`; +- for `kind: 'clientFactory'`, map `factory.exportName` and `factory.moduleSpecifier`; +- for `kind: 'clientFactory'`, normalize `reactContext.moduleSpecifier ?? null`; +- for `kind: 'precreatedClient'`, map `client`, `factory`, and `optionsFactory` directly. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/entrypoints.test.ts +``` + +Expected: PASS. + +## Task 2: Update Transform Tests And Current Runtime Code + +- [x] **Step 1: Replace old config keys in core tests** + +In `packages/tree-shaking-plugin/src/__tests__/core/*.test.ts`, replace: + +- `createAPIClientFn: [{ name, module, context, contextModule }]` +- `apiClient: [{ client, clientModule, createAPIClientFn, createAPIClientFnModule, createAPIClientFnOptions, createAPIClientFnOptionsModule }]` + +with: + +- `entrypoints: [{ kind: 'clientFactory', factory: { exportName: name, moduleSpecifier: module }, reactContext: context ? { exportName: context, moduleSpecifier: contextModule } : undefined }]` +- `entrypoints: [{ kind: 'precreatedClient', client: { exportName: client, moduleSpecifier: clientModule }, factory: { exportName: createAPIClientFn, moduleSpecifier: createAPIClientFnModule }, optionsFactory: { exportName: createAPIClientFnOptions, moduleSpecifier: createAPIClientFnOptionsModule ?? clientModule } }]` + +When a test uses both modes, put both objects in the same `entrypoints` array. +Prefer a local test helper only if it removes repeated mechanical mapping +without hiding the public config shape in snapshots. + +- [x] **Step 2: Update existing planner code minimally** + +Until Session 2 rewires the planner through normalized entrypoints, adapt `packages/tree-shaking-plugin/src/lib/transform/plan.ts` to read the new public shape. + +Allowed temporary adapter: + +```ts +const entrypoints = options.entrypoints ?? []; + +const factoryOptions = entrypoints + .filter((entrypoint) => entrypoint.kind === 'clientFactory') + .map((entrypoint) => ({ + name: entrypoint.factory.exportName, + module: entrypoint.factory.moduleSpecifier, + context: entrypoint.reactContext?.exportName, + contextModule: entrypoint.reactContext?.moduleSpecifier, + })); +``` + +Allowed temporary adapter for precreated config: + +```ts +const precreatedOptions = entrypoints + .filter((entrypoint) => entrypoint.kind === 'precreatedClient') + .map((entrypoint) => ({ + client: entrypoint.client.exportName, + clientModule: entrypoint.client.moduleSpecifier, + createAPIClientFn: entrypoint.factory.exportName, + createAPIClientFnModule: entrypoint.factory.moduleSpecifier, + createAPIClientFnOptions: entrypoint.optionsFactory.exportName, + createAPIClientFnOptionsModule: entrypoint.optionsFactory.moduleSpecifier, + })); +``` + +This adapter is temporary. Session 2 should remove it when generated metadata consumes normalized entrypoints directly. + +- [x] **Step 3: Run core transform tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/explicit-options.test.ts src/__tests__/core/precreated-api-client.test.ts src/__tests__/core/mixed-client-modes.test.ts src/__tests__/core/schema-and-imports.test.ts src/__tests__/core/resolution-and-module-access.test.ts src/__tests__/core/unsupported-and-safety.test.ts +``` + +Expected: PASS without semantic snapshot drift. + +## Task 3: Update Docs And E2E Fixture Config + +- [x] **Step 1: Update README config sections** + +In `packages/tree-shaking-plugin/README.md`: + +- replace the `createAPIClientFn` and `apiClient` public sections with one `entrypoints` section; +- document `kind: 'clientFactory'` and `kind: 'precreatedClient'`; +- show `factory`, `reactContext`, `client`, and `optionsFactory` targets; +- remove docs that explain `clientModule`, `createAPIClientFnModule`, and `createAPIClientFnOptionsModule`. + +- [x] **Step 2: Update tree-shaking-bundlers fixture config** + +Update plugin config in `e2e/projects/tree-shaking-bundlers` to use: + +```ts +entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createRelativeAPIClient', + moduleSpecifier: './src/generated-api/create-relative-api-client', + }, + reactContext: { + exportName: 'RelativeAPIClientContext', + moduleSpecifier: './src/generated-api/create-relative-api-client', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'relativeAPIClient', + moduleSpecifier: './src/precreated/clients/file-relative', + }, + factory: { + exportName: 'createRelativePrecreatedAPIClient', + moduleSpecifier: './src/generated-api/create-relative-precreated-api-client', + }, + optionsFactory: { + exportName: 'createRelativeClientOptions', + moduleSpecifier: './src/precreated/options/direct', + }, + }, +], +``` + +Use each fixture's existing names and paths; do not change fixture behavior. + +- [x] **Step 3: Update plan references for Sessions 2-4** + +Replace implementation instructions that mention public `createAPIClientFn` / +`apiClient`, `generatedFactories`, or `precreatedClients` config with +`entrypoints` where they describe the target public config. + +Keep historical references only when describing old behavior in completed plans. + +## Task 4: Verification + +- [x] **Step 1: Run package checks** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +git diff --check +``` + +Expected: PASS. + +- [x] **Step 2: Run fast e2e gate** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +rm -rf e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cp -R packages/tree-shaking-plugin/dist \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cd e2e/projects/tree-shaking-bundlers +npm run codegen +npm run build +npm run e2e:post-build +``` + +Expected: `Tree-shaking bundle assertions passed.` + +- [x] **Step 3: Commit** + +Run: + +```bash +git add packages/tree-shaking-plugin e2e/projects/tree-shaking-bundlers docs/superpowers +git commit -m "refactor: align tree-shaking public config with entrypoints" +``` + +Expected: one commit containing the public config break, tests, README, and fixture updates. diff --git a/docs/superpowers/plans/2026-05-16-tree-shaking-session-1-diagnostics-config-normalization.md b/docs/superpowers/plans/2026-05-16-tree-shaking-session-1-diagnostics-config-normalization.md new file mode 100644 index 000000000..5c78f6fe4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-tree-shaking-session-1-diagnostics-config-normalization.md @@ -0,0 +1,249 @@ +# Tree-Shaking Session 1 Diagnostics And Config Normalization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the explicit diagnostics policy and normalize public tree-shaking config into internal entrypoints without changing transform semantics. + +**Architecture:** This session works below the planner/mutator behavior. It introduces `diagnostics.ts` and `entrypoints.ts`, wires only the minimum needed into public types and `core.ts`, and leaves generated-source inspection and rewrite semantics for later sessions. + +**Tech Stack:** TypeScript, Vitest, unplugin options, existing `@openapi-qraft/tree-shaking-plugin` test harness. + +--- + +## Source Documents + +- Master plan: `docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md` +- Design spec: `docs/superpowers/specs/2026-05-16-tree-shaking-plugin-pipeline-architecture-design.md` + +Use the master plan as the source for exact test bodies and type snippets: + +- Task 1: `Add Diagnostics Contract` +- Task 2: `Normalize Public Config Into Entrypoints` +- Milestone A: `Diagnostics And Config Normalization E2E Gate` + +## Scope + +Implement: + +- `diagnostics?: 'error' | 'warn' | 'off'`; +- default diagnostics policy as `error`; +- `QraftTreeShakeError`; +- structured diagnostic reasons; +- `normalizeEntrypoints(...)`; +- `ClientEntrypoint[]` and import-target/runtime-context config types. + +Do not implement: + +- source gate; +- generated metadata inspection extraction; +- runtime helper selection changes; +- snapshot updates unless existing tests require mechanical import/type updates. + +## Files + +- Create: `packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/core.ts` + +## Task 1: Diagnostics Contract + +- [x] **Step 1: Read the contract** + +Read the master plan sections: + +```bash +sed -n '/## Transform Criteria Matrix/,/## E2E Verification Strategy/p' docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md +sed -n '/## Task 1: Add Diagnostics Contract/,/## Task 2:/p' docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md +``` + +Expected: the session implementer sees the diagnostics levels, ordinary-skip rule, unresolved-candidate rule, and the exact `diagnostics.test.ts` test cases. + +- [x] **Step 2: Add diagnostics tests first** + +Create `packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts` using the test body from master Task 1 Step 1. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/diagnostics.test.ts +``` + +Expected: FAIL because `diagnostics.ts` does not exist yet. + +- [x] **Step 3: Implement diagnostics types and reporter** + +Update `packages/tree-shaking-plugin/src/lib/transform/types.ts`, `packages/tree-shaking-plugin/src/core.ts`, and create `packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts` using master Task 1 Steps 3-5. + +Required exported names: + +- `DiagnosticsLevel`; +- `DiagnosticLayer`; +- `DiagnosticReason`; +- `QraftTreeShakeError`; +- `DiagnosticReporter`; +- `createDiagnosticReporter(...)`; +- `formatDiagnosticReason(...)`. + +- [x] **Step 4: Verify diagnostics** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/diagnostics.test.ts +``` + +Expected: PASS. + +- [x] **Step 5: Commit diagnostics** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/core.ts \ + packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts \ + packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts +git commit -m "feat: add tree-shaking diagnostics policy" +``` + +Expected: one focused diagnostics commit. + +Completed in commit `a8ceee0e feat: add tree-shaking diagnostics policy`. + +## Task 2: Entrypoint Normalization + +- [x] **Step 1: Read the config-normalization task** + +Run: + +```bash +sed -n '/## Task 2: Normalize Public Config Into Entrypoints/,/## Milestone A:/p' docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md +``` + +Expected: the session implementer sees the exact `entrypoints.test.ts` cases and normalized type shapes. + +- [x] **Step 2: Add entrypoint tests first** + +Create `packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts` using master Task 2 Step 1. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/entrypoints.test.ts +``` + +Expected: FAIL because `entrypoints.ts` does not exist yet. + +- [x] **Step 3: Implement normalized entrypoint types** + +Update `packages/tree-shaking-plugin/src/lib/transform/types.ts` using master Task 2 Step 3. + +Required model: + +- `ImportTarget`; +- `ReactContextConfig`; +- `GeneratedFactoryEntrypoint`; +- `PrecreatedClientEntrypoint`; +- `ClientEntrypoint`. + +- [x] **Step 4: Implement `normalizeEntrypoints(...)`** + +Create `packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts` using master Task 2 Step 4. + +Required behavior: + +- normalize `createAPIClientFn` into `kind: 'generatedFactory'`; +- normalize `apiClient` into `kind: 'precreatedClient'`; +- keep legacy public config support at the `normalizeEntrypoints()` boundary + without carrying raw config as `legacyConfig` in normalized entries; +- compose stable keys from kind, export name, and module specifier. + +- [x] **Step 5: Verify entrypoints** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/entrypoints.test.ts +``` + +Expected: PASS. + +- [x] **Step 6: Commit entrypoints** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts \ + packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts +git commit -m "refactor: normalize tree-shaking entrypoints" +``` + +Expected: one focused entrypoint-normalization commit. + +Completed in commit `667870e7 refactor: normalize tree-shaking entrypoints`. + +## Milestone A Verification + +- [x] **Step 1: Run package-level tests touched by this session** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/diagnostics.test.ts src/lib/transform/entrypoints.test.ts +``` + +Expected: PASS. + +Completed: PASS, `13 passed`, `97 passed`. + +- [x] **Step 2: Run typecheck** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: no TypeScript errors. + +Completed: PASS. + +- [x] **Step 3: Run the e2e gate when code is wired into `core.ts`** + +Run this when Task 1 or Task 2 changed executable plugin paths: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +rm -rf e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cp -R packages/tree-shaking-plugin/dist \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cd e2e/projects/tree-shaking-bundlers +npm run codegen +npm run build +npm run e2e:post-build +``` + +Expected: `Tree-shaking bundle assertions passed.` + +- [x] **Step 4: Record intentional e2e skip** + +If Tasks 1-2 stayed entirely in helper modules not executed by the bundled fixture, write the skip reason in the session final response and run Milestone B in Session 2. + +Completed: e2e intentionally skipped. Session 1 added diagnostics and entrypoint +helper modules plus public type surface, but did not wire the new modules into +the runtime transform path. The fast e2e gate should run in Session 2 after the +source gate or generated metadata boundary reaches executable plugin behavior. + +Additional completed checks: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +git diff --check +``` + +Expected: PASS. diff --git a/docs/superpowers/plans/2026-05-16-tree-shaking-session-2-source-gate-generated-metadata.md b/docs/superpowers/plans/2026-05-16-tree-shaking-session-2-source-gate-generated-metadata.md new file mode 100644 index 000000000..b0e65f84e --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-tree-shaking-session-2-source-gate-generated-metadata.md @@ -0,0 +1,244 @@ +# Tree-Shaking Session 2 Source Gate And Generated Metadata Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the pre-parse source gate and extract generated-source inspection behind a testable metadata boundary. + +**Architecture:** This session depends on Session 1's normalized entrypoints. It adds `source-gate.ts` and `generated-metadata.ts`, then makes the old planner able to call the new metadata inspector through an adapter without rewriting helper selection or mutation semantics yet. + +**Tech Stack:** TypeScript, Babel parser/traverse, module access adapters, Vitest, multi-bundler e2e fixture. + +--- + +## Source Documents + +- Master plan: `docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md` +- Design spec: `docs/superpowers/specs/2026-05-16-tree-shaking-plugin-pipeline-architecture-design.md` +- Session 1 prerequisite: `docs/superpowers/plans/2026-05-16-tree-shaking-session-1-diagnostics-config-normalization.md` +- Session 1.5 prerequisite: `docs/superpowers/plans/2026-05-16-tree-shaking-session-1-5-public-config-alignment.md` + +Run Session 1.5 before this plan if the branch still exposes legacy top-level +client-family options. This session should consume the public `entrypoints` +config shape with `kind: 'clientFactory'` and `kind: 'precreatedClient'`. + +Use the master plan as the source for test bodies and type snippets, but +translate any older public config snippets through the Session 1.5 contract: +`entrypoints` with `kind: 'clientFactory' | 'precreatedClient'`. + +- Task 3: `Add The Pre-Parse Source Gate` +- Task 4: `Extract Generated Metadata Inspection` +- Milestone B: `Source Gate And Generated Metadata E2E Gate` + +## Scope + +Implement: + +- `shouldInspectSource(...)`; +- conservative source-gate behavior; +- independent generated metadata inspection; +- generated factory services ownership proof; +- pre-created client export/factory matching; +- structured diagnostic reasons for unresolved metadata. + +Do not implement: + +- normalized runtime input in `ClientBinding`; +- planner/mutator semantic rewrite changes; +- default diagnostics enforcement in transform candidates; +- README changes. + +## Files + +- Create: `packages/tree-shaking-plugin/src/lib/transform/source-gate.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/source-gate.test.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts` +- Create: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts` +- Modify: `packages/tree-shaking-plugin/src/core.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` + +## Task 1: Pre-Parse Source Gate + +- [x] **Step 1: Read the source-gate task** + +Run: + +```bash +sed -n '/## Task 3: Add The Pre-Parse Source Gate/,/## Task 4:/p' docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md +``` + +Expected: the session implementer sees exact source-gate test cases and implementation rules. + +- [x] **Step 2: Add source-gate tests first** + +Create `packages/tree-shaking-plugin/src/lib/transform/source-gate.test.ts` using master Task 3 Step 1. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/source-gate.test.ts +``` + +Expected: FAIL because `source-gate.ts` does not exist yet. + +- [x] **Step 3: Implement `shouldInspectSource(...)`** + +Create `packages/tree-shaking-plugin/src/lib/transform/source-gate.ts` using master Task 3 Steps 3-4. + +Required behavior: + +- skip when no entrypoints are configured; +- skip obvious non-source and `node_modules` ids; +- respect include/exclude filters; +- inspect when the source contains configured names, module specifiers, or static member-chain hints; +- prefer parsing when uncertain. + +- [x] **Step 4: Wire source gate into `core.ts`** + +Update `packages/tree-shaking-plugin/src/core.ts` using master Task 3 Step 5. + +Required behavior: + +- compute normalized entrypoints before parse; +- return `null` before parse for ordinary source-gate skips; +- do not throw diagnostics for ordinary source-gate skips. + +- [x] **Step 5: Verify source gate** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/source-gate.test.ts src/__tests__/core/harness.test.ts +``` + +Expected: PASS. + +- [x] **Step 6: Commit source gate** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/source-gate.ts \ + packages/tree-shaking-plugin/src/lib/transform/source-gate.test.ts \ + packages/tree-shaking-plugin/src/core.ts +git commit -m "perf: add tree-shaking source inspection gate" +``` + +Expected: one focused source-gate commit. + +## Task 2: Generated Metadata Inspection + +- [x] **Step 1: Read the generated-metadata task** + +Run: + +```bash +sed -n '/## Task 4: Extract Generated Metadata Inspection/,/## Milestone B:/p' docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md +``` + +Expected: the session implementer sees exact generated metadata tests, return types, and extraction boundaries. + +- [x] **Step 2: Add generated-metadata tests first** + +Create `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts` using master Task 4 Step 1. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/generated-metadata.test.ts +``` + +Expected: FAIL because `generated-metadata.ts` does not exist yet. + +- [x] **Step 3: Add metadata result types** + +Update `packages/tree-shaking-plugin/src/lib/transform/types.ts` using master Task 4 Step 3. + +Required model: + +- `GeneratedFactoryMetadata`; +- `PrecreatedClientMetadata`; +- `GeneratedEntrypointMetadata`; +- `GeneratedMetadataResult`. + +- [x] **Step 4: Extract generated-source inspection** + +Create `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts` using master Task 4 Step 4. + +Required behavior: + +- resolve and load configured factory/client modules through `moduleAccess`; +- read generated factory services imports; +- read service operation import paths; +- traverse re-export chains already supported by current planner helpers; +- validate pre-created client export against configured factory export/module; +- return structured `DiagnosticReason` values instead of direct debug skips. + +- [x] **Step 5: Keep the legacy planner compiling through an adapter** + +Update `packages/tree-shaking-plugin/src/lib/transform/plan.ts` using master Task 4 Step 5. + +Required behavior: + +- call `normalizeEntrypoints(options)`; +- call `inspectGeneratedEntrypoints(...)`; +- keep old planner maps if needed for compatibility in this session; +- leave full helper-selection rewiring for Session 3. + +- [x] **Step 6: Verify metadata and core behavior** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/generated-metadata.test.ts src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts +``` + +Expected: PASS. + +- [x] **Step 7: Commit metadata boundary** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts \ + packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts \ + packages/tree-shaking-plugin/src/lib/transform/plan.ts +git commit -m "refactor: extract generated metadata inspection" +``` + +Expected: one focused metadata-boundary commit. + +## Milestone B Verification + +- [x] **Step 1: Run focused package checks** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/source-gate.test.ts src/lib/transform/generated-metadata.test.ts src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: tests pass and typecheck reports no TypeScript errors. + +- [x] **Step 2: Run fast e2e gate** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +rm -rf e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cp -R packages/tree-shaking-plugin/dist \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cd e2e/projects/tree-shaking-bundlers +npm run codegen +npm run build +npm run e2e:post-build +``` + +Expected: `Tree-shaking bundle assertions passed.` + +- [x] **Step 3: Debug e2e failures without weakening assertions** + +If one bundler fails, inspect its output under `e2e/projects/tree-shaking-bundlers/dist` and identify whether the root cause is resolver/module-access behavior, source-gate false negative, or generated metadata extraction. diff --git a/docs/superpowers/plans/2026-05-16-tree-shaking-session-3-planner-mutator-normalized-model.md b/docs/superpowers/plans/2026-05-16-tree-shaking-session-3-planner-mutator-normalized-model.md new file mode 100644 index 000000000..3be568692 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-tree-shaking-session-3-planner-mutator-normalized-model.md @@ -0,0 +1,267 @@ +# Tree-Shaking Session 3 Planner Mutator Normalized Model Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Route planning and mutation through normalized runtime inputs, enforce diagnostics for unresolved candidates, and preserve the agreed tree-shaking semantics. + +**Architecture:** This session consumes Session 1 entrypoints and Session 2 generated metadata. It changes `plan.ts` and `mutate.ts` so helper selection depends on normalized client runtime input instead of legacy flags, then enforces diagnostics behavior at transform boundaries. + +**Tech Stack:** TypeScript, Babel AST, Vitest inline snapshots, source-map tests, multi-bundler e2e fixture. + +--- + +## Source Documents + +- Master plan: `docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md` +- Design spec: `docs/superpowers/specs/2026-05-16-tree-shaking-plugin-pipeline-architecture-design.md` +- Session 1 prerequisite: `docs/superpowers/plans/2026-05-16-tree-shaking-session-1-diagnostics-config-normalization.md` +- Session 1.5 prerequisite: `docs/superpowers/plans/2026-05-16-tree-shaking-session-1-5-public-config-alignment.md` +- Session 2 prerequisite: `docs/superpowers/plans/2026-05-16-tree-shaking-session-2-source-gate-generated-metadata.md` + +Use the master plan as the source for test bodies and type snippets, but +translate any older public config snippets through the Session 1.5 contract: +`entrypoints` with `kind: 'clientFactory' | 'precreatedClient'`. + +- Task 5: `Route Planner Through Normalized Entrypoints And Metadata` +- Task 6: `Enforce Diagnostics In Core Transform Behavior` +- Milestone C: `Planner, Mutator, And Diagnostics E2E Gate` + +## Scope + +Implement: + +- normalized `RuntimeInput`; +- planner binding population from metadata; +- helper/argument selection from runtime input; +- explicit-options rewrite through `qraftAPIClient`; +- pre-created rewrite through `qraftAPIClient(..., optionsFactory())`; +- context zero-arg rewrite through `qraftReactAPIClient` when required; +- `.schema` direct operation rewrites without runtime helper; +- diagnostics enforcement for unresolved transform candidates. + +Do not implement: + +- optional-chain rewrite support; +- computed property rewrite support; +- public generated-client API changes; +- full `TransformEditPlan` redesign beyond what is needed to remove legacy branching. + +## Files + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` +- Modify: `packages/tree-shaking-plugin/src/core.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts` + +## Task 1: Planner And Mutator Runtime Inputs + +- [x] **Step 1: Read the semantic contract** + +Run: + +```bash +sed -n '/## Transform Criteria Matrix/,/## E2E Verification Strategy/p' docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md +sed -n '/## Task 5: Route Planner Through Normalized Entrypoints And Metadata/,/## Task 6:/p' docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md +``` + +Expected: the session implementer sees the exact runtime-input type, focused regressions, and semantic signals. + +- [x] **Step 2: Add planner regressions first** + +Add the tests from master Task 5 Step 1 to the relevant core test files. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/explicit-options.test.ts +``` + +Expected: FAIL if current helper selection still follows legacy flags; PASS is acceptable when existing code already satisfies a regression. + +- [x] **Step 3: Add `RuntimeInput` to transform types** + +Update `packages/tree-shaking-plugin/src/lib/transform/types.ts` using master Task 5 Step 3. + +Required variants: + +- `{ kind: 'none' }`; +- `{ kind: 'context'; context: ReactContextConfig }`; +- `{ kind: 'optionsExpression'; expression: t.Expression }`; +- `{ kind: 'optionsFactoryCall'; target: ImportTarget }`. + +- [x] **Step 4: Populate runtime input for local generated clients** + +Update `packages/tree-shaking-plugin/src/lib/transform/plan.ts` using master Task 5 Step 4. + +Required behavior: + +- zero-argument context-backed generated clients produce `runtimeInput.kind === 'context'`; +- `createAPIClient(optionsExpression)` produces `runtimeInput.kind === 'optionsExpression'`; +- invalid or ambiguous call shapes stay untransformed. + +- [x] **Step 5: Populate runtime input for pre-created clients** + +Update `packages/tree-shaking-plugin/src/lib/transform/plan.ts` using master Task 5 Step 5. + +Required behavior: + +- configured pre-created clients produce `runtimeInput.kind === 'optionsFactoryCall'`; +- the options factory target comes from normalized entrypoint/metadata; +- pre-created clients never choose `qraftReactAPIClient` in this design. + +- [x] **Step 6: Update mutation helper selection** + +Update `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` using master Task 5 Step 6. + +Required behavior: + +- context runtime input emits `qraftReactAPIClient` only for callbacks that require context semantics; +- explicit-options runtime input emits `qraftAPIClient(operation, callbacks, optionsExpression)`; +- pre-created runtime input emits `qraftAPIClient(operation, callbacks, optionsFactory())`; +- `.schema` emits direct `operation.schema` and imports no runtime helper. + +- [x] **Step 7: Run core transform suites** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/explicit-options.test.ts src/__tests__/core/precreated-api-client.test.ts src/__tests__/core/mixed-client-modes.test.ts src/__tests__/core/schema-and-imports.test.ts +``` + +Expected: PASS after intentional snapshot updates. + +Verify these semantic signals before accepting snapshot updates: + +- context zero-arg hook usage preserves context runtime; +- explicit options usage passes the original options expression; +- pre-created usage calls configured options factory; +- schema usage imports no runtime helper; +- unsupported references keep original clients alive. + +- [x] **Step 8: Commit normalized planner wiring** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/lib/transform/plan.ts \ + packages/tree-shaking-plugin/src/lib/transform/mutate.ts \ + packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts +git commit -m "refactor: route tree-shaking through normalized runtime inputs" +``` + +Expected: one commit removing legacy runtime-helper branching where the normalized model replaces it. + +## Task 2: Diagnostics Enforcement + +- [x] **Step 1: Read diagnostics enforcement task** + +Run: + +```bash +sed -n '/## Task 6: Enforce Diagnostics In Core Transform Behavior/,/## Milestone C:/p' docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md +``` + +Expected: the session implementer sees the exact core diagnostics tests and expected error/warn/off behavior. + +- [x] **Step 2: Add core diagnostics behavior tests first** + +Update the core tests listed in master Task 6 Step 1. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/resolution-and-module-access.test.ts src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts src/__tests__/core/schema-and-imports.test.ts +``` + +Expected: FAIL where unresolved candidates still silently skip by default. + +- [x] **Step 3: Enforce diagnostics in core/planner** + +Update `packages/tree-shaking-plugin/src/core.ts` and `packages/tree-shaking-plugin/src/lib/transform/plan.ts` using master Task 6 Steps 3-5. + +Required behavior: + +- ordinary skips remain silent; +- unresolved transform candidates throw by default; +- `diagnostics: 'warn'` warns and skips; +- `diagnostics: 'off'` skips silently; +- old soft-skip tests set `diagnostics: 'off'` only when the skipped behavior is intentional. + +- [x] **Step 4: Run diagnostics and core tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/diagnostics.test.ts src/__tests__/core/resolution-and-module-access.test.ts src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts src/__tests__/core/schema-and-imports.test.ts +``` + +Expected: PASS. + +- [x] **Step 5: Commit diagnostics enforcement** + +Run: + +```bash +git add packages/tree-shaking-plugin/src/core.ts \ + packages/tree-shaking-plugin/src/lib/transform/plan.ts \ + packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts +git commit -m "feat: enforce tree-shaking diagnostics policy" +``` + +Expected: one commit implementing default error diagnostics for unresolved candidates. + +## Milestone C Verification + +- [x] **Step 1: Run package checks** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +git diff --check +``` + +Expected: tests pass, typecheck has no TypeScript errors, lint has no ESLint errors, and `git diff --check` prints no output. + +- [x] **Step 2: Run fast e2e gate** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +rm -rf e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cp -R packages/tree-shaking-plugin/dist \ + e2e/projects/tree-shaking-bundlers/node_modules/@openapi-qraft/tree-shaking-plugin/dist +cd e2e/projects/tree-shaking-bundlers +npm run codegen +npm run build +npm run e2e:post-build +``` + +Expected: `Tree-shaking bundle assertions passed.` + +- [x] **Step 3: Run full Verdaccio e2e before handoff** + +Run: + +```bash +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +Expected: copied fixture under `/Users/radist/w/qraft-e2e` builds and ends with `Tree-shaking bundle assertions passed.` diff --git a/docs/superpowers/plans/2026-05-16-tree-shaking-session-4-debt-docs-final-verification.md b/docs/superpowers/plans/2026-05-16-tree-shaking-session-4-debt-docs-final-verification.md new file mode 100644 index 000000000..d05506b01 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-tree-shaking-session-4-debt-docs-final-verification.md @@ -0,0 +1,224 @@ +# Tree-Shaking Session 4 Debt Docs And Final Verification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Delete obsolete tree-shaking plugin debt left after Sessions 1-3, update public docs, and run final verification. + +**Architecture:** This session is intentionally cleanup-oriented. It should remove compatibility branches only when the normalized model already handles the behavior, update README/test routing docs, and avoid changing transform semantics except for confirmed debt deletion. + +**Tech Stack:** TypeScript, Vitest, ESLint, README docs, multi-bundler e2e fixture. + +--- + +## Source Documents + +- Master plan: `docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md` +- Design spec: `docs/superpowers/specs/2026-05-16-tree-shaking-plugin-pipeline-architecture-design.md` +- Session 3 prerequisite: `docs/superpowers/plans/2026-05-16-tree-shaking-session-3-planner-mutator-normalized-model.md` + +Use the master plan as the source for exact README text and final verification: + +- Task 7: `Documentation And Full Verification` +- Self-review section + +## Scope + +Implement: + +- README `diagnostics?: 'error' | 'warn' | 'off'` documentation; +- core test guide update when ownership changed; +- deletion of dead branches made obsolete by normalized entrypoints/runtime inputs; +- final package and e2e verification. + +Do not implement: + +- new transform features; +- new public generated-client APIs; +- optional-chain/computed-access rewrites; +- broad formatter-only churn. + +## Files + +- Modify: `packages/tree-shaking-plugin/README.md` +- Modify when ownership changed: `packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md` +- Modify when dead after Sessions 1-3: `packages/tree-shaking-plugin/src/core.ts` +- Modify when dead after Sessions 1-3: `packages/tree-shaking-plugin/src/lib/transform/plan.ts` +- Modify when dead after Sessions 1-3: `packages/tree-shaking-plugin/src/lib/transform/mutate.ts` +- Modify when dead after Sessions 1-3: `packages/tree-shaking-plugin/src/lib/transform/types.ts` + +## Task 1: Debt Deletion Sweep + +- [x] **Step 1: Find legacy branches and helpers** + +Run: + +```bash +rg -n "debugSkip|hasExplicitContext|entrypoints|diagnostics" packages/tree-shaking-plugin/src packages/tree-shaking-plugin/README.md +``` + +Expected: results show which legacy config reads, diagnostics paths, and runtime-helper flags remain. + +- [x] **Step 2: Classify each hit** + +Use this classification: + +- keep public `entrypoints` only at config normalization/public API boundaries; +- delete internal reads of old top-level public config options after Session 1.5; +- delete internal reads of rejected pre-normalized config shapes; +- delete internal reads of `options.entrypoints` after normalization; +- delete `hasExplicitContext` after `runtimeInput` fully replaces it; +- delete `debugSkip` after diagnostics reporter handles unresolved candidates and ordinary skips. + +- [x] **Step 3: Delete obsolete internal branches** + +Edit only the files where Step 2 found dead internal paths. Preserve the current public `entrypoints` boundary. + +Required result: + +```bash +rg -n "hasExplicitContext|debugSkip" packages/tree-shaking-plugin/src +``` + +Expected: no matches, unless a match is in a historical test name or migration comment that explains why it still exists. + +- [x] **Step 4: Verify transform behavior after deletion** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/explicit-options.test.ts src/__tests__/core/precreated-api-client.test.ts src/__tests__/core/mixed-client-modes.test.ts src/__tests__/core/schema-and-imports.test.ts src/__tests__/core/unsupported-and-safety.test.ts +``` + +Expected: PASS without new snapshot changes. If snapshots change, verify they are semantic no-ops or revert the debt deletion that caused the semantic drift. + +- [x] **Step 5: Commit debt deletion** + +Run: + +```bash +git add packages/tree-shaking-plugin/src +git commit -m "refactor: remove legacy tree-shaking transform branches" +``` + +Expected: one cleanup commit, or no commit if Step 2 found no removable debt. + +## Task 2: Documentation And Test Routing + +- [x] **Step 1: Read final documentation task** + +Run: + +```bash +sed -n '/## Task 7: Documentation And Full Verification/,/## Self-Review/p' docs/superpowers/plans/2026-05-16-tree-shaking-plugin-pipeline-architecture.md +``` + +Expected: the session implementer sees the exact README diagnostics wording and verification commands. + +- [x] **Step 2: Update README diagnostics docs** + +In `packages/tree-shaking-plugin/README.md`, document: + +```md +- `diagnostics` - controls unresolved transform candidates: + - `'error'` (default) throws when configured source looks transformable but + generated metadata or operation ownership cannot be proven. + - `'warn'` prints a warning and skips the candidate. + - `'off'` skips unresolved candidates silently. +``` + +Ensure public-facing wording documents only `diagnostics`; no legacy boolean diagnostics alias is part of the supported public or internal config surface. + +- [x] **Step 3: Update core test ownership guide** + +Open `packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md`. + +If Sessions 1-3 changed diagnostics or metadata test ownership, add: + +```md +- `resolution-and-module-access.test.ts` + - Use for diagnostics behavior when generated modules cannot be resolved or loaded through module access. +``` + +If test ownership did not change, leave this file untouched. + +- [x] **Step 4: Commit docs** + +Run: + +```bash +git add packages/tree-shaking-plugin/README.md packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md +git commit -m "docs: document tree-shaking diagnostics" +``` + +If `AGENTS.md` did not change, run: + +```bash +git add packages/tree-shaking-plugin/README.md +git commit -m "docs: document tree-shaking diagnostics" +``` + +Expected: one docs commit. + +## Task 3: Final Verification + +- [x] **Step 1: Run package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run +``` + +Expected: all tree-shaking-plugin tests pass. + +- [x] **Step 2: Run typecheck** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: no TypeScript errors. + +- [x] **Step 3: Run lint** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +``` + +Expected: no ESLint errors. + +- [x] **Step 4: Run whitespace check** + +Run: + +```bash +git diff --check +``` + +Expected: no output. + +- [x] **Step 5: Run full Verdaccio e2e when needed** + +If Session 3 already ran the full Verdaccio loop after the last code change and this session changed README only, record the earlier result in the final response. Otherwise run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +Expected: `Tree-shaking bundle assertions passed.` + +- [x] **Step 6: Confirm final worktree state** + +Run: + +```bash +git status --short --branch +git log --oneline -8 +``` + +Expected: worktree has only intentional changes, and the recent commits correspond to Sessions 1-4. diff --git a/docs/superpowers/plans/2026-05-17-tree-shaking-module-access-resolving.md b/docs/superpowers/plans/2026-05-17-tree-shaking-module-access-resolving.md new file mode 100644 index 000000000..29aa3ec7f --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-tree-shaking-module-access-resolving.md @@ -0,0 +1,184 @@ +# Tree-Shaking Module Access Resolving Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` or `superpowers:executing-plans`. + +## Source Documents + +- Design spec: + `docs/superpowers/specs/2026-05-17-tree-shaking-module-access-resolving-design.md` +- Existing architecture spec: + `docs/superpowers/specs/2026-05-16-tree-shaking-plugin-pipeline-architecture-design.md` +- Current e2e runner memory: + `cd e2e && corepack yarn e2e:tree-shaking-bundlers-local` + +## Goal + +Refactor `@openapi-qraft/tree-shaking-plugin` module access so resolving and +source loading have one explicit contract: + +```text +resolve: user resolve -> native resolve +load: user load -> native load -> adapter-local source fallback +``` + +Adapter-local source fallback is non-public and best-effort. Core transform must +continue to use only `moduleAccess.resolve/load`. + +## Task 1: Lock Resolver/Loader Strategy Contract + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/common.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/agnostic.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/rollup-like.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/webpack-like.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/rspack.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/esbuild.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts` + +**Implementation:** + +- Introduce named resolve/load strategies so tests and diagnostics can identify + `native`, `user`, and `adapter-fallback` stages. +- Keep `QraftModuleAccess` public shape as `resolve/load`; optional trace + metadata may be added as implementation detail on adapter-created objects. +- Preserve current resolver caching and rejected-load retry behavior. +- Standardize adapter order: + - agnostic: user resolve/load only; + - Vite/Rollup: user resolve -> native resolve; user load -> adapter fallback; + - webpack: user resolve -> native resolve; user load -> `loadModule` -> + adapter fallback; + - Rspack: user resolve -> reconstructed native resolve; user load -> + `loadModule` -> adapter fallback; + - esbuild: user resolve -> native resolve; user load -> adapter fallback. + +**Tests:** + +- User resolve wins over native resolve. +- Native resolve runs after user miss/error. +- Webpack/Rspack user load wins over native load. +- User load runs before adapter-local source fallback. +- Rejected source loading is not permanently cached. +- Exact query/hash id is passed to user load. +- Adapter-local file read strips query/hash only locally. + +## Task 2: Preserve Exact IDs Through Source Loading + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/state.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts` + +**Implementation:** + +- Stop normalizing resolved ids before calling `moduleAccess.load`. +- Use exact ids for loading source. +- Use canonical ids only for identity matching, cycle detection, and emitted + import path/path-composition decisions. +- Avoid passing query/hash ids into `path.dirname(...)`-based import rendering. +- Keep operation import resolution behavior semantically unchanged. + +**Tests:** + +- Generated metadata loads exact resolved ids containing query/hash. +- Source ownership matching still uses canonical ids. +- Missing source diagnostics still trigger when exact-id load returns null. + +## Task 3: Attach Module Access Trace To Diagnostics + +**Files:** + +- Modify: `packages/tree-shaking-plugin/src/lib/resolvers/common.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/state.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts` + +**Implementation:** + +- Record compact trace entries for resolve/load attempts: + - kind: `resolve` or `load`; + - request target; + - stages with `hit`, `miss`, or `error`; + - resolved id or short error message when useful. +- Add optional trace data to unresolved diagnostics. +- Format trace only for unresolved diagnostics under existing + `diagnostics: 'error' | 'warn'`; keep `diagnostics: 'off'` silent. +- Do not print trace for successful transforms by default. + +**Tests:** + +- `QraftTreeShakeError.reason` contains trace for unavailable generated source. +- Warning output includes stage trace. +- `diagnostics: 'off'` remains silent. + +## Task 4: Update Public Documentation + +**Files:** + +- Modify: `packages/tree-shaking-plugin/README.md` + +**Implementation:** + +- Document `moduleAccess.resolve` as user override with native fallback. +- Document `moduleAccess.load` as the only public custom/virtual source + provider. +- State that adapter-local source fallback is non-public, best-effort, and not + configurable. +- Mention Rspack resolver drift risk and optional `@rspack/resolver` + expectations without adding new public API. + +## Task 5: Add Targeted E2E Coverage + +**Files:** + +- Modify: `e2e/projects/tree-shaking-bundlers/src/*` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/scenarios.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs` +- Modify adapter configs only if required: + - `e2e/projects/tree-shaking-bundlers/vite.config.ts` + - `e2e/projects/tree-shaking-bundlers/rollup.config.mjs` + - `e2e/projects/tree-shaking-bundlers/webpack.config.mjs` + - `e2e/projects/tree-shaking-bundlers/rspack.config.mjs` + - `e2e/projects/tree-shaking-bundlers/scripts/build-esbuild.mjs` + +**Implementation:** + +- Add the smallest number of scenarios needed to cover: + - query/hash exact id through `moduleAccess.load`; + - omitted `index` import through alias resolution; + - alias plus re-export barrel ownership traversal; + - virtual/load-only generated module; + - Rspack-specific alias/re-export drift. +- Prefer extending `assert-dist.mjs` semantic token checks over fragile full + bundle text snapshots. + +## Verification + +Run after each meaningful milestone: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/resolvers/resolvers.test.ts +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/generated-metadata.test.ts src/lib/transform/diagnostics.test.ts src/__tests__/core/resolution-and-module-access.test.ts +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +git diff --check +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +## Completion Criteria + +- Unit tests lock the adapter contract table. +- Core transform loads source through exact ids. +- Diagnostics explain resolve/load misses. +- README matches the public API contract. +- Multi-bundler e2e passes. diff --git a/docs/superpowers/plans/2026-05-25-tree-shaking-core-test-contract-fixes.md b/docs/superpowers/plans/2026-05-25-tree-shaking-core-test-contract-fixes.md new file mode 100644 index 000000000..bcd9bef4f --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-tree-shaking-core-test-contract-fixes.md @@ -0,0 +1,298 @@ +# Tree-Shaking Core Test Contract Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Clarify reviewed tree-shaking core snapshots and align test fixtures with the generated client surfaces they claim to model. + +**Architecture:** Do not change production transform behavior for the one-argument object-literal case. Keep that test as synthetic transform-shape coverage, make its intent explicit, and limit functional test fixture changes to precreated direct invocation and React context value clarity. + +**Tech Stack:** TypeScript, Babel AST traversal, Vitest inline snapshots, `@openapi-qraft/tree-shaking-plugin`. + +--- + +## File Structure + +- Modify `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` + - Keep the existing `createAPIClient({ useQuery })` positive snapshot. + - Rename or annotate the test so future reviewers do not treat it as generated-client runtime-validity evidence. + +- Modify `packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts` + - Make the shared precreated fixture include `operationInvokeFn` when direct invocation is part of the fixture surface. + +- Modify `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` + - Refresh only snapshots affected by the precreated fixture fidelity change. + +- Modify `packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts` + - Replace `APIClientContext`-as-options source code with `useContext(APIClientContext)`. + +- Review `packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md` + - Leave unchanged when fixture ownership guidance remains accurate after the shared fixture change. + +## Task 1: Clarify Synthetic One-Arg Object-Literal Coverage + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` + +- [ ] **Step 1: Rename and annotate the synthetic snapshot test** + +Find the test currently named: + +```ts +it('optimizes clients with a single object literal even without known option keys', async () => { +``` + +Replace the test heading with this comment and name: + +```ts + // Synthetic transform-shape coverage: this does not assert that `{ useQuery }` + // is a valid generated-client runtime options object. It verifies that a + // single expression argument keeps callback import/alias wiring stable. + it('optimizes synthetic one-arg object literals without validating options shape', async () => { +``` + +Do not change the source snippet or inline snapshot in this test. + +- [ ] **Step 2: Run the focused renamed test** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts -t "optimizes synthetic one-arg object literals without validating options shape" +``` + +Expected: PASS with the existing snapshot output unchanged. + +- [ ] **Step 3: Commit Task 1** + +```bash +git add packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts +git commit -m "test: clarify synthetic one-arg client snapshot" +``` + +## Task 2: Precreated Fixture Direct Invoke Fidelity + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` + +- [ ] **Step 1: Update the shared precreated fixture** + +Change `PRECREATED_API_INDEX_TS` in `fixtures.ts` from a `useQuery`-only callback set to a callback set that includes `operationInvokeFn`: + +```ts +export const PRECREATED_API_INDEX_TS = ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { + operationInvokeFn, + useQuery, +} from '@openapi-qraft/react/callbacks/index'; +import { services } from './services/index'; + +const defaultCallbacks = { operationInvokeFn, useQuery } as const; + +export function createAPIClient(options?: { + baseUrl: string; + queryClient: unknown; + requestFn: (...args: unknown[]) => Promise; +}) { + return qraftAPIClient(services, defaultCallbacks, options); +} +`; +``` + +- [ ] **Step 2: Update the default precreated options fixture** + +Change `DEFAULT_PRECREATED_CLIENT_OPTIONS_TS` to model a request-capable client: + +```ts +export const DEFAULT_PRECREATED_CLIENT_OPTIONS_TS = ` +export const createAPIClientOptions = () => ({ + baseUrl: 'http://localhost', + queryClient: {}, + requestFn: async () => ({ data: undefined, error: undefined }) +}); +`; +``` + +- [ ] **Step 3: Run the focused precreated test** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/precreated-api-client.test.ts +``` + +Expected: PASS or inline snapshot mismatch caused only by changed fixture import text. When Vitest reports a snapshot mismatch, inspect the diff. When it is only emitted fixture-driven output, run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/precreated-api-client.test.ts -u +``` + +Expected after update: PASS. + +- [ ] **Step 4: Review affected snapshots** + +Inspect: + +```bash +git diff -- packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts +``` + +Expected: fixture now models `operationInvokeFn`; snapshots still use `qraftAPIClient` for precreated mode; no unrelated snapshot churn. + +- [ ] **Step 5: Commit Task 2** + +```bash +git add packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts +git commit -m "test: align precreated direct invoke fixture" +``` + +## Task 3: Explicit Options React Fixture Clarity + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts` + +- [ ] **Step 1: Replace the context object with a context value** + +In the `preserves void and await prefixes for named and inline client calls` source fixture, change: + +```ts +import { createAPIClient, APIClientContext } from './api'; + +async function run() { + const api = createAPIClient(); + const apiOptions = APIClientContext; +``` + +to: + +```ts +import { createAPIClient, APIClientContext } from './api'; +import { useContext } from 'react'; + +async function run() { + const api = createAPIClient(); + const apiOptions = useContext(APIClientContext); +``` + +- [ ] **Step 2: Update the expected inline snapshot** + +The snapshot should preserve the new import and local context value: + +```ts +"import { APIClientContext } from './api'; +import { useContext } from 'react'; +import { qraftAPIClient } from \"@openapi-qraft/react\"; +import { invalidateQueries } from \"@openapi-qraft/react/callbacks/invalidateQueries\"; +import { findPetsByStatus } from \"./api/services/PetsService\"; +async function run() { + const api_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, APIClientContext); + const apiOptions = useContext(APIClientContext); + void api_pets_findPetsByStatus.invalidateQueries(); + await api_pets_findPetsByStatus.invalidateQueries(); + void qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, apiOptions!).invalidateQueries(); + await qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, apiOptions!).invalidateQueries(); +}" +``` + +When formatting differs, refresh the inline snapshot from actual Vitest output. + +- [ ] **Step 3: Run the focused explicit-options test** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/explicit-options.test.ts -t "preserves void and await prefixes" +``` + +Expected: PASS. + +- [ ] **Step 4: Commit Task 3** + +```bash +git add packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts +git commit -m "test: use React context value in explicit options fixture" +``` + +## Task 4: Guide Check And Full Verification + +**Files:** +- Review: `packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md` +- Verify: `packages/tree-shaking-plugin/src/__tests__/core/*.test.ts` + +- [ ] **Step 1: Check core test guide ownership wording** + +Run: + +```bash +sed -n '1,140p' packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md +``` + +Expected: the guide still accurately says `fixtures.ts` owns generated API source strings and fixture builders. When the fixture ownership text remains accurate, do not edit the guide. + +- [ ] **Step 2: Run focused core tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts src/__tests__/core/explicit-options.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run full package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run +``` + +Expected: all package tests pass. + +- [ ] **Step 4: Run typecheck** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +Expected: exit 0. + +- [ ] **Step 5: Run lint** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +``` + +Expected: exit 0 with no warnings. + +- [ ] **Step 6: Check formatting whitespace** + +Run: + +```bash +git diff --check +``` + +Expected: no output and exit 0. + +- [ ] **Step 7: Commit verification/doc-only guide changes** + +When `AGENTS.md` changed, commit it separately: + +```bash +git add packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md +git commit -m "docs: update tree-shaking core test guide" +``` + +When `AGENTS.md` did not change, do not create an empty commit. diff --git a/docs/superpowers/plans/2026-05-27-tree-shaking-public-import-specifiers.md b/docs/superpowers/plans/2026-05-27-tree-shaking-public-import-specifiers.md new file mode 100644 index 000000000..319b32627 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-tree-shaking-public-import-specifiers.md @@ -0,0 +1,1422 @@ +# Tree-Shaking Public Import Specifiers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `@openapi-qraft/tree-shaking-plugin` emit operation and context imports from configured public module specifiers instead of resolver-derived physical file paths. + +**Architecture:** Add `services?: { moduleSpecifierBase: string }` to every entrypoint kind that emits operation imports. Normalize omitted `services.moduleSpecifierBase` to `factory.moduleSpecifier`, normalize omitted `reactContext.moduleSpecifier` to `factory.moduleSpecifier`, and remove the legacy config bridge so transform state carries normalized entrypoints directly. Resolver/module loading remains for metadata discovery only. + +**Tech Stack:** TypeScript, Babel AST traversal, Vitest inline snapshots, qraft `tree-shaking-bundlers` e2e fixture, Yarn 4/Turborepo. + +--- + +## File Structure + +- `packages/tree-shaking-plugin/src/core.ts` + - Public option types. Add `ServicesImportBaseTarget` and optional `services` to `clientFactory` and `precreatedClient`. +- `packages/tree-shaking-plugin/src/lib/transform/types.ts` + - Internal normalized entrypoint, metadata, binding, and create-import types. Remove `LegacyQraftFactoryConfig` and `LegacyQraftPrecreatedClientConfig`. +- `packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts` + - Normalize `services.moduleSpecifierBase` and `reactContext.moduleSpecifier` to concrete public module specifiers. +- `packages/tree-shaking-plugin/src/lib/transform/generated-info-key.ts` + - Keep a temporary legacy-factory-shaped bridge until transform state stores normalized entrypoint keys directly, then remove it in Task 4. +- `packages/tree-shaking-plugin/src/lib/transform/path-rendering.ts` + - Add module-specifier join helpers for service-file operation imports. +- `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts` + - Keep resolving/loading generated factories, but stop treating missing static `services` imports as an unresolved generated factory. +- `packages/tree-shaking-plugin/src/lib/transform/state.ts` + - Use normalized entrypoints directly instead of constructing legacy config arrays. +- `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` + - Snapshot coverage for bare/alias public imports, explicit `moduleSpecifierBase`, default context imports, and removal of obsolete no-services skip behavior. +- `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` + - Snapshot coverage for `services.moduleSpecifierBase` on `precreatedClient`. +- `packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts` + - Unit tests for normalization. +- `packages/tree-shaking-plugin/src/lib/transform/path-rendering.test.ts` + - Unit tests for service operation import composition. +- `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts` + - Metadata tests for factories without static `services` imports. +- `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` + - Add explicit `services.moduleSpecifierBase` where fixture entrypoints point at factory files instead of generated API roots. +- `packages/tree-shaking-plugin/README.md` + - Document default service base assumptions and the explicit `services.moduleSpecifierBase` escape hatch. + +--- + +### Task 1: Normalize Public Entrypoint Config + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/core.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/generated-info-key.ts` + +- [ ] **Step 1: Write failing normalization tests** + +Replace the current service-related cases in `packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts` with these tests: + +```ts +it('normalizes omitted clientFactory services and context modules to the factory module specifier', () => { + expect( + normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createReactAPIClient', + moduleSpecifier: '@api/my-api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + }) + ).toEqual([ + { + kind: 'generatedFactory', + key: JSON.stringify([ + 'generatedFactory', + 'createReactAPIClient', + '@api/my-api', + '@api/my-api', + '@api/my-api', + ]), + factory: { + exportName: 'createReactAPIClient', + moduleSpecifier: '@api/my-api', + }, + services: { + moduleSpecifierBase: '@api/my-api', + }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: '@api/my-api', + }, + }, + ]); +}); + +it('preserves explicit clientFactory services moduleSpecifierBase', () => { + const [entrypoint] = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createReactAPIClient', + moduleSpecifier: '@api/my-api', + }, + services: { + moduleSpecifierBase: '@api/my-public-root', + }, + }, + ], + }); + + expect(entrypoint).toMatchObject({ + kind: 'generatedFactory', + key: JSON.stringify([ + 'generatedFactory', + 'createReactAPIClient', + '@api/my-api', + '@api/my-public-root', + '', + ]), + services: { + moduleSpecifierBase: '@api/my-public-root', + }, + }); +}); + +it('normalizes omitted precreatedClient services to the factory module specifier', () => { + expect( + normalizeEntrypoints({ + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'nodeAPIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: '@api/my-api', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + }) + ).toEqual([ + { + kind: 'precreatedClient', + key: JSON.stringify([ + 'precreatedClient', + 'nodeAPIClient', + './client', + 'createNodeAPIClient', + '@api/my-api', + 'createNodeAPIClientOptions', + './client-options', + '@api/my-api', + ]), + client: { + exportName: 'nodeAPIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: '@api/my-api', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + services: { + moduleSpecifierBase: '@api/my-api', + }, + }, + ]); +}); +``` + +- [ ] **Step 2: Run tests and verify they fail** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/entrypoints.test.ts +``` + +Expected: FAIL because `services.moduleSpecifierBase` is not part of the public or normalized config yet. + +- [ ] **Step 3: Add public services base type** + +In `packages/tree-shaking-plugin/src/core.ts`, add the public type and attach it to both entrypoint configs: + +```ts +export type ServicesImportBaseTarget = { + moduleSpecifierBase: string; +}; + +export type QraftClientFactoryEntrypointConfig = { + kind: 'clientFactory'; + factory: ModuleExportTarget; + services?: ServicesImportBaseTarget; + reactContext?: ReactContextTarget; +}; + +export type QraftPrecreatedClientEntrypointConfig = { + kind: 'precreatedClient'; + client: ModuleExportTarget; + factory: ModuleExportTarget; + optionsFactory: ModuleExportTarget; + services?: ServicesImportBaseTarget; +}; +``` + +Mirror that public shape in `packages/tree-shaking-plugin/src/lib/transform/types.ts`: + +```ts +export type ServicesImportBaseTarget = { + moduleSpecifierBase: string; +}; + +export type ReactContextConfig = { + exportName: string; + moduleSpecifier: string; +}; +``` + +Update normalized entrypoint types in `types.ts`: + +```ts +export type GeneratedFactoryEntrypoint = { + kind: 'generatedFactory'; + key: string; + factory: ImportTarget; + services: ServicesImportBaseTarget; + reactContext: ReactContextConfig | null; +}; + +export type PrecreatedClientEntrypoint = { + kind: 'precreatedClient'; + key: string; + client: ImportTarget; + factory: ImportTarget; + optionsFactory: ImportTarget; + services: ServicesImportBaseTarget; +}; +``` + +- [ ] **Step 4: Normalize services and context eagerly** + +Update `packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts`: + +```ts +function normalizeServices( + factoryModuleSpecifier: string, + services: { moduleSpecifierBase: string } | undefined +) { + return { + moduleSpecifierBase: + services?.moduleSpecifierBase ?? factoryModuleSpecifier, + }; +} + +function normalizeReactContext( + factoryModuleSpecifier: string, + reactContext: + | { exportName: string; moduleSpecifier?: string } + | undefined +) { + return reactContext + ? { + exportName: reactContext.exportName, + moduleSpecifier: + reactContext.moduleSpecifier ?? factoryModuleSpecifier, + } + : null; +} +``` + +For `clientFactory`, compute both normalized objects: + +```ts +const services = normalizeServices( + entrypoint.factory.moduleSpecifier, + entrypoint.services +); +const reactContext = normalizeReactContext( + entrypoint.factory.moduleSpecifier, + entrypoint.reactContext +); +``` + +Use those normalized objects in the returned entrypoint and in the key: + +```ts +function composeGeneratedFactoryEntrypointKey( + exportName: string, + moduleSpecifier: string, + servicesModuleSpecifierBase: string, + contextModuleSpecifier: string +) { + return composeEntrypointKey([ + 'generatedFactory', + exportName, + moduleSpecifier, + servicesModuleSpecifierBase, + contextModuleSpecifier, + ]); +} + +function composeEntrypointKey(parts: string[]) { + return JSON.stringify(parts); +} +``` + +For `precreatedClient`, normalize `services` from `entrypoint.factory.moduleSpecifier` and append `services.moduleSpecifierBase` to the precreated key. + +- [ ] **Step 5: Keep generated-info keys type-safe during the transition** + +Update `packages/tree-shaking-plugin/src/lib/transform/generated-info-key.ts`: + +```ts +type LegacyGeneratedInfoFactoryKeyParts = { + context?: string | null; + contextModule?: string | null; +}; + +export function getGeneratedInfoKey( + createImportPath: string, + entrypointKey: string | LegacyGeneratedInfoFactoryKeyParts +) { + if (typeof entrypointKey === 'string') { + return `${createImportPath}::${entrypointKey}`; + } + + return `${createImportPath}::${entrypointKey.context ?? ''}::${entrypointKey.contextModule ?? ''}`; +} +``` + +Task 4 removes the legacy object branch once all call sites pass normalized entrypoint keys. This keeps Task 1 independently typecheckable while still making normalized keys available. + +- [ ] **Step 6: Run normalization tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/entrypoints.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/tree-shaking-plugin/src/core.ts \ + packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts \ + packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts \ + packages/tree-shaking-plugin/src/lib/transform/generated-info-key.ts +git commit -m "feat: normalize tree-shaking public import bases" +``` + +--- + +### Task 2: Render Service Operation Imports From Public Bases + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/path-rendering.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/path-rendering.test.ts` + +- [ ] **Step 1: Write failing path-rendering tests** + +Add these tests to `packages/tree-shaking-plugin/src/lib/transform/path-rendering.test.ts`: + +```ts +import { composeServiceOperationImportPath } from './path-rendering.js'; + +it('composes operation imports from a public module specifier base', () => { + expect( + composeServiceOperationImportPath( + '@api/my-api', + './services', + './PetsService.ts' + ) + ).toBe('@api/my-api/services/PetsService'); +}); + +it('composes operation imports from an explicit nested public base', () => { + expect( + composeServiceOperationImportPath( + '@api/my-api/public', + './services', + './PetsService' + ) + ).toBe('@api/my-api/public/services/PetsService'); +}); + +it('preserves local relative root behavior', () => { + expect( + composeServiceOperationImportPath('./api', './services', './PetsService') + ).toBe('./api/services/PetsService'); +}); +``` + +- [ ] **Step 2: Run tests and verify they fail** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/path-rendering.test.ts +``` + +Expected: FAIL because `composeServiceOperationImportPath` does not exist. + +- [ ] **Step 3: Add module-specifier join helper** + +Add to `packages/tree-shaking-plugin/src/lib/transform/path-rendering.ts`: + +```ts +export function composeServiceOperationImportPath( + moduleSpecifierBase: string, + servicesDir: string, + serviceImportPath: string +) { + return joinModuleSpecifierParts( + moduleSpecifierBase, + servicesDir, + serviceImportPath + ); +} + +function joinModuleSpecifierParts(base: string, ...parts: string[]) { + const normalizedBase = stripTrailingSlash( + stripIndexSourceExtension(stripSourceExtension(base)) + ); + const suffix = parts + .map(normalizeModuleSpecifierPart) + .filter(Boolean) + .join('/'); + + return suffix ? `${normalizedBase}/${suffix}` : normalizedBase; +} + +function normalizeModuleSpecifierPart(part: string) { + return stripIndexSourceExtension(stripSourceExtension(part)) + .replace(/^\.?\//, '') + .replace(/\/$/, ''); +} + +function stripTrailingSlash(value: string) { + return value.replace(/\/$/, ''); +} +``` + +Do not use `node:path` to join bare module specifiers. + +- [ ] **Step 4: Run path-rendering tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/path-rendering.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/path-rendering.ts \ + packages/tree-shaking-plugin/src/lib/transform/path-rendering.test.ts +git commit -m "feat: render tree-shaking service import bases" +``` + +--- + +### Task 3: Simplify Generated Metadata + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts` + +- [ ] **Step 1: Replace obsolete missing-services metadata test** + +In `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts`, replace `returns unresolved reason for factories without static services imports` with: + +```ts +it('uses the conventional services directory when the generated factory has no static services import', async () => { + const root = await createTempFixture(); + await writeFixtureFiles(root, { + 'src/api/index.ts': ` +import { qraftReactAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; +import { APIClientContext } from './APIClientContext'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(services, callbacks = defaultCallbacks) { + return qraftReactAPIClient(services, callbacks, APIClientContext); +} +`, + 'src/api/APIClientContext.ts': ` +export const APIClientContext = {}; +`, + }); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + reactContext: { exportName: 'APIClientContext' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(result.reasons).toEqual([]); + expect(metadata).toMatchObject({ + entrypoint: entrypoints[0], + factoryFile: path.join(root, 'src/api/index.ts'), + servicesDir: './services', + serviceImportPaths: {}, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: './api', + }, + }); +}); +``` + +- [ ] **Step 2: Run metadata tests and verify they fail** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/generated-metadata.test.ts +``` + +Expected: FAIL because missing static `services` imports still produce `generated-services-import-missing`. + +- [ ] **Step 3: Keep normalized entrypoint in metadata** + +In `packages/tree-shaking-plugin/src/lib/transform/types.ts`, keep the full normalized entrypoint on metadata and remove duplicate service/context ownership fields: + +```ts +export type GeneratedClientMetadata = { + entrypoint: ClientEntrypoint; + factoryFile: string; + factoryLoadId: string; + servicesDir: string; + serviceImportPaths: Record; + reactContext: ReactContextConfig | null; + optionsFactory?: ImportTarget; +}; +``` + +In `packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts`, continue returning `entrypoint` unchanged in metadata. Do not create a legacy factory-shaped copy. + +- [ ] **Step 4: Default `servicesDir` instead of returning missing-services diagnostics** + +In `inspectFactoryFile(...)`, replace: + +```ts +const factoryImports = readGeneratedFactoryImports(ast, reactContext); +if (!factoryImports.servicesDir) { + return missingServicesImport(entrypoint.key); +} + +const serviceImportPaths = await readServiceImportPaths( + factoryFile, + factoryImports.servicesDir, + moduleAccess +); +``` + +with: + +```ts +const factoryImports = readGeneratedFactoryImports(ast, reactContext); +const servicesDir = factoryImports.servicesDir ?? './services'; +const serviceImportPaths = factoryImports.servicesDir + ? await readServiceImportPaths(factoryFile, servicesDir, moduleAccess) + : {}; +``` + +Use `servicesDir` in returned metadata. Keep `missingServicesImport(...)` for re-export cycles and non-qraft factories. + +- [ ] **Step 5: Preserve normalized context module specifiers** + +In `readGeneratedFactoryImports(...)`, do not replace configured context module specifiers with generated physical import paths. The configured context branch should keep the normalized public module specifier: + +```ts +if ( + configuredContext && + specifier.imported.name === configuredContext.exportName +) { + inferredContext = { + exportName: configuredContext.exportName, + moduleSpecifier: configuredContext.moduleSpecifier, + }; +} +``` + +For unconfigured contexts discovered from `qraftReactAPIClient(..., ..., context)`, keep the discovered import source because there is no public context config: + +```ts +if (!configuredContext) { + inferredContext = { + exportName: importedContext.exportName, + moduleSpecifier: importedContext.moduleSpecifier, + }; +} +``` + +- [ ] **Step 6: Run metadata tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/generated-metadata.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts \ + packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts +git commit -m "refactor: simplify generated tree-shaking metadata" +``` + +--- + +### Task 4: Remove Legacy Config Bridge From Transform State + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/lib/transform/types.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/state.ts` +- Modify: `packages/tree-shaking-plugin/src/lib/transform/generated-info-key.ts` + +- [ ] **Step 1: Replace legacy factory types in transform types** + +In `packages/tree-shaking-plugin/src/lib/transform/types.ts`, delete `LegacyQraftFactoryConfig` and `LegacyQraftPrecreatedClientConfig`. + +Update `ClientBinding` and related request/import types to carry normalized entrypoint references directly: + +```ts +export type ClientBinding = { + name: string; + clientSourceKey: string; + createImportPath: string; + createImportLoadId: string; + entrypoint: GeneratedFactoryEntrypoint | PrecreatedClientEntrypoint; + bindingNode: t.Node; + declarationScope: Scope; + runtimeInput: RuntimeInput; + localInitPath?: import('@babel/traverse').NodePath; + mode: + | { type: 'context' } + | { type: 'options'; optionsExpression: t.Expression } + | { + type: 'precreated'; + optionsImportPath: string; + optionsExportName: string; + }; +}; + +export type GeneratedInfoRequest = { + createImportPath: string; + createImportLoadId: string; + entrypoint: GeneratedFactoryEntrypoint | PrecreatedClientEntrypoint; +}; + +export type CreateImportEntry = { + sourceSpecifier: string; + factoryFile: string; + factoryLoadId: string; + entrypoint: GeneratedFactoryEntrypoint; +}; +``` + +Update inline/schema match result types in `state.ts` to return `entrypoint` instead of `factory`. + +- [ ] **Step 2: Replace legacy arrays with normalized entrypoint maps** + +In `packages/tree-shaking-plugin/src/lib/transform/state.ts`, delete `factoryOptions`, `factoryEntrypointKeys`, `precreatedOptions`, and `precreatedEntrypointKeys`. + +Use these maps instead: + +```ts +const generatedFactoryEntrypoints = entrypoints.filter( + (entrypoint): entrypoint is GeneratedFactoryEntrypoint => + entrypoint.kind === 'generatedFactory' +); +const precreatedClientEntrypoints = entrypoints.filter( + (entrypoint): entrypoint is PrecreatedClientEntrypoint => + entrypoint.kind === 'precreatedClient' +); +``` + +Where the old code filtered `factoryOptions`, filter `generatedFactoryEntrypoints` by `entrypoint.factory.exportName`. + +Where the old code filtered `precreatedOptions`, filter `precreatedClientEntrypoints` by `entrypoint.client.exportName`. + +- [ ] **Step 3: Store normalized entrypoints in create imports and signals** + +When matching a generated factory import, store the entrypoint directly: + +```ts +createImports.set(specifier.local.name, { + sourceSpecifier: source, + factoryFile: resolvedId ?? normalizeResolvedId(resolvedAbs), + factoryLoadId: resolvedAbs, + entrypoint: matched, +}); +factoryImportSignals.set(specifier.local.name, { + key: matched.key, + bindingNode: specifier.local, +}); +``` + +Update every `createImport.factory` access to `createImport.entrypoint`. + +- [ ] **Step 4: Update generated-info cache calls** + +Replace every call shaped like: + +```ts +getGeneratedInfoKey(createImportPath, factory) +``` + +with: + +```ts +getGeneratedInfoKey(createImportPath, entrypoint.key) +``` + +For precreated clients, use the normalized precreated entrypoint key. For generated factories, use the normalized generated factory entrypoint key. + +After those call sites are migrated, narrow `getGeneratedInfoKey` to accept only a normalized entrypoint key: + +```ts +export function getGeneratedInfoKey( + createImportPath: string, + entrypointKey: string +) { + return `${createImportPath}::${entrypointKey}`; +} +``` + +- [ ] **Step 5: Rewrite metadata seeding without legacy factories** + +Replace `seedGeneratedInfoByImport(...)` with a version that accepts only metadata and importer id: + +```ts +function seedGeneratedInfoByImport( + generatedInfoByImport: Map, + metadataByEntrypointKey: Map, + importerId: string +) { + for (const metadata of metadataByEntrypointKey.values()) { + if (!metadata) continue; + + const generatedInfo = toGeneratedClientInfo(metadata, importerId); + const sourceIds = new Set([metadata.factoryFile]); + + for (const sourceId of sourceIds) { + generatedInfoByImport.set( + getGeneratedInfoKey(sourceId, metadata.entrypoint.key), + generatedInfo + ); + } + } +} +``` + +Then update `toGeneratedClientInfo(...)`: + +```ts +function toGeneratedClientInfo( + metadata: GeneratedClientMetadata, + importerId: string +): GeneratedClientInfo { + return { + importerId, + clientFile: metadata.factoryFile, + servicesModuleSpecifierBase: + metadata.entrypoint.services.moduleSpecifierBase, + servicesDir: metadata.servicesDir, + serviceImportPaths: metadata.serviceImportPaths, + contextImportPath: resolveMetadataContextImportPath(metadata), + contextName: + metadata.entrypoint.kind === 'generatedFactory' + ? metadata.entrypoint.reactContext?.exportName ?? null + : null, + }; +} +``` + +Use this `GeneratedClientInfo` shape in `types.ts`: + +```ts +export type GeneratedClientInfo = { + importerId: string; + clientFile: string; + servicesModuleSpecifierBase: string; + servicesDir: string; + serviceImportPaths: Record; + contextImportPath: string | null; + contextName: string | null; +}; +``` + +- [ ] **Step 6: Render context imports from normalized entrypoints** + +Replace `resolveMetadataContextImportPath(...)` with: + +```ts +function resolveMetadataContextImportPath(metadata: GeneratedClientMetadata) { + const entrypoint = metadata.entrypoint; + if (entrypoint.kind !== 'generatedFactory') return null; + return entrypoint.reactContext?.moduleSpecifier ?? null; +} +``` + +- [ ] **Step 7: Render operation imports from normalized service bases** + +Update `resolveOperationImport(...)`: + +```ts +function resolveOperationImport( + generatedInfo: GeneratedClientInfo, + serviceName: string, + operationName: string, + programScope: Scope, + fileBindingNames: Set, + reservedImportLocalNames: Set, + operationImports: Map +): OperationImportInfo { + const key = [ + generatedInfo.clientFile, + generatedInfo.servicesModuleSpecifierBase, + generatedInfo.servicesDir, + serviceName, + operationName, + ].join(':'); + const cached = operationImports.get(key); + if (cached) return cached; + + const serviceImportPath = + generatedInfo.serviceImportPaths[serviceName] ?? + `./${serviceNameToFileBase(serviceName)}`; + const resolved = { + importPath: composeServiceOperationImportPath( + generatedInfo.servicesModuleSpecifierBase, + generatedInfo.servicesDir, + serviceImportPath + ), + operationName, + localName: createProgramUniqueName( + programScope, + operationName, + fileBindingNames, + reservedImportLocalNames + ), + }; + reservedImportLocalNames.add(resolved.localName); + operationImports.set(key, resolved); + return resolved; +} +``` + +Remove null checks that report `operation-import-unresolved` and `inline-operation-import-unresolved`, because operation import rendering no longer resolves files. + +- [ ] **Step 8: Update precreated validation to use normalized entrypoints** + +Change `findPrecreatedClients(...)` to accept `PrecreatedClientEntrypoint[]` instead of legacy configs. + +Use entrypoint fields directly: + +```ts +entrypoint.client.moduleSpecifier +entrypoint.client.exportName +entrypoint.factory.moduleSpecifier +entrypoint.factory.exportName +entrypoint.optionsFactory.moduleSpecifier +entrypoint.optionsFactory.exportName +entrypoint.services.moduleSpecifierBase +``` + +Change `validatePrecreatedClientConfig(...)` to return: + +```ts +Promise<{ entrypoint: PrecreatedClientEntrypoint } | null> +``` + +When validation succeeds, return the normalized entrypoint rather than a legacy factory object. + +- [ ] **Step 9: Verify no legacy bridge remains** + +Run: + +```bash +rg -n "LegacyQraft|factoryOptions|precreatedOptions|factoryEntrypointKeys|precreatedEntrypointKeys" packages/tree-shaking-plugin/src/lib/transform +``` + +Expected: no output. + +- [ ] **Step 10: Run focused transform tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/entrypoints.test.ts src/lib/transform/generated-metadata.test.ts src/lib/transform/path-rendering.test.ts +``` + +Expected: PASS. + +- [ ] **Step 11: Commit** + +```bash +git add packages/tree-shaking-plugin/src/lib/transform/types.ts \ + packages/tree-shaking-plugin/src/lib/transform/state.ts +git commit -m "refactor: remove tree-shaking legacy config bridge" +``` + +--- + +### Task 5: Update Core Transform Snapshot Contracts + +**Files:** +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts` +- Modify: `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts` + +- [ ] **Step 1: Update the existing bare module factory regression** + +In `packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts`, update `recognizes a custom factory name imported via a bare module specifier`. + +Expected snapshot: + +```ts +expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "@api/my-api/services/PetsService"; + import { APIClientContext } from "@api/my-api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); +``` + +- [ ] **Step 2: Add an explicit services base regression for clientFactory** + +Add this test to `create-api-client-fn.test.ts` near the bare module test: + +```ts +it('uses explicit services moduleSpecifierBase for a generated factory', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const apiIndex = path.join(fixture, 'src/api/index.ts'); + + const result = await transformQraftTreeShaking( + ` +import { createMyAPIClient } from '@api/my-api'; + +const api = createMyAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createMyAPIClient', + moduleSpecifier: '@api/my-api', + }, + services: { + moduleSpecifierBase: '@api/my-public-root', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + async resolve(specifier) { + if (specifier === '@api/my-api') return apiIndex; + return null; + }, + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "@api/my-public-root/services/PetsService"; + import { APIClientContext } from "@api/my-api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); +}); +``` + +- [ ] **Step 3: Replace obsolete no-services skip coverage** + +Delete `skips generated factories that receive an operation argument without services imports` from `create-api-client-fn.test.ts`. + +Add: + +```ts +it('rewrites generated factories without static services imports when service base is configured', async () => { + const fixture = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(fixture, { + 'src/api/createAPIClient.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { getQueryKey } from '@openapi-qraft/react/callbacks/index'; + +const defaultCallbacks = { getQueryKey } as const; + +export function createAPIClient(operation, callbacks = defaultCallbacks) { + return qraftAPIClient(operation, callbacks); +} +`, + 'src/api/services/PetsService.ts': PETS_SERVICE_TS, + }); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api/createAPIClient'; +import { getPets } from './api/services/PetsService'; + +const api = createAPIClient(getPets); + +export function App() { + return api.pets.getPets.getQueryKey(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api/createAPIClient', + }, + services: { + moduleSpecifierBase: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPets } from "./api/services/PetsService"; + const api_pets_getPets = qraftAPIClient(getPets, { + getQueryKey + }); + export function App() { + return api_pets_getPets.getQueryKey(); + }" + `); +}); +``` + +- [ ] **Step 4: Add precreated services base regression** + +In `packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts`, add: + +```ts +it('uses explicit services moduleSpecifierBase for a precreated API client', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient as API } from './client'; + +export function App() { + return API.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: '@api/my-api', + }, + services: { + moduleSpecifierBase: '@api/my-public-root', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + async resolve(specifier) { + if (specifier === '@api/my-api') { + return path.join(root, 'src/api/index.ts'); + } + return null; + }, + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "@api/my-public-root/services/PetsService"; + import { createAPIClientOptions } from "./client-options"; + const API_pets_getPets = qraftAPIClient(getPets, { + useQuery + }, createAPIClientOptions()); + export function App() { + return API_pets_getPets.useQuery(); + }" + `); +}); +``` + +- [ ] **Step 5: Delete obsolete precreated no-static-services skip coverage** + +Delete `skips a precreated client whose generated factory has no static services import` from `precreated-api-client.test.ts`. + +- [ ] **Step 6: Run focused core tests and verify snapshot failures** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts +``` + +Expected: FAIL on inline snapshot differences caused by this task. + +- [ ] **Step 7: Update inline snapshots** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts -u +``` + +Expected: PASS and inline snapshots updated. + +- [ ] **Step 8: Re-run focused core tests without update mode** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts +``` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts \ + packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts +git commit -m "test: cover public service import bases" +``` + +--- + +### Task 6: Retune E2E Fixture Entrypoints + +**Files:** +- Modify: `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs` + +- [ ] **Step 1: Add explicit service bases for factory-file entrypoints** + +In `e2e/projects/tree-shaking-bundlers/scripts/shared.mjs`, add `services.moduleSpecifierBase` to every entrypoint whose `factory.moduleSpecifier` points at a factory file instead of the generated API root. + +Use `./generated-api` for relative file-level factories and `@/generated-api` for alias-root file-level factories. For example: + +```js +{ + kind: 'clientFactory', + factory: { + exportName: 'createRelativeAPIClient', + moduleSpecifier: '@/generated-api/create-relative-api-client', + }, + services: { + moduleSpecifierBase: '@/generated-api', + }, + reactContext: { + exportName: 'RelativeAPIClientContext', + moduleSpecifier: './generated-api/RelativeAPIClientContext', + }, +}, +{ + kind: 'precreatedClient', + client: { + exportName: 'RelativeClient', + moduleSpecifier: './precreated/clients/file-relative.ts', + }, + factory: { + exportName: 'createRelativePrecreatedAPIClient', + moduleSpecifier: + './generated-api/create-relative-precreated-api-client.ts', + }, + services: { + moduleSpecifierBase: './generated-api', + }, + optionsFactory: { + exportName: 'buildRelativeClientOptions', + moduleSpecifier: './precreated/options/barrel', + }, +}, +``` + +Keep barrel/root entrypoints such as `./generated-api` and `@/generated-api` without explicit `services`; those intentionally exercise inheritance from `factory.moduleSpecifier`. + +- [ ] **Step 2: Run static syntax check** + +Run: + +```bash +node --check e2e/projects/tree-shaking-bundlers/scripts/shared.mjs +``` + +Expected: PASS with no syntax output. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/projects/tree-shaking-bundlers/scripts/shared.mjs +git commit -m "test: configure tree-shaking e2e service bases" +``` + +--- + +### Task 7: Document The Public Services Base Contract + +**Files:** +- Modify: `packages/tree-shaking-plugin/README.md` + +- [ ] **Step 1: Update README examples** + +In `packages/tree-shaking-plugin/README.md`, update at least one entrypoint example to show `services.moduleSpecifierBase`: + +```ts +const entrypoints = [ + { + kind: 'precreatedClient', + client: { exportName: 'nodeAPIClient', moduleSpecifier: './client' }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: './create-node-api-client', + }, + services: { + moduleSpecifierBase: './api', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, +]; +``` + +- [ ] **Step 2: Add services base wording** + +Add this wording under the `entrypoints` section: + +```md +`services.moduleSpecifierBase` is optional. When it is omitted, operation imports inherit `factory.moduleSpecifier` as the public generated API root. For example, a factory module of `@api/my-api` emits operation imports such as `@api/my-api/services/PetsService`. + +Use `services.moduleSpecifierBase` when the factory module is not the public generated API root, such as file-level factories (`./api/createAPIClient`) or packages that expose generated service files below another public root. The plugin appends the generated services directory and service file, such as `services/PetsService`. +``` + +- [ ] **Step 3: Run README diff check** + +Run: + +```bash +git diff -- packages/tree-shaking-plugin/README.md +``` + +Expected: The docs explain inherited factory-root behavior and explicit service-base behavior without describing resolver-derived output paths as supported. + +- [ ] **Step 4: Commit** + +```bash +git add packages/tree-shaking-plugin/README.md +git commit -m "docs: document tree-shaking service import bases" +``` + +--- + +### Task 8: Final Verification + +**Files:** +- Verify: `packages/tree-shaking-plugin/src/lib/transform/*.test.ts` +- Verify: `packages/tree-shaking-plugin/src/__tests__/core/*.test.ts` +- Verify: `e2e/projects/tree-shaking-bundlers` + +- [ ] **Step 1: Run focused transform tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/lib/transform/entrypoints.test.ts src/lib/transform/path-rendering.test.ts src/lib/transform/generated-metadata.test.ts src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run full tree-shaking package tests** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run +``` + +Expected: PASS. + +- [ ] **Step 3: Run typecheck and lint** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +``` + +Expected: both PASS. + +- [ ] **Step 4: Build the plugin** + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin build +``` + +Expected: PASS and `packages/tree-shaking-plugin/dist` is regenerated. + +- [ ] **Step 5: Run the local tree-shaking e2e fixture** + +Run: + +```bash +cd e2e && corepack yarn e2e:tree-shaking-bundlers-local +``` + +Expected: PASS. The wrapper builds publishable packages, publishes to the local Verdaccio registry, copies `tree-shaking-bundlers` into `/Users/radist/w/qraft-e2e`, updates dependencies, and runs the fixture through `npm run e2e:pre-build`, `npm run build`, and `npm run e2e:post-build`. + +- [ ] **Step 6: Run diff hygiene** + +Run: + +```bash +git diff --check +git status --short +``` + +Expected: `git diff --check` has no output. `git status --short` shows no uncommitted source changes. + +- [ ] **Step 7: Review final repository state** + +Run: + +```bash +git log --oneline -8 +git status --short +``` + +Expected: the recent commits match the completed tasks, and `git status --short` shows no uncommitted source changes. diff --git a/docs/superpowers/specs/2026-05-11-tree-shaking-core-test-deduplication-design.md b/docs/superpowers/specs/2026-05-11-tree-shaking-core-test-deduplication-design.md new file mode 100644 index 000000000..1a8ef083b --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-tree-shaking-core-test-deduplication-design.md @@ -0,0 +1,107 @@ +# Tree-Shaking Core Test Deduplication Design + +## Purpose + +`packages/tree-shaking-plugin/src/core.test.ts` has grown into a mixed contract suite for transform behavior, resolver behavior, source maps, naming collisions, and precreated-client support. The cleanup should reduce overlapping inline snapshots without weakening the behavioral guarantees around the two public tree-shaking modes: + +- `createAPIClientFn`, covering both context-based clients and explicit-options clients. +- `apiClient`, covering precreated clients built from a configured factory and options source. + +The goal is not to make the file smaller at any cost. The goal is to keep one strong regression per distinct behavior and remove tests that only repeat the same transform shape. + +## Boundaries + +This work is test-only. It must not change production transform behavior. + +The cleanup must preserve these architectural boundaries: + +- Context-based `createAPIClientFn` clients use `qraftReactAPIClient` when runtime context is required. +- Context-free callbacks and schema access can use direct operation imports and `qraftAPIClient`. +- Explicit-options `createAPIClientFn` clients are first-class transform targets, including `createAPIClient(apiContext!)` inside callbacks and inline call expressions. +- Precreated `apiClient` mode remains separate from context-based generation and uses configured client/factory/options metadata. +- Resolver/moduleAccess and source-map tests remain separate from output-shape snapshots because they protect integration boundaries rather than ordinary rewrite behavior. + +## Proposed Structure + +Group `core.test.ts` by behavioral contract: + +1. Transform plan and module access smoke tests. +2. `createAPIClientFn` context-based output. +3. `createAPIClientFn` no-context and explicit-options output. +4. Scope, collision, and partial-transform regressions. +5. Resolver and moduleAccess negative controls. +6. `apiClient` precreated output and precreated negative controls. +7. Source-map composition. + +The groups can stay in the same file for now. A later split into multiple files is optional and should only happen if the grouped file still feels hard to scan after deduplication. + +## Deduplication Plan + +Merge the two multi-operation context tests into one scenario: + +- `creates separate optimized clients for multiple operations from the same service` +- `creates separate optimized clients for operations from different services` + +The merged test should include both same-service and cross-service operations in one snapshot. + +Merge prefix-preservation tests into one scenario: + +- `preserves void and await prefixes for named client calls` +- `preserves void and await prefixes for inline client calls` + +The merged test should keep both named-client and inline-client call shapes. + +Consolidate zero-arg and no-context callback coverage: + +- Keep one context-based zero-arg test that proves `createAPIClient()` can optimize context-free callbacks without hoisting local named clients. +- Keep one no-context-factory test that proves a factory without runtime context can optimize both zero-arg no-options calls and options calls. +- Remove or fold the narrower duplicate snapshots into those two cases. + +Shrink explicit-options callback coverage: + +- Keep `splits explicit options clients across sibling callback scopes` as the main lexical-scope regression. +- Keep `optimizes mutation callbacks across onMutate, onError, and onSuccess` as the broad callback-lifecycle regression. +- Keep `aliases generated names for explicit options clients inside nested function scopes` as the collision-specific regression. +- Remove or reduce `optimizes explicit options clients created inside callbacks` if the remaining tests still cover named explicit-options clients inside callbacks. + +Consolidate precreated options import coverage: + +- Keep one direct separate-module options import test. +- Keep one same-module or re-export-through-client test. +- Convert fixture-relative barrel coverage to a narrower assertion if it still protects a distinct import-path rendering edge. + +Keep negative controls focused and short: + +- Same-named import from a different module. +- Unresolved configured module. +- Empty `createAPIClientFn`. +- Exported client skip. +- Local same-named precreated factory skip. +- Wrong imported precreated factory module skip. +- Namespace and dynamic precreated imports skip. + +## Non-Goals + +- Do not replace inline snapshots with broad `contains` assertions for the primary output contracts. +- Do not merge `createAPIClientFn` and `apiClient` fixtures into a generic helper that hides their architectural difference. +- Do not remove source-map, moduleAccess, resolver precedence, or collision-safety tests as part of simple deduplication. +- Do not update e2e fixtures in this cleanup. + +## Testing Strategy + +After the test edits, run the focused package checks: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +If the final diff changes only test organization and snapshots, e2e validation is optional. If any helper or production transform code changes, run the bundler-level validation separately. + +## Success Criteria + +- The number of full transform snapshots decreases. +- Each remaining snapshot has a named behavioral reason. +- `createAPIClientFn` context-based, `createAPIClientFn` explicit-options, and `apiClient` precreated modes each keep a clear primary happy-path contract. +- Negative controls still cover false positives. +- Package tests and typecheck pass. diff --git a/docs/superpowers/specs/2026-05-12-tree-shaking-core-test-refactor-design.md b/docs/superpowers/specs/2026-05-12-tree-shaking-core-test-refactor-design.md new file mode 100644 index 000000000..d348d472f --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-tree-shaking-core-test-refactor-design.md @@ -0,0 +1,201 @@ +# Tree-Shaking Core Test Refactor Design + +## Goal + +Refactor `packages/tree-shaking-plugin/src/core.test.ts` from one large catch-all file into a readable test suite with reusable fixtures, explicit behavioral domains, and a small coverage matrix for the tree-shaking transform. + +The refactor should preserve the existing exact snapshot contract while making it easier to add missing regression coverage for client modes, callback classes, mixed-source identity, context detection, and unsupported syntax. + +Implementation note: the old `packages/tree-shaking-plugin/src/core.test.ts` path has been removed. New core transform coverage lives in `packages/tree-shaking-plugin/src/__tests__/core/*.test.ts`. + +## Current Problems + +`core.test.ts` is now large enough that coverage gaps are hard to see. It mixes resolver tests, source-map tests, context-client snapshots, explicit-options snapshots, precreated-client snapshots, mixed-mode regressions, schema rewrites, collision tests, and fixture helpers in one file. + +Recent mixed-client fixes also showed two specific weaknesses: + +- Context inference and operation import identity can regress without a narrow test group around generated-source identity. +- Variable names inside fixtures can blur client-mode semantics, for example using a context-like name for an explicit options object. + +The file has strong inline snapshots and realistic source snippets. The refactor should keep that strength. + +## Target File Structure + +Create real Vitest test files under a core-focused test folder, without a central aggregator file: + +```text +packages/tree-shaking-plugin/src/__tests__/ + core/ + harness.ts + fixtures.ts + create-api-client-fn.test.ts + explicit-options.test.ts + precreated-api-client.test.ts + mixed-client-modes.test.ts + schema-and-imports.test.ts + resolution-and-module-access.test.ts + unsupported-and-safety.test.ts + source-maps.test.ts +``` + +The existing `core.test.ts` should be removed after its tests have moved. If the project or Vitest setup requires keeping the path temporarily, it may be left only during migration, not as a long-term aggregator. The final implementation removes the file rather than keeping an aggregator. + +## Shared Test Utilities + +`harness.ts` should own transform execution helpers: + +- `transformQraftTreeShaking(...)` +- `createTransformPlan(...)` convenience setup where needed +- source-map transform wiring +- fixture-root/module-access wiring + +`fixtures.ts` should own reusable generated API file builders: + +- context-based generated API fixtures +- precreated generated API fixtures +- service files for `pets` and `stores` +- client options modules +- filesystem fixture writer +- fixture module resolver/load helper + +Do not add assertion helpers until there is a concrete consumer that improves clarity without weakening the emitted transform contract. Inline snapshots remain in the test files. + +## Behavioral Test Files + +`create-api-client-fn.test.ts` covers zero-arg context-based clients, custom factory names, factory barrels, no-context factories, exported-client skip behavior, and generated context detection. + +`explicit-options.test.ts` covers named and inline `createAPIClient(options)` clients, sibling scopes, nested scopes, prefix preservation (`void` / `await`), mutation callback flows, and options naming cleanup. + +`precreated-api-client.test.ts` covers configured `apiClient` imports, default exports, separate/same options modules, partial transforms, precreated collision safety, invalid config, namespace/dynamic import skips, and operation invoke behavior. + +`mixed-client-modes.test.ts` covers files containing more than one client mode: + +- context-based `createAPIClientFn` plus explicit-options `createAPIClientFn` +- context-based `createAPIClientFn` plus precreated `apiClient` +- explicit-options `createAPIClientFn` plus precreated `apiClient` +- all three modes in one file +- same operation name across different generated roots +- same local client names in different scopes + +`schema-and-imports.test.ts` covers `.schema` rewrites, import aliasing, operation import dedupe, same operation names across generated roots, and helper import ordering. + +`resolution-and-module-access.test.ts` covers module access precedence, legacy resolver compatibility, resolver fallback, no filesystem fallback when `moduleAccess.load` returns `null`, same-named wrong-module imports, and unresolved specifiers. + +`unsupported-and-safety.test.ts` covers inputs that should not transform or should only partially transform: + +- unsupported remaining references +- computed properties +- optional chaining behavior +- destructuring aliases +- namespace client access +- dynamic import shapes +- exported clients + +`source-maps.test.ts` covers incoming source-map traceability and any future source-map-specific transform regressions. + +## Coverage Matrix + +Do not build a full Cartesian product. Add representative coverage for these dimensions: + +- Client mode: context, explicit options, precreated, mixed modes. +- Call shape: named client, inline client, top-level call, React-like component call, nested callback call. +- Callback class: key-only, query-client data read/write, fetch/prefetch/ensure, infinite, suspense, mutation, global query-client state. +- Source identity: same operation from different generated roots, same local operation export name, same local client variable name in separate scopes. +- Syntax safety: static member access, optional member access, computed member access, destructuring, namespace access. + +The existing tests already heavily cover `useQuery`, `getQueryKey`, `invalidateQueries`, `setQueryData`, `getQueryData`, `cancelQueries`, and `useMutation`. New coverage should prioritize callbacks with no current direct references: + +- `ensureQueryData` +- `fetchQuery` +- `prefetchQuery` +- `getQueryState` +- `getInfiniteQueryKey` +- `getInfiniteQueryData` +- `prefetchInfiniteQuery` +- `useSuspenseQuery` +- `useInfiniteQuery` +- `useQueries` +- `useMutationState` +- `isFetching` +- `isMutating` + +Each new callback-class test should use a realistic source snippet, not a synthetic list of method calls with no user context. + +## Realistic Fixture Rules + +React-like context usage should look like real React code: + +```ts +const apiContext = useContext(APIClientContext); + +useEffect(() => { + void createAPIClient(apiContext!).pets.findPetsByStatus.invalidateQueries(); +}, [apiContext]); +``` + +Mutation tests should use canonical mutation callback structure where it clarifies behavior: + +- `onMutate` +- `onError` +- `onSuccess` + +Top-level calls are still valid and should remain when the test intentionally covers top-level transform behavior. + +## Naming Cleanup Rules + +Clean up fixture variable names while moving tests: + +- Values passed to `createAPIClient(...)` as options should be named `apiOptions`, `clientOptions`, `queryClientOptions`, or `nodeClientOptions`, not `apiContext`. +- Values returned by `useContext(...)` may be named `apiContext`. +- Zero-arg context clients should use names like `contextApi` or `reactApi`. +- Explicit-options clients should use names like `optionsApi` or `nodeApi`. +- Precreated imports should use `APIClient` when testing configured public names, or `precreatedApi` when the exact import name is not the point. +- Mutation fixtures should prefer semantic names such as `petParams`, `previousPet`, and `rollbackContext`. + +When a strange name is part of a collision or aliasing regression, keep it and add a short English intent comment in the fixture source. + +## Migration Strategy + +Use a two-phase implementation. + +Phase 1 is a mechanical split: + +1. Add shared helpers. +2. Move one behavioral group at a time. +3. Keep snapshots semantically identical except for import ordering or naming changes intentionally caused by fixture cleanup. +4. Keep package tests green after each large move. +5. Remove `core.test.ts` after the last group moves. + +Phase 2 is a coverage pass: + +1. Add representative callback-class regressions. +2. Add mixed-mode regressions for callback classes beyond `useQuery`, `getQueryKey`, and `invalidateQueries`. +3. Add unsupported syntax and safety regressions. +4. Add context-detection variants for inferred third argument, custom context name, explicit `contextModule`, and aliased context import. +5. Refresh inline snapshots only when emitted output is semantically correct. + +## Failure Policy + +If a moved existing test fails, treat it as a migration bug and fix the test move or helper extraction. + +If a new test reveals a local production gap, fix production in the same implementation plan when the fix is narrow. + +If a new test reveals a broader production gap outside the refactor scope, do not hide it silently. Either keep it as an active failing regression if the team wants to fix it immediately, or record a separate follow-up design/plan with an explicit reason. + +## Verification + +The main verification commands are: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +During migration, targeted Vitest commands for individual new test files are expected before running the full package test. + +## Non-Goals + +- Do not change public plugin options. +- Do not change e2e fixtures as part of this core-test refactor. +- Do not replace inline snapshots with hidden helper assertions. +- Do not build a large test DSL that makes failures harder to understand. diff --git a/docs/superpowers/specs/2026-05-12-tree-shaking-mixed-client-identity-design.md b/docs/superpowers/specs/2026-05-12-tree-shaking-mixed-client-identity-design.md new file mode 100644 index 000000000..159ccf9f2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-tree-shaking-mixed-client-identity-design.md @@ -0,0 +1,104 @@ +# Tree-Shaking Mixed Client Identity Design + +## Purpose + +Two skipped mixed-mode regressions in `packages/tree-shaking-plugin/src/core.test.ts` expose the same class of production bug: the transform pipeline does not consistently distinguish operation usages that come from different client sources when `createAPIClientFn` and precreated `apiClient` modes are used in one file. + +The fix should make both skipped tests pass: + +- `keeps same-operation rewrites separate across all client modes` +- `supports createAPIClientFn and precreated apiClient clients in one file` + +## Scope + +This is a production transform fix with focused unit snapshot coverage. It should not change public plugin options, e2e fixtures, generated API shape, or callback metadata. + +The implementation should update the existing planner/mutator model rather than adding a narrow special case for the skipped tests. + +## Root Cause + +The transform currently builds several lookup keys from combinations like client local name, service name, operation name, callback name, and scope key. That is not enough in mixed-mode files: + +- a context/options client and a precreated client can reference operations with the same export name, such as `getPets`; +- those operations can come from different generated roots, such as `./context-api` and `./precreated-api`; +- aliased factory imports, such as `createAPIClient as createContextAPIClient`, still represent the configured factory and should participate in normal context/options planning. + +The pipeline needs a source-aware identity for client usages and operation usages. + +## Design + +Introduce or derive a stable source-aware key for every client usage. The key should distinguish: + +- `createAPIClientFn` context clients by resolved factory file and factory context configuration; +- `createAPIClientFn` explicit-options clients by the same factory identity plus the usage mode; +- precreated `apiClient` clients by resolved precreated client/factory identity and options source. + +The exact representation can be a helper function rather than a stored field if that keeps types simpler, but the same identity rule must be reused across planning and mutation. + +Use the source-aware key in these places: + +- operation grouping keys; +- usage lookup keys; +- local optimized client name allocation; +- schema source keys where the same collision risk applies; +- mutator lookup keys for named call rewriting; +- scope-split detection for non-precreated clients. + +This should prevent context `getPets` and precreated `getPets` from sharing an operation import or optimized client name just because their export names match. + +## Expected Output Shape + +When two generated roots export the same operation name, the output should keep both imports distinct through collision-safe aliases: + +```ts +import { getPets } from "./context-api/services/PetsService"; +import { getPets as _getPets } from "./precreated-api/services/PetsService"; +``` + +Context clients should still use `qraftReactAPIClient` when a React/runtime-context callback is present: + +```ts +const contextApi_pets_getPets = qraftReactAPIClient(getPets, { + useQuery +}, ContextAPIClientContext); +``` + +Precreated clients should still use `qraftAPIClient` with the configured options factory: + +```ts +const APIClient_pets_getPets = qraftAPIClient(_getPets, { + getQueryKey +}, createAPIClientOptions()); +``` + +Inline explicit-options calls should keep passing their original options expression: + +```ts +qraftAPIClient(findPetsByStatus, { + invalidateQueries +}, apiContext!).invalidateQueries(); +``` + +## Testing + +Unskip and make these tests pass: + +- `keeps same-operation rewrites separate across all client modes` +- `supports createAPIClientFn and precreated apiClient clients in one file` + +Their existing inline snapshots are intended as the expected post-fix contract. Minor Babel UID naming differences are acceptable only if they preserve the same structure and distinguish the generated roots clearly. + +Run: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +``` + +## Non-Goals + +- Do not add e2e coverage in this step. +- Do not redesign the full transform pipeline. +- Do not change public configuration names or generated API contracts. +- Do not add broad callback matrix tests. +- Do not remove unrelated tests. diff --git a/docs/superpowers/specs/2026-05-15-tree-shaking-transform-boundaries-design.md b/docs/superpowers/specs/2026-05-15-tree-shaking-transform-boundaries-design.md new file mode 100644 index 000000000..7c6b43cbe --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-tree-shaking-transform-boundaries-design.md @@ -0,0 +1,135 @@ +# Tree-Shaking Transform Boundaries Design + +## Purpose + +Define the allowed and forbidden transformation boundaries for +`@openapi-qraft/tree-shaking-plugin` before auditing the existing test matrix or +changing transform behavior. + +The plugin is not a type checker. It should only rewrite calls when it can +statically prove the generated factory owns the operation source and can safely +carry runtime options or context into the optimized operation client. + +## Shared Boundary + +The transform may optimize only when the generated factory module contains a +static `services` import that the plugin can resolve. + +If the factory was generated with `services: none`, services or a single +operation are passed by the caller as the first argument. The plugin must not +guess whether that argument is a services object, an operation, runtime options, +or a context value. These factories are out of scope for tree-shaking. + +Callback generation mode does not define transform eligibility. Factories with +`callbacks: all`, `callbacks: none`, or a specific callback list can be +optimized as long as the factory statically owns `services`. + +## `createAPIClientFn` Contract + +For configured `createAPIClientFn` factories: + +- `services: all` factories can be optimized. +- `services: none` factories must not be optimized. +- `callbacks: all`, `callbacks: none`, and specific callback lists can all be + optimized when services are statically imported. +- `createAPIClient(runtimeOptions)` can be optimized only when the factory owns + services; the runtime expression is preserved as the optimized client's third + argument. +- `createAPIClient(services)` and `createAPIClient(operation)` are not + optimized when the factory does not import services. + +Runtime helper selection: + +- If the tree-shaking config for the factory explicitly provides `context`, + hook callbacks use `qraftReactAPIClient`. +- If no context is configured, hook callbacks use `qraftAPIClient`. +- Non-hook callbacks always use `qraftAPIClient`. +- Passing runtime options does not by itself select `qraftReactAPIClient`. + +Examples: + +```ts +// Context configured. +createAPIClient().pets.getPets.useQuery(); +// -> qraftReactAPIClient(getPets, { useQuery }, APIClientContext) + +// No context configured. +createAPIClient(options).pets.getPets.useQuery(); +// -> qraftAPIClient(getPets, { useQuery }, options) + +createAPIClient(options).pets.getPets.getQueryData(); +// -> qraftAPIClient(getPets, { getQueryData }, options) +``` + +## `apiClient` Contract + +For configured pre-created `apiClient` clients: + +- The referenced generated factory must statically import services. +- If the factory was generated with `services: none`, the pre-created client is + not optimized. +- The configured options factory is treated as the runtime options source. + The plugin does not inspect whether it contains `queryClient`, `requestFn`, or + context. +- All `apiClient` transformations use `qraftAPIClient`. +- `qraftReactAPIClient` is never used in `apiClient` mode. + +Example: + +```ts +APIClient.pets.getPets.useQuery(); +// -> qraftAPIClient(getPets, { useQuery }, createAPIClientOptions()).useQuery() +``` + +## Test Matrix + +The test matrix should cover generated factory and client modes, not every +callback. Representative callbacks are only used to distinguish helper classes: +hook, non-hook, context-free helper, operation invoke, and schema access. + +### `create-api-client-fn.test.ts` + +- `services: all` with configured context and a hook callback emits + `qraftReactAPIClient(..., APIClientContext)`. +- `services: all` with no configured context, runtime options, and a hook + callback emits `qraftAPIClient(..., runtimeOptions)`. +- `services: all` with runtime options and a non-hook callback emits + `qraftAPIClient(..., runtimeOptions)`. +- `services: none` with an explicit services argument is skipped. +- `services: none` with an explicit operation argument is skipped. +- Callback generation mode does not get an exhaustive callback matrix; it needs + representative coverage that `callbacks: all`, `callbacks: none`, and specific + callback lists do not block transforms when services are imported. + +### `precreated-api-client.test.ts` + +- `apiClient` with a `services: all` factory and hook callback emits + `qraftAPIClient`. +- `apiClient` with a `services: all` factory and non-hook callback emits + `qraftAPIClient`. +- `apiClient` with a `services: none` factory is skipped. +- The emitted optimized client calls the configured options factory and does not + inspect its contents. + +### `mixed-client-modes.test.ts` + +Mixed files should prove helper selection remains isolated: + +- context `createAPIClientFn` hook usage emits `qraftReactAPIClient`; +- explicit-options `createAPIClientFn` hook usage emits `qraftAPIClient`; +- pre-created `apiClient` hook usage emits `qraftAPIClient`. + +### `schema-and-imports.test.ts` + +- `.schema` rewrites remain operation-import rewrites and do not depend on + callback/runtime helper selection. +- `services: none` factories are skipped for schema access when operation source + cannot be resolved from factory-owned services. + +## Out Of Scope + +- Parameter shape and const-parameter type coverage. +- Exhaustive callback-by-callback transform snapshots. +- Illegal type usage assertions from `*.types-test.ts` unless they define a + transform boundary. +- Runtime validation of options factory contents. diff --git a/docs/superpowers/specs/2026-05-16-tree-shaking-plugin-pipeline-architecture-design.md b/docs/superpowers/specs/2026-05-16-tree-shaking-plugin-pipeline-architecture-design.md new file mode 100644 index 000000000..4b0f32c8a --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-tree-shaking-plugin-pipeline-architecture-design.md @@ -0,0 +1,520 @@ +# Tree-Shaking Plugin Pipeline Architecture Design + +## Purpose + +Define the next architecture for `@openapi-qraft/tree-shaking-plugin` before +rewriting the current transform pipeline. + +The goal is not to remove existing tree-shaking capabilities. The goal is to +make the plugin core easier to reason about, test, and publish by separating +configuration compatibility, generated-source inspection, usage collection, and +AST mutation into clear layers. + +The first implementation should preserve transform semantics covered by the +current unit and e2e tests, while allowing output formatting and snapshot shape +to change when the transformed code remains semantically equivalent. + +## Source Of Truth + +The type and runtime surface introduced around commit `ce9479fc` is the primary +behavior contract for generated clients and optimized runtime clients. + +In particular, the plugin must respect the public behavior of: + +- `qraftAPIClient`; +- `qraftReactAPIClient`; +- generated `createAPIClient` overloads for context-backed clients; +- generated Node-style/object-options clients; +- configured pre-created clients. + +Tree-shaking snapshots are the contract for transformation semantics, not for +every detail of Babel output shape. If a snapshot contradicts the validated +client type/runtime surface, the transform and snapshot should be corrected; the +type/runtime surface should not be reinterpreted to fit the snapshot. + +## Transform Contract + +### Transform Criteria Matrix + +The tree-shaking contract should be readable without opening every snapshot. +The plugin should classify client usage into the matrix below before deciding +whether and how to rewrite it. + +| Plugin term | Source shape | Runtime input in emitted code | Runtime helper | Optimized when | Excluded when | +| --- | --- | --- | --- | --- | --- | +| Context-based generated client | `const api = createAPIClient()` where generated source returns `qraftReactAPIClient(..., Context)` | `Context` for context-backed hooks; no input for context-free helper buckets | `qraftReactAPIClient` for context-backed hook surfaces; `qraftAPIClient` for context-free helper buckets | configured generated factory resolves, source loads, factory statically owns `services`, operation source is resolved, usage is a static member chain | factory does not own services, operation source is unresolved, required context cannot be resolved, or usage is unsupported | +| Explicit-options generated client | `const api = createAPIClient(optionsExpression)` or inline `createAPIClient(optionsExpression)` | original `optionsExpression` | `qraftAPIClient` | same generated factory ownership proof as above, and usage is a supported static callback/schema access | options are not represented by one expression, services/operation are supplied by caller instead of owned by generated source, or usage is unsupported | +| Pre-created client | imported configured client export, for example `nodeAPIClient.pets.getPets.useQuery()` | configured `optionsFactory()` call | `qraftAPIClient` | client export resolves, export is created by configured factory, options factory is known, underlying generated factory owns `services`, operation source is resolved | client export missing, factory binding mismatch, namespace/dynamic import, underlying factory has no static services ownership, or operation source is unresolved | +| Schema access | `.schema` on any optimizable generated/pre-created operation | none | none | operation source is resolved from owned services | operation source is unresolved or service ownership is not proven | + +The matrix is intentionally based on the valid generated-client type/runtime +surface, especially the context and object-options behavior covered by the +`ce9479fc` type tests. + +Concrete rules: + +- optimize only configured entrypoints; +- require static ownership proof for generated services before resolving an + operation source; +- treat `createAPIClient(optionsExpression)` as explicit-options usage, not as + context usage; +- treat pre-created clients as runtime-options clients that receive the + configured options factory call; +- rewrite `.schema` to direct operation access without runtime helpers; +- remove the original client only when every reference is safely transformed; +- keep the original client when unsupported references remain; +- do not inspect option object keys to decide eligibility. + +Explicit exclusions: + +- factories where services or a single operation are supplied only by the + caller; +- computed member access; +- destructured client aliases; +- optional chains; +- namespace or dynamic imports of configured clients; +- exported local client declarations; +- unresolved generated module, generated source, services import, client export, + options factory, or operation source. + +### Eligibility + +The plugin may optimize only configured entrypoints. + +A generated factory entrypoint can be optimized when the plugin proves all of +these facts: + +- the configured factory module resolves through the bundler/module-access + boundary; +- the generated source is loadable; +- the generated factory statically owns a `services` import; +- the target operation source can be resolved from those owned services; +- the usage in app code is a static member chain. + +A generated factory entrypoint must not be optimized when services or a single +operation are supplied only by the caller and the generated factory does not +statically import services. In that case the plugin cannot prove whether the +argument is services, an operation, runtime options, or context. + +A pre-created client entrypoint can be optimized when the plugin proves all of +these facts: + +- the configured client export resolves from the configured client module; +- that export is created by the configured factory export from the configured + factory module; +- the configured options factory export/module is known; +- the underlying generated factory statically owns services; +- the target operation source can be resolved from those owned services. + +### Client Shapes + +The transform should model three semantic client shapes. + +`context` clients come from zero-argument generated factory calls whose factory +returns a context-backed `qraftReactAPIClient(..., Context)`. + +Rules: + +- hook callbacks that rely on React context must preserve context semantics; +- use `qraftReactAPIClient(operation, callbacks, Context)` for context-backed + hook surfaces; +- context-free key helpers may use `qraftAPIClient(operation, callbacks)` when + no runtime input is needed. + +`explicitOptions` clients come from `createAPIClient(optionsExpression)`. + +Rules: + +- preserve the original `optionsExpression`; +- use `qraftAPIClient(operation, callbacks, optionsExpression)`; +- do not wrap hooks through React context; +- do not inspect option object keys in the plugin to decide transform + eligibility. +- if TypeScript allows the callback on that generated object-options surface, + the plugin may rewrite the supported static usage without re-validating the + option object's runtime shape. + +`precreated` clients come from configured imported client exports. + +Rules: + +- use `qraftAPIClient(operation, callbacks, optionsFactory())`; +- preserve the configured options factory call as the runtime input; +- do not use `qraftReactAPIClient` for pre-created clients unless a separate + pre-created-context contract is designed later. +- validate the configured client export against the configured generated + factory before rewriting any usage of the imported client. + +### Callback And Schema Rewrites + +Callbacks are optimized per operation and per valid scope. + +The transform must: + +- import only used callbacks; +- group callbacks for the same operation/scope when doing so preserves + semantics; +- skip unsupported callback names; +- map `api.service.operation()` to `operationInvokeFn`; +- rewrite `.schema` directly to `operation.schema` without importing runtime + helpers. + +Callback availability must follow the valid client type surface. Generated +context object-options clients expose methods, not hooks. Generated context +zero-argument clients expose the hook/context surface plus context-free helpers +that are valid for that generated client. + +### Safety + +The transform must preserve original code whenever it cannot prove a rewrite is +safe. + +Rules: + +- if unsupported references remain, keep the original client binding/import + alive; +- remove the original client only when all references are safely transformed; +- do not transform exported local client declarations; +- do not transform computed member access; +- do not transform destructured client aliases; +- do not transform namespace or dynamic imports of configured clients; +- do not transform optional chains until short-circuit semantics are explicitly + designed; +- keep generated import/client names collision-safe in program and local scopes. + +## Diagnostics + +Replace the current `debug`-style behavior with an explicit diagnostics policy: + +```ts +type DiagnosticsLevel = 'error' | 'warn' | 'off'; +``` + +Add this option to the public config: + +```ts +type QraftTreeShakeOptions = { + diagnostics?: DiagnosticsLevel; +}; +``` + +Default: `diagnostics: 'error'`. + +Diagnostics apply only to unresolved transform candidates. Ordinary skips remain +silent. + +An ordinary skip is a file or syntax shape that does not provide a transform +candidate, for example: + +- no configured entrypoints; +- source gate has no relevant signals; +- no matching configured imports; +- unsupported syntax such as computed access or optional chains. + +An unresolved transform candidate is a case where the app source and config +indicate that a transform should be possible, but the plugin cannot complete the +proof, for example: + +- configured entrypoint module cannot be resolved; +- generated source cannot be loaded; +- generated services import is missing; +- generated services index cannot be resolved; +- pre-created client export is missing; +- pre-created client factory binding does not match config; +- operation source cannot be resolved; +- required runtime context cannot be resolved. + +Behavior: + +- `error`: throw a `QraftTreeShakeError` for unresolved transform candidates; +- `warn`: emit a warning with a stable reason and skip the candidate; +- `off`: skip the candidate silently. + +Do not add automatic dev/build detection in this design. The plugin core should +not infer policy from `process.env.NODE_ENV` or bundler-specific mode unless a +later design proves a reliable cross-bundler contract. + +## Pipeline Architecture + +The target pipeline is: + +```text +QraftTreeShakeOptions +-> normalizeEntrypoints() +-> shouldInspectSource() +-> parse source +-> inspectGeneratedEntrypoints() +-> collectTransformUsages() +-> buildSemanticRewritePlan() +-> applyRewritePlan() +``` + +### `normalizeEntrypoints` + +This is the only layer that understands the external config shape. + +It converts public options into a single internal `ClientEntrypoint[]` model. +Existing capabilities remain supported: + +- generated factory config; +- pre-created client export config; +- options factory config for pre-created clients; +- generated React context config; +- app-facing module specifiers. + +The target public config should use the same naming model as normalized +entrypoints: + +```ts +type QraftTreeShakeOptions = { + entrypoints?: Array< + | { + kind: 'clientFactory'; + factory: ModuleExportTarget; + reactContext?: { + exportName: string; + moduleSpecifier?: string; + }; + } + | { + kind: 'precreatedClient'; + client: ModuleExportTarget; + factory: ModuleExportTarget; + optionsFactory: ModuleExportTarget; + } + >; +}; +``` + +Downstream layers should not read old `createAPIClientFn` or `apiClient` +config directly. +They should consume normalized entrypoints. + +### `shouldInspectSource` + +This is a lightweight pre-parse gate. It is not a parser replacement. + +Allowed signals: + +- id passes include/exclude and is not in `node_modules`; +- at least one entrypoint is configured; +- source contains a configured local/export/module name or module specifier; +- source contains static member-chain hints such as `.schema`, `.useQuery`, or + `.getQueryKey`. + +Reliability rule: if the gate is uncertain, parse. The gate should reduce +obvious noise, not decide transform correctness. + +### `inspectGeneratedEntrypoints` + +This is the only layer that resolves and reads generated source. + +It owns: + +- resolving configured modules through `moduleAccess`; +- loading source through `moduleAccess`; +- following generated factory re-export chains; +- detecting generated services ownership; +- reading service import paths; +- resolving context import information when configured or reliably detected; +- validating pre-created client exports; +- validating pre-created factory binding; +- preserving configured options factory information. + +It returns generated metadata or a structured skip/diagnostic reason. It does +not mutate app source. + +### `collectTransformUsages` + +This layer works only with parsed app source plus proven generated metadata. + +It owns: + +- finding generated factory local clients; +- finding pre-created client imports; +- finding inline generated factory calls; +- finding `.schema` access; +- finding supported callback calls; +- deciding insertion scope and runtime input from normalized metadata. + +It must not read files, call resolvers, or guess generated layout. + +### `buildSemanticRewritePlan` And `applyRewritePlan` + +The design target is a semantic rewrite plan with explicit edits: + +- imports to add; +- optimized clients to declare; +- call sites to rewrite; +- schema accesses to rewrite; +- original declarations/imports to remove when fully transformed. + +The first implementation may keep parts of the current mutator if needed, but +the data passed into it should already use normalized runtime input rather than +historical `context` / `options` / `precreated` branching. + +## Internal Data Model + +The first implementation should introduce a normalized model close to this: + +```ts +type ClientEntrypoint = + | GeneratedFactoryEntrypoint + | PrecreatedClientEntrypoint; + +type GeneratedFactoryEntrypoint = { + kind: 'generatedFactory'; + factory: ImportTarget; + reactContext: ReactContextConfig | null; +}; + +type PrecreatedClientEntrypoint = { + kind: 'precreatedClient'; + client: ImportTarget; + factory: ImportTarget; + optionsFactory: ImportTarget; +}; + +type ImportTarget = { + exportName: string; + moduleSpecifier: string; +}; + +type ReactContextConfig = { + exportName: string; + moduleSpecifier: string | null; +}; +``` + +Generated-source inspection should produce metadata close to this: + +```ts +type GeneratedClientMetadata = { + entrypoint: ClientEntrypoint; + factoryFile: string; + servicesDir: string; + serviceImportPaths: Record; + reactContext: ReactContextConfig | null; +}; +``` + +Usage collection should produce semantic usage data close to this: + +```ts +type RuntimeInput = + | { kind: 'none' } + | { kind: 'context'; context: ReactContextConfig } + | { kind: 'optionsExpression'; expression: t.Expression } + | { kind: 'optionsFactoryCall'; target: ImportTarget }; +``` + +The exact names may change during implementation, but these ownership boundaries +should remain. + +## Debt To Delete + +The first implementation should delete debt in config/model and +resolver/source-inspection layers. + +Targeted deletions: + +- remove `hasExplicitContext` as standalone `ClientBinding` state; context + should come from normalized runtime context metadata; +- replace hand-built mode-specific identity keys with normalized entrypoint keys; +- stop passing broad `QraftTreeShakeOptions` into deep transform layers except + for diagnostics and module-access setup; +- remove duplicated checks that rediscover whether a client is generated, + explicit-options, or pre-created after normalization; +- remove hidden best-effort fallback paths in generated-source inspection; +- reduce repeated generated-info reads within one transform call. + +Do not delete these capabilities: + +- generated factory configuration; +- pre-created client configuration; +- options factory configuration; +- generated React context configuration; +- strict skip for factories without static service ownership. + +## Testing Strategy + +### Transform Contract Tests + +The focused `packages/tree-shaking-plugin/src/__tests__/core/*` tests remain the +main transform semantics contract. + +They should assert: + +- direct operation imports; +- correct runtime helper selection; +- correct context/options/optionsFactory propagation; +- safe local-scope insertion; +- safe client cleanup; +- schema rewrites without runtime helpers; +- collision-safe names; +- strict skips for unresolved ownership. + +Snapshot text can change when semantics are preserved, but changes must be +reviewed against the transform contract above. + +### Normalization Tests + +Add focused tests for config normalization: + +- public `entrypoints` items with `kind: 'clientFactory'` normalize to + `generatedFactory`; +- public `entrypoints` items with `kind: 'precreatedClient'` normalize to + `precreatedClient`; +- `reactContext` normalizes into `ReactContextConfig`; +- options factory module fallback is normalized once at the boundary. + +### Generated Metadata Tests + +Add or reorganize tests for generated-source inspection: + +- direct generated factory with static services import; +- generated factory barrel re-export; +- re-export cycle skip; +- source unavailable diagnostic; +- `services: none` skip; +- context import detection; +- explicit contextModule resolution from the app-facing importer; +- pre-created client export validation; +- pre-created factory mismatch skip; +- options factory target preservation. + +### Diagnostics Tests + +Add tests for `diagnostics`: + +- ordinary no-signal skip stays silent; +- unresolved transform candidate throws by default; +- `diagnostics: 'warn'` warns and skips; +- `diagnostics: 'off'` skips silently; +- thrown/warned reasons include stable code and enough entrypoint context for + debugging. + +### E2E Tests + +The `tree-shaking-bundlers` e2e fixture remains the final cross-bundler guard. +It should verify emitted bundle tokens and source-map behavior, not internal +architecture. + +## Implementation Phases + +1. Define transform contract and diagnostics types in tests. +2. Add `normalizeEntrypoints()` and route existing config through it. +3. Extract generated-source inspection behind a strict metadata boundary. +4. Update usage collection to consume normalized entrypoints and metadata. +5. Reduce mutator branching by passing normalized runtime input. +6. Introduce a fuller semantic rewrite plan in a follow-up phase if the first + implementation becomes too large. + +## Out Of Scope + +- A generated manifest file. +- Automatic dev/build diagnostics detection. +- Optional-chain transform support. +- Computed-property transform support. +- Runtime validation of options factory contents. +- Rewriting public generated-client type surfaces. diff --git a/docs/superpowers/specs/2026-05-17-tree-shaking-module-access-resolving-design.md b/docs/superpowers/specs/2026-05-17-tree-shaking-module-access-resolving-design.md new file mode 100644 index 000000000..759561d90 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-tree-shaking-module-access-resolving-design.md @@ -0,0 +1,337 @@ +# Tree-Shaking Module Access Resolving Design + +## Purpose + +Define the next `moduleAccess.resolve/load` contract for +`@openapi-qraft/tree-shaking-plugin`. + +The current implementation works for the covered cases, but the resolving layer +mixes several concepts: + +- native bundler resolving; +- user-provided fallback resolving; +- native source loading; +- user-provided source loading; +- adapter-local best-effort source fallback; +- normalized ids used for comparison; +- exact ids needed by virtual or query/hash loaders. + +This design intentionally allows breaking changes. The plugin is not public yet, +and the resolver/load layer should become easier to explain, test, and debug +before the feature is published. + +## Goals + +- Make adapter behavior explicit for Vite, Rollup, webpack, Rspack, esbuild, and + direct core/unit-test usage. +- Standardize user hook semantics. +- Keep the core transform independent from filesystem access. +- Preserve support for generated clients behind barrels, aliases, omitted + `index` segments, and explicit `.js` imports that resolve to `.ts` source. +- Make virtual/load-only generated modules possible through public hooks. +- Add diagnostics that show which resolving/loading stages were tried. +- Treat Rspack as a drift-prone adapter and test it accordingly. + +## Non-Goals + +- Reimplement each bundler's full module graph in the plugin. +- Resolve named export origins from a single `resolve(...)` call. +- Support arbitrary dynamic imports, namespace imports, computed properties, or + runtime-dependent generated-client shapes. +- Add filesystem reads to transform core. + +## Terms + +`native resolve` +: The bundler adapter's own module resolution API. + +`user resolve` +: A user-provided fallback resolver. It is not an override for successful native + resolution. + +`native load` +: A bundler adapter API that can return transformed or loader-pipeline source + for a resolved module id. + +`user load` +: A user-provided override source provider. It can provide source for virtual + modules or for custom generated-source stores. + +`adapter-local source fallback` +: Best-effort adapter implementation detail used only after user/native loading + misses. It may read ordinary files when an adapter can do so, but it is not a + public API and is not configurable. + +`exact id` +: The id returned by `resolve(...)`, including query/hash suffixes. + +`canonical id` +: A normalized id used for identity comparison, import ownership checks, and + cache keys where query/hash should not distinguish the generated source file. + +## Core Contract + +The transform core depends only on `QraftModuleAccess`: + +```ts +type QraftModuleAccess = { + resolve(specifier: string, importer: string): Promise | string | null; + load(resolvedId: string): Promise | string | null; +}; +``` + +Rules: + +- core never reads the filesystem directly; +- core resolves a configured/imported module before loading it; +- core loads source only through `moduleAccess.load(exactResolvedId)`; +- core may derive canonical ids for matching, but must preserve exact ids for + source loading; +- `resolve(...)` proves only which module id a specifier points to; +- export ownership still requires loading and traversing source; +- if ownership or generated-source loading cannot be proven, the transform must + skip or report according to `diagnostics`. + +## User Hook Semantics + +`moduleAccess.resolve` is a user override. + +The adapter must call user resolve before native resolve. If user resolve +returns `null`, throws, or returns no usable module id, the adapter continues to +native resolve. + +Successful user resolution wins. This makes explicitly configured custom module +access stronger than bundler behavior. + +`moduleAccess.load` is a user override source provider. + +The adapter must call user load before native loading and before any +adapter-local source fallback. If user load returns `null` or throws, the +adapter continues to native loading when available. + +This is a breaking standardization. The resulting contract is: + +```text +resolve: user resolve -> native resolve +load: user load -> native load -> adapter-local source fallback +``` + +For adapters without a native arbitrary source-loading API, `native load` is +`unsupported`, so the effective load order becomes: + +```text +load: user load -> adapter-local source fallback +``` + +User hooks are therefore escape hatches, not primary overrides. If a user needs +to replace a real filesystem module with alternate source, they should make the +native stage miss by resolving to a virtual/custom id or use a bundler-level +plugin before the tree-shaking plugin. + +Adapter-local source fallback is intentionally not configurable public API. It +may read from the host filesystem for ordinary generated files, but users should +not model it as a feature surface. If it misses or is unavailable, the adapter +continues as a load miss and `diagnostics` decides whether that miss throws, +warns, or stays silent. + +## Adapter Contract + +| Adapter | Resolve order | Load order | Native resolve | Native load | Adapter-local fallback | Unsupported / weak spots | +| --- | --- | --- | --- | --- | --- | --- | +| Agnostic core/unit tests | user | user | none | none | none | no automatic source access | +| Vite | user -> `this.resolve(..., { skipSelf: true })` | user -> adapter-local source fallback | Rollup-compatible plugin context | none | best-effort ordinary file read | virtual modules need user load unless source is available through the adapter fallback | +| Rollup | user -> `this.resolve(..., { skipSelf: true })` | user -> adapter-local source fallback | Rollup plugin context | none | best-effort ordinary file read | `this.load` is intentionally not part of the current contract until proven safe | +| webpack | user -> `loaderContext.getResolve({ dependencyType: 'esm' })` | user -> `loadModule(id)` -> adapter-local source fallback | webpack resolver | webpack loader pipeline | best-effort input filesystem read | fallback reads raw files and may diverge from loader output | +| Rspack | user -> `@rspack/resolver` built from `compiler.options.resolve` | user -> `loadModule(id)` -> adapter-local source fallback | reconstructed Rspack resolver | Rspack loader pipeline | best-effort input filesystem read | reconstructed resolve can drift from actual Rspack plugin behavior | +| esbuild | user -> `build.resolve(...)` | user -> adapter-local source fallback | esbuild build context | none | best-effort ordinary file read | virtual/onLoad-only modules need user load | + +### Vite/Rollup + +Vite and Rollup should allow user resolving to override identity first, then use +native resolving for aliases, extension resolution, and barrel paths when the +user hook misses. + +They currently do not have a standardized adapter-native arbitrary load stage in +this plugin. Until such a stage is proven against real fixtures, user load is +the only public way to provide virtual generated source. Adapter-local source +fallback remains an implementation detail for ordinary generated files. + +### webpack + +webpack should let user load override source first, then prefer `loadModule(id)` +because it is closest to the real loader pipeline when the user hook misses. + +Adapter-local source fallback is allowed only after user load and `loadModule` +miss. It is a weak fallback for plain generated files, not a substitute for +bundler source loading. + +### Rspack + +Rspack should let user load override source first, then use `loadModule(id)` for +source when the user hook misses. + +Resolving is the main risk. The adapter currently reconstructs resolution with +`@rspack/resolver` and `compiler.options.resolve`, which may diverge from actual +Rspack behavior when plugins or undocumented defaults participate. + +The design accepts this as the current implementation path, but the docs and +tests must label it as a drift-prone adapter. If a more native Rspack resolve +API becomes available, this adapter should move to it. + +### esbuild + +esbuild has `build.resolve(...)` but no generic `build.load(...)` equivalent for +arbitrary ids. The adapter can read ordinary generated files from disk, but +virtual/onLoad-only generated clients require `moduleAccess.load`. + +## Exact Id And Canonical Id + +Do not strip query/hash globally before loading source. + +The loader boundary receives exact ids: + +```text +resolve("./api", "src/App.tsx") -> "/repo/src/api.ts?raw#factory" +load("/repo/src/api.ts?raw#factory") +``` + +Only adapter-local source fallback may strip query/hash when reading from disk: + +```text +fs.readFile(stripQueryAndHash("/repo/src/api.ts?raw#factory")) +``` + +The transform may compute canonical ids for comparisons: + +```text +canonicalId("/repo/src/api.ts?raw#factory") -> "/repo/src/api.ts" +``` + +This keeps virtual/custom source providers working while preserving stable +identity checks for generated files. + +## Generated Source Traversal + +Resolving a module id is not enough to prove operation ownership. + +The generated-source inspection layer remains responsible for: + +- resolving configured factory/client modules; +- loading generated source; +- following `export { ... } from ...` and `export * from ...` chains; +- reading generated services imports; +- resolving the services index; +- resolving operation source paths from generated service exports. + +The planner/mutator must not call resolvers directly to guess generated layout. + +## Diagnostics Contract + +Resolving/loading diagnostics should be structured enough to explain why a +configured transform candidate was skipped or failed. + +For each unresolved candidate, diagnostics should include a compact trace: + +```text +resolve "./api" from "/repo/src/App.tsx": + native: miss + user: miss + +load "/repo/src/generated-api/index.ts": + native: miss + user: miss + fs: hit +``` + +The trace should be attached to the existing diagnostics flow: + +- `diagnostics: 'error'` throws with the trace in the reason; +- `diagnostics: 'warn'` prints the trace in the warning; +- `diagnostics: 'off'` suppresses the trace. + +The trace should not be printed for successful transforms by default. It exists +to make unresolved configured candidates actionable. + +## Test Contract + +### Unit Tests + +Resolver tests should lock the adapter contract table. + +Required cases: + +- user resolve wins over native resolve; +- native resolve runs after user miss/error; +- user load wins over native load for webpack/Rspack; +- user load runs before adapter-local source fallback; +- rejected source loading is not permanently cached; +- exact query/hash id is passed to user load; +- adapter-local source fallback strips query/hash locally when it reads files; +- agnostic module access does not read files; +- Rspack `tsConfig` normalization remains covered. + +Core/generated-metadata tests should lock transform-facing behavior: + +- source inspection loads exact resolved ids; +- canonical ids are used only for matching/ownership; +- missing source produces diagnostics with resolve/load trace; +- operation source resolution does not guess when generated services ownership + is not proven. + +### E2E Tests + +The multi-bundler fixture should add targeted scenarios instead of broad +snapshot churn. + +Required scenarios: + +- query/hash resolved generated client where user load receives the exact id; +- omitted `index` import resolved through bundler aliases; +- alias plus re-export barrel ownership traversal; +- virtual/load-only generated module through `moduleAccess.load`; +- Rspack alias/re-export scenario verified separately because its resolve path + is most likely to drift. + +E2E assertions should focus on emitted bundle semantics: + +- optimized operation import exists; +- unused full generated client is absent when fully transformed; +- correct helper (`qraftAPIClient` vs `qraftReactAPIClient`) remains selected; +- source-map assertions continue to map rewritten call sites to original source + when relevant. + +## Migration Shape + +This should be implemented as a resolving-layer refactor, not as incidental +patches inside transform planning. + +Recommended implementation order: + +1. Add trace-capable strategy result types in resolver common code. +2. Standardize adapter order to `user -> native -> adapter-local fallback` + according to this design. +3. Preserve exact ids through source loading and isolate canonical id helpers. +4. Thread trace data into unresolved diagnostics. +5. Add unit tests for adapter contract. +6. Add targeted e2e scenarios, with Rspack checked explicitly. +7. Update README module-access documentation. + +## Acceptance Criteria + +- Adapter behavior is documented in one table. +- User hooks have one meaning across all adapter entrypoints. +- Core transform still has no filesystem dependency. +- Exact ids are preserved for user load. +- Adapter-local source fallback is non-public, best-effort, and traceable. +- Rspack drift risk is documented and tested. +- Existing tree-shaking semantic tests pass. +- Multi-bundler e2e passes after targeted fixture additions. + +## Self-Review + +- The design allows breaking changes and does not preserve current inconsistent + user-load precedence where it conflicts with the new contract. +- The design does not pretend `resolve(...)` can identify named export origins. +- The design keeps source traversal in generated metadata/source inspection. +- The design avoids adding filesystem behavior to core. +- The design gives virtual modules a supported path through exact-id user load. diff --git a/docs/superpowers/specs/2026-05-25-tree-shaking-core-test-contract-fixes-design.md b/docs/superpowers/specs/2026-05-25-tree-shaking-core-test-contract-fixes-design.md new file mode 100644 index 000000000..ee4cb1e45 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-tree-shaking-core-test-contract-fixes-design.md @@ -0,0 +1,208 @@ +# Tree-Shaking Core Test Contract Fixes Design + +## Purpose + +Define a narrow cleanup pass for `@openapi-qraft/tree-shaking-plugin` core +transform tests after auditing the current branch's snapshot suite. + +The transformer should not validate arbitrary TypeScript or runtime option +shapes. Generated clients and user TypeScript define which application code is +valid. Core transform tests may still include synthetic source snippets when +the purpose is to verify emitted transform shape, import wiring, aliasing, and +snapshot stability. + +This design records which reviewed cases are real cleanup items and which case +is intentionally kept as synthetic transform-shape coverage. + +## Explicitly Accepted Non-Problem + +### One-arg object literal without option keys + +The test named +`optimizes clients with a single object literal even without known option keys` +uses this source: + +```ts +import { createAPIClient } from './api'; +import { useQuery } from '@openapi-qraft/react/callbacks/useQuery'; + +const api = createAPIClient({ useQuery }); + +api.pets.getPets.useQuery(); +``` + +This is accepted as synthetic transform-shape coverage. + +The test does not prove that `{ useQuery }` is a valid runtime options object +for a generated API client. It verifies that when the transformer receives a +single expression argument, it preserves the existing convention of treating +that argument as explicit runtime input and still wires callback imports, +aliases, operation imports, and the optimized client call consistently: + +```ts +const api_pets_getPets = qraftAPIClient(getPets, { + useQuery: _useQuery +}, { + useQuery +}); +api_pets_getPets.useQuery(); +``` + +This behavior stays unchanged. The transformer should not reject this source, +should not inspect whether the object literal is a semantically valid options +object, and should not add diagnostics for this case. + +The cleanup action is to make the test intent explicit so future review does +not reclassify it as a runtime-contract bug. + +## Problems + +### 1. Synthetic one-arg object-literal test is easy to misread + +The current test name makes the accepted synthetic case look like a public API +contract claim. A reviewer can reasonably read it as saying that +`createAPIClient({ useQuery })` is a valid generated-client runtime form. + +The test should be annotated or renamed so its real purpose is clear: +transform-shape coverage for a single expression argument, not runtime validity +coverage for generated clients. + +### 2. Precreated direct-invoke fixture lacks `operationInvokeFn` + +The shared precreated fixture currently models a generated factory with only +`useQuery` in `defaultCallbacks`, but one positive snapshot exercises direct +operation invocation: + +```ts +APIClient.pets.getPets(); +``` + +and expects `operationInvokeFn` in the optimized output. + +That test should model a real generated precreated client whose callback set +contains `operationInvokeFn`. The production transform should not change for +this point; the shared fixture is the inaccurate part. + +### 3. Explicit-options snapshot uses the context object as options + +One `explicit-options` snapshot uses: + +```ts +const apiOptions = APIClientContext; +``` + +and then passes `apiOptions` to `createAPIClient(...)`. + +This makes the test harder to read because `APIClientContext` is a React context +object, not the context value. The test is intended to cover inline explicit +options and `void`/`await` preservation, so the fixture should use React-like +code: + +```ts +const apiOptions = useContext(APIClientContext); +``` + +This is a test clarity/fidelity cleanup, not a production behavior change. + +## Target Behavior + +### One-arg object-literal synthetic transform test + +Keep the current transform behavior for: + +```ts +const api = createAPIClient({ useQuery }); + +api.pets.getPets.useQuery(); +``` + +Expected behavior: + +- transform succeeds; +- callback import aliasing remains stable; +- the single argument is emitted as the optimized client's runtime input; +- no diagnostics are reported; +- the test name/comment makes clear that this is not runtime-validity evidence. + +### Precreated direct invocation + +The shared precreated fixture should include `operationInvokeFn` whenever the +test surface includes direct operation invocation: + +```ts +APIClient.pets.getPets(); +``` + +The emitted optimized helper stays `qraftAPIClient` for precreated mode. + +### Explicit options fixture + +The explicit-options fixture should use a React context value: + +```ts +const apiOptions = useContext(APIClientContext); +``` + +The test should continue to verify that `void` and `await` prefixes survive +named and inline explicit-options rewrites. + +## Test Changes + +### `create-api-client-fn.test.ts` + +- Keep the existing one-arg object-literal positive snapshot. +- Rename or annotate the test so it says it is synthetic transform-shape + coverage and not generated-client runtime-validity coverage. +- Do not add diagnostics for this case. +- Do not change `callbacks.ts` or `state.ts` for this case. + +### `precreated-api-client.test.ts` and `fixtures.ts` + +- Update the shared precreated generated factory fixture so its callback set + includes `operationInvokeFn` when direct operation invocation is covered. +- Keep precreated mode emitted helper selection as `qraftAPIClient`. +- Do not change production transform behavior for this point. + +### `explicit-options.test.ts` + +- Replace `const apiOptions = APIClientContext` with + `const apiOptions = useContext(APIClientContext)`. +- Add the `useContext` import in the fixture source. +- Preserve the original test purpose: `void` and `await` prefixes must survive + named and inline explicit-options rewrites. + +### `AGENTS.md` + +No separate finding is needed for the local core test guide. During +implementation, check whether fixture ownership wording remains accurate after +the shared fixture update. Update the guide only if its instructions become +stale. + +## Non-Goals + +- Do not add TypeScript validation to the transformer. +- Do not reject one-argument `createAPIClient({ useQuery })` synthetic tests. +- Do not introduce diagnostics for object literals without `requestFn` or + `queryClient`. +- Do not introduce callback metadata for network hooks vs state-only hooks. +- Do not change callback runtime implementations. +- Do not broaden this cleanup into a full snapshot refactor. +- Do not change e2e fixture behavior unless focused verification exposes a + concrete mismatch. + +## Verification + +Run focused tests first: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run src/__tests__/core/create-api-client-fn.test.ts src/__tests__/core/precreated-api-client.test.ts src/__tests__/core/explicit-options.test.ts +``` + +Then run full package verification: + +```bash +corepack yarn workspace @openapi-qraft/tree-shaking-plugin test -- --run +corepack yarn workspace @openapi-qraft/tree-shaking-plugin typecheck +corepack yarn workspace @openapi-qraft/tree-shaking-plugin lint +git diff --check +``` diff --git a/docs/superpowers/specs/2026-05-27-tree-shaking-external-import-specifiers-design.md b/docs/superpowers/specs/2026-05-27-tree-shaking-external-import-specifiers-design.md new file mode 100644 index 000000000..7f094f99c --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-tree-shaking-external-import-specifiers-design.md @@ -0,0 +1,269 @@ +# Tree-Shaking External Import Specifiers Design + +## Purpose + +Capture follow-up design notes for `@openapi-qraft/tree-shaking-plugin` import +specifier handling when a generated entrypoint is imported through an alias, +bare package specifier, or third-party module. + +This spec records risks and target behavior before an implementation plan is +written. + +## Problem + +The current transform can use a resolver to recognize a configured generated +factory: + +```ts +import { createMyAPIClient } from '@api/my-api'; +``` + +with config: + +```ts +{ + kind: 'clientFactory', + factory: { + exportName: 'createMyAPIClient', + moduleSpecifier: '@api/my-api', + }, + reactContext: { + exportName: 'APIClientContext', + }, +} +``` + +The resolver may map `@api/my-api` to a physical source file such as +`src/api/index.ts`. The transform currently uses that physical file path to +compose emitted operation and context imports: + +```ts +import { getPets } from "./api/services/PetsService"; +import { APIClientContext } from "./api/APIClientContext"; +``` + +That behavior is risky. Resolving paths is useful for validation and metadata +loading, but resolved physical paths should not automatically become public +emitted import specifiers. + +For real third-party packages this can produce imports such as: + +```ts +import { getPets } from "../../node_modules/@scope/api/dist/services/PetsService"; +import { APIClientContext } from "../../node_modules/@scope/api/dist/APIClientContext"; +``` + +Those imports can bypass package `exports`, depend on package manager layout, +break under pnpm symlinks or virtual stores, and couple user output to private +package internals. + +## Target Behavior + +Resolver output may be used to: + +- validate that a configured factory points to a generated client; +- load generated client metadata; +- inspect the generated factory to discover service ownership and context + relationships; +- resolve local source files while analyzing the generated client. + +Resolver output must not, by itself, decide the import specifiers emitted into +the transformed user module. + +For aliased, bare, or third-party factory imports, emitted imports should +preserve a public/module-specifier-based boundary. When +`reactContext.moduleSpecifier` is omitted, the context import should come from +the same public module specifier as `factory.moduleSpecifier`: + +```ts +{ + kind: 'clientFactory', + factory: { + exportName: 'createMyAPIClient', + moduleSpecifier: '@api/my-api', + }, + reactContext: { + exportName: 'APIClientContext', + }, +} +``` + +the transform should emit: + +```ts +import { getPets } from "@api/my-api/services/PetsService"; +import { APIClientContext } from "@api/my-api"; +``` + +not physical relative paths derived from the resolver target. + +When the context lives in a different public module, users can configure it +explicitly: + +```ts +{ + kind: 'clientFactory', + factory: { + exportName: 'createMyAPIClient', + moduleSpecifier: '@api/my-api', + }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: '@api/my-api/APIClientContext', + }, +} +``` + +the transform should emit: + +```ts +import { getPets } from "@api/my-api/services/PetsService"; +import { APIClientContext } from "@api/my-api/APIClientContext"; +``` + +This explicit path is also the escape hatch for aliased generated factory +internals, such as a factory that imports +`APIClientContext as InternalContext` and passes `InternalContext` as the third +argument to `qraftReactAPIClient(...)`. + +## Service Import Configuration + +The plugin needs a way to describe the public module specifier used as the base +for generated service-file imports. + +Add an entrypoint-level services import configuration for every entrypoint kind +that can emit operation imports. This includes `clientFactory` and +`precreatedClient`. The config should specify only the generated API public +module specifier base, not an export name and not a concrete service index +module: + +```ts +{ + kind: 'clientFactory', + factory: { + exportName: 'createMyAPIClient', + moduleSpecifier: '@api/my-api', + }, + services: { + moduleSpecifierBase: '@api/my-api', + }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: '@api/my-api/APIClientContext', + }, +} +``` + +Operation imports are composed from that public module specifier base plus the +generated service-file suffix discovered from the generated `services` object: + +```ts +import { getPets } from "@api/my-api/services/PetsService"; +``` + +When `services.moduleSpecifierBase` is omitted, the transform uses +`factory.moduleSpecifier` as the public generated API root for service-file +imports. This default is normalized up front, so internal transform code always +has a concrete services module specifier base. For example: + +```ts +{ + kind: 'clientFactory', + factory: { + exportName: 'createMyAPIClient', + moduleSpecifier: '@api/my-api', + }, + reactContext: { + exportName: 'APIClientContext', + }, +} +``` + +emits: + +```ts +import { getPets } from "@api/my-api/services/PetsService"; +``` + +This default is an intentional tree-shaking layout assumption: the public +generated API root exposes service files below the same module root. If a +package uses a different public layout, users should configure +`services.moduleSpecifierBase` explicitly. + +The service export name does not need to be configurable for this design. The +generated services object is already discovered from the generated client, and +operation export names such as `getPets` still come from service files. + +For `precreatedClient` entrypoints, `services.moduleSpecifierBase` follows the +same rule. If omitted, operation imports use `factory.moduleSpecifier` as the +public generated API root; if provided, operation imports use the explicit +services base. + +## Import Specifier Rules + +The transform should prefer emitted import specifiers in this order: + +1. Explicit config: + - `reactContext.moduleSpecifier` for context imports; + - `services.moduleSpecifierBase` for operation imports. +2. Default public context import: + - when `reactContext.exportName` is configured but + `reactContext.moduleSpecifier` is omitted, import that context export from + `factory.moduleSpecifier`. +3. Default public operation import: + - when `services.moduleSpecifierBase` is omitted, compose operation imports from + `factory.moduleSpecifier` plus the service-file suffix discovered from the + generated `services` object. + +The transform should not infer emitted service or context import specifiers from +the generated factory's physical source file. Physical paths remain analysis +inputs only. For path-like `factory.moduleSpecifier` values such as `./api`, the +same default rule preserves the current local-source behavior by composing +`./api/services/PetsService`. + +The transform should not infer emitted context import specifiers for bare +factory imports from the generated factory's physical source file. Use +`factory.moduleSpecifier` by default, or `reactContext.moduleSpecifier` when the +context is not exported from the factory module. This treats +`factory.moduleSpecifier` as a user-provided public contract for the configured +context export. + +## Test Coverage To Add + +- Aliased local factory import: + - `import { createMyAPIClient } from '@api/my-api';` + - resolver maps it to a local generated client; + - emitted imports use `@api/my-api/services/PetsService` and + `@api/my-api` for the default context import, not `./api/...`. +- Explicit context module: + - `reactContext.moduleSpecifier: '@api/my-api/APIClientContext'`; + - emitted context import uses `@api/my-api/APIClientContext`. +- Aliased generated context internals: + - generated factory imports + `APIClientContext as InternalContext` from `./APIClientContext`; + - without `reactContext.moduleSpecifier`, emitted context import uses + `factory.moduleSpecifier`; + - with explicit `reactContext.moduleSpecifier`, emitted context import uses + that explicit module. +- Third-party-style factory import: + - resolver maps `@scope/api` to a fixture path under `node_modules`; + - emitted imports do not contain `node_modules` or physical relative paths. +- Explicit services base: + - `services.moduleSpecifierBase: '@scope/api/public'`; + - emitted operation imports use `@scope/api/public/services/PetsService`. +- Precreated client entrypoint: + - `services.moduleSpecifierBase` works for `kind: 'precreatedClient'`; + - without it, operation imports use `factory.moduleSpecifier` as the public + generated API root. +- Existing local relative imports: + - keep current relative emitted import behavior for `./api` style generated + clients. + +## Non-Goals + +- Do not stop using the resolver for validation or metadata loading. +- Do not require users to configure service export names. +- Do not make bundler resolution decisions inside the transform. The transform + should emit stable public specifiers and let the bundler resolve them. +- Do not broaden this into a full entrypoint API redesign beyond import + specifier ownership. diff --git a/e2e/.gitignore b/e2e/.gitignore index a487dc8bc..9e65fc678 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -21,3 +21,5 @@ yarn-error.log* !.yarn/releases !.yarn/sdks !.yarn/versions + +projects/*/package-lock.json \ No newline at end of file diff --git a/e2e/README.md b/e2e/README.md index e94e09f2a..6deb939c0 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,8 +1,8 @@ -# @team-monite/e2e - End-to-End Package Testing +# @qraft/e2e - End-to-End Package Testing ## Overview -This package is dedicated to testing the functionality of the `@monite/*` packages. It allows for verification of package installation capabilities and the ability of building projects using these installed packages. +This package is dedicated to testing the functionality of the `@qraft/*` packages. It allows for verification of package installation capabilities and the ability of building projects using these installed packages. The testing process utilizes [Verdaccio](https://verdaccio.org/), a local package registry, to simulate the publication and installation of packages in a controlled environment. This approach ensures the reliability of the packages before they are released into production. @@ -32,9 +32,16 @@ This command will sequentially run: - `e2e:build-projects` - Build the test projects. - `e2e:unpublish-from-private-registry` - Remove packages from the local registry for reuse in future tests. -## Test Stands +### Local Multi-Bundler Run -- `projects/sdk-drop-in-with-vite` - SDK React with Vite as the bundler. +To run only the `tree-shaking-bundlers` fixture in an isolated local directory, use: + +```bash +yarn e2e:tree-shaking-bundlers-local +``` + +This copies `e2e/projects/tree-shaking-bundlers` into `/Users/radist/w/qraft-e2e`, sets `TEST_PROJECTS_DIR` to that directory, and then runs the same publish/update/build/unpublish flow as CI. +It also builds the publishable workspace packages first, matching the GitHub workflow. ## Adding a New Test Stand diff --git a/e2e/bin/tree-shaking-bundlers-local-e2e.sh b/e2e/bin/tree-shaking-bundlers-local-e2e.sh new file mode 100755 index 000000000..7cb6f1a53 --- /dev/null +++ b/e2e/bin/tree-shaking-bundlers-local-e2e.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env sh + +set -o errexit +set -o nounset + +BASE_DIR=$(dirname "$(readlink -f "$0")") +E2E_DIR=$(dirname "$BASE_DIR") +MONOREPO_ROOT=$(cd "$E2E_DIR/.." && pwd) + +SOURCE_PROJECT="$MONOREPO_ROOT/e2e/projects/tree-shaking-bundlers" +TARGET_ROOT="${TREE_SHAKING_E2E_DIR:-/Users/radist/w/qraft-e2e}" + +NPM_PUBLISH_SCOPES="${NPM_PUBLISH_SCOPES:-openapi-qraft qraft}" +NPM_PUBLISH_REGISTRY="${NPM_PUBLISH_REGISTRY:-http://localhost:4873}" +TEST_PROJECTS_DIR="$TARGET_ROOT" + +cleanup() { + status=$? + trap - EXIT INT TERM + + if [ "${PUBLISHED_TO_PRIVATE_REGISTRY:-0}" -eq 1 ]; then + echo "Unpublishing packages from private registry..." + ( + cd "$E2E_DIR" && + NPM_PUBLISH_SCOPES="$NPM_PUBLISH_SCOPES" \ + NPM_PUBLISH_REGISTRY="$NPM_PUBLISH_REGISTRY" \ + yarn e2e:unpublish-from-private-registry + ) || echo "Warning: failed to unpublish packages from private registry." >&2 + fi + + exit "$status" +} + +trap cleanup EXIT INT TERM + +echo "Preparing local e2e workspace at $TARGET_ROOT..." +rm -rf "$TARGET_ROOT" +mkdir -p "$TARGET_ROOT" +cp -a "$SOURCE_PROJECT" "$TARGET_ROOT/" + +echo "Building publishable packages..." +( + cd "$MONOREPO_ROOT" && + yarn build:publishable +) + +rm -rf "$E2E_DIR/verdaccio-storage" + +echo "Publishing packages to private registry..." +( + cd "$E2E_DIR" && + NPM_PUBLISH_SCOPES="$NPM_PUBLISH_SCOPES" \ + NPM_PUBLISH_REGISTRY="$NPM_PUBLISH_REGISTRY" \ + yarn e2e:publish-to-private-registry +) +PUBLISHED_TO_PRIVATE_REGISTRY=1 + +echo "Updating local test project from private registry..." +( + cd "$E2E_DIR" && + NPM_PUBLISH_SCOPES="$NPM_PUBLISH_SCOPES" \ + NPM_PUBLISH_REGISTRY="$NPM_PUBLISH_REGISTRY" \ + TEST_PROJECTS_DIR="$TEST_PROJECTS_DIR" \ + yarn e2e:update-projects-from-private-registry +) + +echo "Building local test project..." +( + cd "$E2E_DIR" && + TEST_PROJECTS_DIR="$TEST_PROJECTS_DIR" \ + yarn e2e:build-projects +) diff --git a/e2e/bin/unpublish-from-private-registry.sh b/e2e/bin/unpublish-from-private-registry.sh index 885e99dcd..f839ba19d 100755 --- a/e2e/bin/unpublish-from-private-registry.sh +++ b/e2e/bin/unpublish-from-private-registry.sh @@ -22,7 +22,7 @@ unpublish_from_registry() { sh -c "(cd '$(monorepo_root)' && yarn workspaces foreach --recursive -t --no-private \ $from_flags \ - exec npm unpublish --force --registry '${NPM_PUBLISH_REGISTRY:-http://localhost:4873/}')" + exec npm unpublish --force --registry '${NPM_PUBLISH_REGISTRY:-http://localhost:4873/}') || true" } # Cleanup on exit or interrupt diff --git a/e2e/package.json b/e2e/package.json index 2dda880fc..5e4929b05 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,7 +9,8 @@ "e2e:unpublish-from-private-registry": "NPM_PUBLISH_SCOPES='openapi-qraft qraft' yarn exec bin/unpublish-from-private-registry.sh", "e2e:update-projects-from-private-registry": "NPM_PUBLISH_SCOPES='openapi-qraft qraft' yarn exec bin/update-projects-from-private-registry.sh", "e2e:build-projects": "yarn exec bin/build-projects.sh", - "e2e:test": "yarn e2e:publish-to-private-registry && yarn e2e:update-projects-from-private-registry && yarn e2e:build-projects && yarn e2e:unpublish-from-private-registry" + "e2e:test": "yarn e2e:publish-to-private-registry && yarn e2e:update-projects-from-private-registry && yarn e2e:build-projects && yarn e2e:unpublish-from-private-registry", + "e2e:tree-shaking-bundlers-local": "yarn exec bin/tree-shaking-bundlers-local-e2e.sh" }, "packageManager": "yarn@3.5.0", "devDependencies": { diff --git a/e2e/projects/tree-shaking-bundlers/.gitignore b/e2e/projects/tree-shaking-bundlers/.gitignore new file mode 100644 index 000000000..cc6b166ea --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/.gitignore @@ -0,0 +1,4 @@ +/package-lock.json +/dist +node_modules +/src/generated-api diff --git a/e2e/projects/tree-shaking-bundlers/openapi.yaml b/e2e/projects/tree-shaking-bundlers/openapi.yaml new file mode 100644 index 000000000..405af9340 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/openapi.yaml @@ -0,0 +1,82 @@ +openapi: 3.1.0 +info: + title: Tree Shaking Vite Fixture + version: 1.0.0 +paths: + /pets: + get: + operationId: getPets + tags: + - pets + responses: + '200': + description: Pets list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + post: + operationId: createPet + tags: + - pets + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewPet' + responses: + '200': + description: Created pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + /stores: + get: + operationId: getStores + tags: + - stores + responses: + '200': + description: Stores list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Store' +components: + schemas: + Pet: + type: object + additionalProperties: false + required: + - id + - name + properties: + id: + type: integer + name: + type: string + NewPet: + type: object + additionalProperties: false + required: + - name + properties: + name: + type: string + Store: + type: object + additionalProperties: false + required: + - id + - title + properties: + id: + type: integer + title: + type: string diff --git a/e2e/projects/tree-shaking-bundlers/package.json b/e2e/projects/tree-shaking-bundlers/package.json new file mode 100644 index 000000000..93f3c178a --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/package.json @@ -0,0 +1,39 @@ +{ + "name": "tree-shaking-bundlers", + "private": true, + "version": "1.0.0", + "description": "End-to-end multi-bundler fixture for @openapi-qraft/tree-shaking-plugin", + "type": "module", + "sideEffects": false, + "scripts": { + "codegen": "openapi-qraft --plugin tanstack-query-react --plugin openapi-typescript ./openapi.yaml --clean -o src/generated-api --openapi-types-import-path '../schema.ts' --openapi-types-file-name schema.ts --explicit-import-extensions --create-api-client-fn createBarrelAPIClient filename:create-barrel-api-client context:BarrelAPIClientContext --create-api-client-fn createNodeAPIClient filename:create-node-api-client --create-api-client-fn createRelativeAPIClient filename:create-relative-api-client context:RelativeAPIClientContext --create-api-client-fn createRelativeExtAPIClient filename:create-relative-ts-api-client context:RelativeExtAPIClientContext --create-api-client-fn createAliasAPIClient filename:create-alias-api-client context:AliasAPIClientContext --create-api-client-fn createAliasDirectAPIClient filename:create-alias-direct-api-client context:AliasDirectAPIClientContext --create-api-client-fn createBarrelPrecreatedAPIClient filename:create-barrel-precreated-api-client --create-api-client-fn createRelativePrecreatedAPIClient filename:create-relative-precreated-api-client --create-api-client-fn createRelativeExtPrecreatedAPIClient filename:create-relative-ts-precreated-api-client --create-api-client-fn createAliasDirectPrecreatedAPIClient filename:create-alias-direct-precreated-api-client", + "build": "node ./scripts/build.mjs", + "build:rspack": "QRAFT_TREE_SHAKE_SCENARIO=mixed-context-precreated-mirrors node ./scripts/build-rspack.mjs", + "e2e:pre-build": "npm run codegen", + "e2e:post-build": "node ./scripts/assert-dist.mjs" + }, + "dependencies": { + "@esbuild-plugins/tsconfig-paths": "^0.1.2", + "@openapi-qraft/cli": "latest", + "@openapi-qraft/react": "latest", + "@openapi-qraft/tree-shaking-plugin": "latest", + "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "tsconfig-paths-webpack-plugin": "^4.2.0" + }, + "devDependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "@rspack/cli": "latest", + "@rspack/core": "latest", + "@types/node": "latest", + "esbuild": "latest", + "esbuild-loader": "latest", + "rollup": "latest", + "rollup-plugin-esbuild": "latest", + "typescript": "latest", + "vite": "latest", + "webpack": "latest", + "webpack-cli": "latest", + "@rspack/resolver": "^0.4.0" + } +} diff --git a/e2e/projects/tree-shaking-bundlers/rollup.config.mjs b/e2e/projects/tree-shaking-bundlers/rollup.config.mjs new file mode 100644 index 000000000..9244df5e8 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/rollup.config.mjs @@ -0,0 +1,47 @@ +import { resolve } from 'node:path'; +import { qraftTreeShakeRollup } from '@openapi-qraft/tree-shaking-plugin/rollup'; +import alias from '@rollup/plugin-alias'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import esbuild from 'rollup-plugin-esbuild'; +import { getTreeShakePluginOptions } from './scripts/module-access-fixtures.mjs'; +import { + getBundlerOutputDir, + getScenario, + isExternalModuleRequest, +} from './scripts/shared.mjs'; + +const scenario = getScenario(process.env.QRAFT_TREE_SHAKE_SCENARIO ?? ''); + +export default { + plugins: [ + alias({ + entries: [ + { + find: /^@\//, + replacement: `${resolve(process.cwd(), 'src')}/`, + }, + ], + customResolver: nodeResolve({ + extensions: ['.ts', '.tsx', '.mts', '.cts', '.mjs', '.js'], + }), + }), + qraftTreeShakeRollup(getTreeShakePluginOptions(scenario)), + esbuild({ + include: /\.[cm]?[jt]sx?$/, + sourceMap: true, + minify: false, + target: 'es2020', + }), + ], + input: resolve(process.cwd(), scenario.entry), + external: isExternalModuleRequest, + output: { + dir: getBundlerOutputDir('rollup', scenario), + format: 'es', + sourcemap: true, + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name].js', + assetFileNames: 'assets/[name][extname]', + }, + treeshake: true, +}; diff --git a/e2e/projects/tree-shaking-bundlers/rspack.config.mjs b/e2e/projects/tree-shaking-bundlers/rspack.config.mjs new file mode 100644 index 000000000..2909180cf --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/rspack.config.mjs @@ -0,0 +1,95 @@ +import { resolve } from 'node:path'; +import { qraftTreeShakeRspack } from '@openapi-qraft/tree-shaking-plugin/rspack'; +import TerserPlugin from 'terser-webpack-plugin'; +import { getTreeShakePluginOptions } from './scripts/module-access-fixtures.mjs'; +import { + getBundlerOutputDir, + getScenario, + isExternalModuleRequest, +} from './scripts/shared.mjs'; + +const scenario = getScenario(process.env.QRAFT_TREE_SHAKE_SCENARIO ?? ''); + +export default { + mode: 'production', + target: 'web', + devtool: 'source-map', + entry: { + [scenario.name]: resolve(process.cwd(), scenario.entry), + }, + output: { + path: getBundlerOutputDir('rspack', scenario), + filename: '[name].js', + chunkFilename: 'chunks/[name].js', + assetModuleFilename: 'assets/[name][ext]', + module: true, + clean: true, + }, + experiments: { + outputModule: true, + }, + resolve: { + extensions: ['.ts', '.tsx', '.mts', '.cts', '.mjs', '.js'], + tsConfig: resolve(process.cwd(), 'tsconfig.json'), + extensionAlias: { + '.js': ['.ts', '.js'], + '.mjs': ['.mts', '.mjs'], + '.cjs': ['.cts', '.cjs'], + }, + }, + externalsType: 'module', + externals: [ + ({ request }, callback) => { + if (request && isExternalModuleRequest(request)) { + callback(null, `module ${request}`); + return; + } + + callback(); + }, + ], + module: { + rules: [ + { + test: /\.[cm]?[jt]sx?$/, + exclude: /node_modules/, + loader: 'esbuild-loader', + options: { + loader: 'ts', + target: 'es2020', + }, + }, + ], + }, + optimization: { + usedExports: true, + sideEffects: true, + innerGraph: true, + minimize: true, + minimizer: [ + new TerserPlugin({ + terserOptions: { + compress: { + dead_code: true, + collapse_vars: false, + evaluate: false, + inline: false, + reduce_vars: false, + passes: 1, + }, + format: { + beautify: true, + comments: false, + }, + keep_classnames: true, + keep_fnames: true, + mangle: false, + }, + extractComments: false, + }), + ], + }, + plugins: [ + qraftTreeShakeRspack(getTreeShakePluginOptions(scenario)), + ], +}; diff --git a/e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs b/e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs new file mode 100644 index 000000000..29ec4b85d --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/scripts/assert-dist.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'; +import { + bundlers, + getScenario, + scenarios, + supportsScenarioBundler, +} from './scenarios.mjs'; +import { getBundleMapPath, getBundlePath } from './shared.mjs'; + +const modeExpectations = { + context: () => ({ + include: [/qraftReactAPIClient(?:__|\()/], + exclude: [/qraftAPIClient(?:__|\()/], + }), + precreated: (scenario) => ({ + include: [/qraftAPIClient(?:__|\()/], + exclude: [/qraftReactAPIClient(?:__|\()/], + }), + mixed: () => ({ + include: [/qraftReactAPIClient(?:__|\()/, /qraftAPIClient(?:__|\()/], + exclude: [], + }), + apiOnly: () => ({ + include: [/qraftAPIClient(?:__|\()/], + exclude: [/qraftReactAPIClient(?:__|\()/, /APIClientContext/], + }), +}; + +const tokenMatches = (bundle, token) => + token instanceof RegExp ? token.test(bundle) : bundle.includes(token); + +const sourceMapAssertions = { + 'barrel-context-relative': { + source: 'src/barrel-context-relative.ts', + token: 'qraftReactAPIClient(', + }, + 'barrel-precreated-relative': { + source: 'src/barrel-precreated-relative.ts', + token: 'qraftAPIClient(', + }, + 'barrel-precreated-alias': { + source: 'src/barrel-precreated-alias.ts', + token: 'qraftAPIClient(', + }, + 'file-context-query-hash-user-load': { + source: 'src/file-context-query-hash-user-load.ts', + token: 'qraftReactAPIClient(', + }, + 'mixed-context-precreated-mirrors': { + source: 'src/mixed-context-precreated-mirrors.ts', + tokens: ['getPets.schema', 'createPet.schema', 'getStores.schema'], + }, + 'node-api-helper-selection': { + source: 'src/node-api-helper-selection.ts', + token: 'qraftAPIClient(', + }, + 'node-api-virtual-load-only': { + source: 'src/node-api-virtual-load-only.ts', + token: 'qraftAPIClient(', + }, + 'barrel-mixed-helper-selection': { + source: 'src/barrel-mixed-helper-selection.ts', + tokens: ['qraftAPIClient(', 'qraftReactAPIClient('], + }, +}; + +function sourceMatchesExpected(source, expectedSource) { + return source?.replaceAll('\\', '/').endsWith(expectedSource); +} + +function getGeneratedPosition(bundle, traceMap, token, expectedSource) { + const bundleLines = bundle.split('\n'); + const candidateLines = bundleLines + .map((lineText, index) => ({ lineText, line: index + 1 })) + .filter(({ lineText }) => lineText.includes(token)); + + for (const { line, lineText } of candidateLines) { + const column = lineText.indexOf(token); + const originalPosition = originalPositionFor(traceMap, { line, column }); + + if (sourceMatchesExpected(originalPosition.source, expectedSource)) { + return { + line, + column, + originalPosition, + }; + } + } + + for (const [index, lineText] of bundleLines.entries()) { + const line = index + 1; + + for (let column = 0; column < lineText.length; column += 1) { + const originalPosition = originalPositionFor(traceMap, { line, column }); + + if (sourceMatchesExpected(originalPosition.source, expectedSource)) { + return { + line, + column, + originalPosition, + }; + } + } + } + + throw new Error( + `Expected to find a source-mapped generated position for "${token}"` + ); +} + +const selectedBundler = process.env.QRAFT_TREE_SHAKE_BUNDLER; +const selectedScenario = process.env.QRAFT_TREE_SHAKE_SCENARIO; +const assertedBundlers = selectedBundler ? [selectedBundler] : bundlers; +const assertedScenarios = selectedScenario + ? [getScenario(selectedScenario)] + : scenarios; +let assertionCount = 0; + +for (const bundler of assertedBundlers) { + for (const scenario of assertedScenarios) { + if (!supportsScenarioBundler(bundler, scenario)) continue; + + assertionCount += 1; + const bundlePath = getBundlePath(bundler, scenario); + const bundle = await readFile(bundlePath, 'utf8'); + const resolvedModeExpectation = modeExpectations[scenario.mode](scenario); + const includeTokens = [ + ...new Set([...scenario.include, ...resolvedModeExpectation.include]), + ]; + const excludeTokens = [ + ...new Set([...scenario.exclude, ...resolvedModeExpectation.exclude]), + ]; + + assert.ok( + bundle.length > 0, + `Expected non-empty bundle for ${bundler} / ${scenario.name}` + ); + + for (const token of includeTokens) { + assert.ok( + tokenMatches(bundle, token), + `Expected ${bundler} / ${scenario.name} bundle at ${bundlePath} to include "${token}"` + ); + } + + for (const token of excludeTokens) { + assert.ok( + !tokenMatches(bundle, token), + `Expected ${bundler} / ${scenario.name} bundle at ${bundlePath} not to include "${token}"` + ); + } + + const sourceMapAssertion = sourceMapAssertions[scenario.name]; + + if (sourceMapAssertion) { + const mapPath = getBundleMapPath(bundler, scenario); + const map = JSON.parse(await readFile(mapPath, 'utf8')); + const traceMap = new TraceMap(map); + const tokens = sourceMapAssertion.tokens ?? [sourceMapAssertion.token]; + + for (const token of tokens) { + const generatedPosition = getGeneratedPosition( + bundle, + traceMap, + token, + sourceMapAssertion.source + ); + const originalPosition = generatedPosition.originalPosition; + + assert.ok( + sourceMatchesExpected( + originalPosition.source, + sourceMapAssertion.source + ), + `Expected ${bundler} / ${scenario.name} generated call site for "${token}" at ${bundlePath} to map back to ${sourceMapAssertion.source}, got ${originalPosition.source}` + ); + } + } + } +} + +assert.ok( + assertionCount > 0, + `Expected at least one scenario assertion, got bundler=${selectedBundler ?? '*'} scenario=${selectedScenario ?? '*'}` +); + +console.log('Tree-shaking bundle assertions passed.'); diff --git a/e2e/projects/tree-shaking-bundlers/scripts/build-esbuild.mjs b/e2e/projects/tree-shaking-bundlers/scripts/build-esbuild.mjs new file mode 100644 index 000000000..e793451b0 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/scripts/build-esbuild.mjs @@ -0,0 +1,51 @@ +import { resolve } from 'node:path'; +import { TsconfigPathsPlugin } from '@esbuild-plugins/tsconfig-paths'; +import { qraftTreeShakeEsbuild } from '@openapi-qraft/tree-shaking-plugin/esbuild'; +import { build } from 'esbuild'; +import { getTreeShakePluginOptions } from './module-access-fixtures.mjs'; +import { + getBundlerOutputDir, + getScenario, + isExternalModuleRequest, +} from './shared.mjs'; + +const scenario = getScenario(process.env.QRAFT_TREE_SHAKE_SCENARIO ?? ''); + +await build({ + define: { + 'process.env.NODE_ENV': '"production"', + }, + entryPoints: { + [scenario.name]: resolve(process.cwd(), scenario.entry), + }, + outdir: getBundlerOutputDir('esbuild', scenario), + format: 'esm', + bundle: true, + minify: false, + sourcemap: true, + target: 'es2020', + splitting: true, + platform: 'browser', + entryNames: '[name]', + chunkNames: 'chunks/[name]', + assetNames: 'assets/[name][ext]', + plugins: [ + TsconfigPathsPlugin({ tsconfig: resolve(process.cwd(), 'tsconfig.json') }), + qraftTreeShakeEsbuild(getTreeShakePluginOptions(scenario)), + { + name: 'external-dependencies', + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (!isExternalModuleRequest(args.path)) { + return null; + } + + return { + path: args.path, + external: true, + }; + }); + }, + }, + ], +}); diff --git a/e2e/projects/tree-shaking-bundlers/scripts/build-rspack.mjs b/e2e/projects/tree-shaking-bundlers/scripts/build-rspack.mjs new file mode 100644 index 000000000..5d32670ca --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/scripts/build-rspack.mjs @@ -0,0 +1,57 @@ +import { rmSync } from 'node:fs'; +import { rspack } from '@rspack/core'; +import { bundlers, getBundlerOutputDir, getScenario } from './scenarios.mjs'; + +if (!bundlers.includes('rspack')) { + throw new Error('Rspack is not configured for this fixture.'); +} + +const scenarioName = process.argv[2] ?? process.env.QRAFT_TREE_SHAKE_SCENARIO; + +if (!scenarioName) { + throw new Error( + 'Pass a scenario name as argv[2] or set QRAFT_TREE_SHAKE_SCENARIO.' + ); +} + +const scenario = getScenario(scenarioName); + +console.log(`Building tree-shaking bundle: rspack / ${scenario.name}`); + +rmSync(getBundlerOutputDir('rspack', scenario), { + force: true, + recursive: true, +}); + +process.env.QRAFT_TREE_SHAKE_BUNDLER = 'rspack'; +process.env.QRAFT_TREE_SHAKE_SCENARIO = scenario.name; +process.env.QRAFT_TREE_SHAKE_DEBUG = '1'; + +const { default: config } = await import('../rspack.config.mjs'); + +await new Promise((resolve, reject) => { + rspack(config, (error, stats) => { + if (error) { + reject(error); + return; + } + + if (!stats) { + reject(new Error('Rspack returned no stats object.')); + return; + } + + const info = stats.toJson(); + + if (stats.hasErrors()) { + reject(new Error(JSON.stringify(info.errors, null, 2))); + return; + } + + if (stats.hasWarnings()) { + console.warn(stats.toString({ colors: true })); + } + + resolve(stats); + }); +}); diff --git a/e2e/projects/tree-shaking-bundlers/scripts/build.mjs b/e2e/projects/tree-shaking-bundlers/scripts/build.mjs new file mode 100644 index 000000000..bd48f04cb --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/scripts/build.mjs @@ -0,0 +1,62 @@ +import { rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { + bundlers, + getBundlerOutputDir, + scenarios, + supportsScenarioBundler, +} from './scenarios.mjs'; + +const runners = { + vite: (scenario) => ({ + command: 'vite', + args: ['build', '--config', 'vite.config.ts', '--mode', scenario.name], + }), + rollup: () => ({ + command: 'rollup', + args: ['--config', 'rollup.config.mjs'], + }), + webpack: () => ({ + command: 'webpack', + args: ['--config', 'webpack.config.mjs'], + }), + rspack: () => ({ + command: 'rspack', + args: ['--config', 'rspack.config.mjs'], + }), + esbuild: () => ({ + command: process.execPath, + args: ['scripts/build-esbuild.mjs'], + }), +}; + +for (const bundler of bundlers) { + for (const scenario of scenarios) { + if (!supportsScenarioBundler(bundler, scenario)) continue; + + console.log(`Building tree-shaking bundle: ${bundler} / ${scenario.name}`); + + rmSync(getBundlerOutputDir(bundler, scenario), { + force: true, + recursive: true, + }); + + const runner = runners[bundler](scenario); + const result = spawnSync(runner.command, runner.args, { + stdio: 'inherit', + env: { + ...process.env, + QRAFT_TREE_SHAKE_BUNDLER: bundler, + QRAFT_TREE_SHAKE_SCENARIO: scenario.name, + }, + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } +} diff --git a/e2e/projects/tree-shaking-bundlers/scripts/module-access-fixtures.mjs b/e2e/projects/tree-shaking-bundlers/scripts/module-access-fixtures.mjs new file mode 100644 index 000000000..00e742489 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/scripts/module-access-fixtures.mjs @@ -0,0 +1,125 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { entrypoints } from './shared.mjs'; + +const queryHashFactorySpecifier = 'virtual:qraft-query-hash-api'; +const queryHashContextSpecifier = 'virtual:qraft-query-hash-context'; +const queryHashFactorySourceFile = resolve( + process.cwd(), + 'src/generated-api/create-relative-api-client.ts' +); +const queryHashContextSourceFile = resolve( + process.cwd(), + 'src/generated-api/RelativeAPIClientContext.ts' +); +const queryHashFactoryId = `${queryHashFactorySourceFile}?tree-shaking#factory`; +const queryHashContextId = `${queryHashContextSourceFile}?tree-shaking#context`; + +const virtualNodeFactorySpecifier = 'virtual:qraft-node-api'; +const virtualNodeFactorySourceFile = resolve( + process.cwd(), + 'src/generated-api/create-node-api-client.ts' +); +const virtualNodeFactoryId = `${virtualNodeFactorySourceFile}?tree-shaking#factory`; + +const queryHashEntrypoint = { + kind: 'clientFactory', + factory: { + exportName: 'createQueryHashAPIClient', + moduleSpecifier: queryHashFactorySpecifier, + }, + services: { + moduleSpecifierBase: './generated-api', + }, + reactContext: { + exportName: 'QueryHashAPIClientContext', + moduleSpecifier: queryHashContextSpecifier, + }, +}; + +const virtualNodeEntrypoint = { + kind: 'clientFactory', + factory: { + exportName: 'createVirtualNodeAPIClient', + moduleSpecifier: virtualNodeFactorySpecifier, + }, + services: { + moduleSpecifierBase: './generated-api', + }, +}; + +export function getTreeShakePluginOptions(scenario) { + const customEntrypoints = [...entrypoints]; + + if (scenario.name === 'file-context-query-hash-user-load') { + customEntrypoints.push(queryHashEntrypoint); + } + + if (scenario.name === 'node-api-virtual-load-only') { + customEntrypoints.push(virtualNodeEntrypoint); + } + + return { + entrypoints: customEntrypoints, + moduleAccess: { + resolve: (specifier) => resolveVirtualModule(specifier, scenario), + load: (resolvedId) => loadVirtualModule(resolvedId, scenario), + }, + }; +} + +function resolveVirtualModule(specifier, scenario) { + if (scenario.name === 'file-context-query-hash-user-load') { + if (specifier === queryHashFactorySpecifier) return queryHashFactoryId; + if (specifier === queryHashContextSpecifier) { + return queryHashContextId; + } + } + + return null; +} + +async function loadVirtualModule(resolvedId, scenario) { + if ( + scenario.name === 'file-context-query-hash-user-load' && + (resolvedId === queryHashFactoryId || + resolvedId === queryHashFactorySpecifier) + ) { + const source = await readFile(queryHashFactorySourceFile, 'utf8'); + return source + .replaceAll('createRelativeAPIClient', 'createQueryHashAPIClient') + .replaceAll('RelativeAPIClientContext', 'QueryHashAPIClientContext') + .replaceAll( + './QueryHashAPIClientContext.js', + resolvedId === queryHashFactorySpecifier + ? queryHashContextSourceFile + : './QueryHashAPIClientContext.js' + ); + } + + if ( + scenario.name === 'file-context-query-hash-user-load' && + (resolvedId === queryHashContextId || + resolvedId === queryHashContextSpecifier) + ) { + const source = await readFile(queryHashContextSourceFile, 'utf8'); + return source.replaceAll( + 'RelativeAPIClientContext', + 'QueryHashAPIClientContext' + ); + } + + if ( + scenario.name === 'node-api-virtual-load-only' && + (resolvedId === virtualNodeFactoryId || + resolvedId === virtualNodeFactorySpecifier) + ) { + const source = await readFile(virtualNodeFactorySourceFile, 'utf8'); + return source.replaceAll( + 'createNodeAPIClient', + 'createVirtualNodeAPIClient' + ); + } + + return null; +} diff --git a/e2e/projects/tree-shaking-bundlers/scripts/scenarios.mjs b/e2e/projects/tree-shaking-bundlers/scripts/scenarios.mjs new file mode 100644 index 000000000..96ddf463e --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/scripts/scenarios.mjs @@ -0,0 +1,10 @@ +export { + bundlers, + entrypoints, + getBundlerOutputDir, + getBundlePath, + getScenario, + isExternalModuleRequest, + scenarios, + supportsScenarioBundler, +} from './shared.mjs'; diff --git a/e2e/projects/tree-shaking-bundlers/scripts/shared.mjs b/e2e/projects/tree-shaking-bundlers/scripts/shared.mjs new file mode 100644 index 000000000..b6c4669b4 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/scripts/shared.mjs @@ -0,0 +1,551 @@ +import { isAbsolute, resolve } from 'node:path'; + +export const bundlers = ['vite', 'rollup', 'webpack', 'rspack', 'esbuild']; + +const unique = (values) => [...new Set(values.filter(Boolean))]; + +const qraftReactAPIClientPattern = /qraftReactAPIClient(?:__|\()/; +const qraftAPIClientPattern = /qraftAPIClient(?:__|\()/; + +const contextScenario = ({ name, entry, include, exclude }) => ({ + name, + mode: 'context', + entry, + include: unique([qraftReactAPIClientPattern, ...include]), + exclude: unique([ + qraftAPIClientPattern, + 'allCallbacks', + 'petsService', + 'storesService', + ...exclude, + ]), +}); + +const precreatedScenario = ({ + name, + entry, + include, + exclude, + clientToken = 'qraftAPIClient', + optionsToken = 'createAPIClientOptions', +}) => ({ + name, + mode: 'precreated', + entry, + clientToken, + optionsToken, + include: unique([optionsToken, qraftAPIClientPattern, ...include]), + exclude: unique([ + 'allCallbacks', + qraftReactAPIClientPattern, + 'petsService', + 'storesService', + ...exclude, + ]), +}); + +const mixedScenario = ({ name, entry, include, exclude }) => ({ + name, + mode: 'mixed', + entry, + include: unique([ + qraftReactAPIClientPattern, + qraftAPIClientPattern, + ...include, + ]), + exclude: unique(['allCallbacks', 'petsService', 'storesService', ...exclude]), +}); + +const apiOnlyScenario = ({ name, entry, include, exclude, ...scenario }) => ({ + name, + mode: 'apiOnly', + entry, + ...scenario, + include: unique([qraftAPIClientPattern, ...include]), + exclude: unique([ + qraftReactAPIClientPattern, + 'allCallbacks', + 'APIClientContext', + ...exclude, + ]), +}); + +export const scenarios = [ + contextScenario({ + name: 'barrel-context-relative', + entry: 'src/barrel-context-relative.ts', + include: [ + '@openapi-qraft/react/callbacks/useQuery', + 'getPets', + 'BarrelAPIClientContext', + ], + exclude: [ + 'qraftAPIClient(', + 'allCallbacks', + '@openapi-qraft/react/callbacks/useMutation', + 'getStores', + 'createPet', + ], + }), + precreatedScenario({ + name: 'barrel-precreated-relative', + entry: 'src/barrel-precreated-relative.ts', + clientToken: 'BarrelClient', + optionsToken: 'createBarrelClientOptions', + include: [ + 'qraftAPIClient', + '@openapi-qraft/react/callbacks/useQuery', + 'getPets', + 'createBarrelClientOptions', + ], + exclude: [ + 'BarrelAPIClientContext', + 'createAPIClientOptions', + 'allCallbacks', + '@openapi-qraft/react/callbacks/useMutation', + 'getStores', + 'createPet', + 'createBarrelPrecreatedAPIClient', + ], + }), + contextScenario({ + name: 'barrel-context-alias', + entry: 'src/barrel-context-alias.ts', + include: [ + '@openapi-qraft/react/callbacks/useQuery', + 'getStores', + 'AliasAPIClientContext', + ], + exclude: [ + 'qraftAPIClient(', + 'allCallbacks', + '@openapi-qraft/react/callbacks/useMutation', + 'getPets', + 'createPet', + ], + }), + precreatedScenario({ + name: 'barrel-precreated-alias', + entry: 'src/barrel-precreated-alias.ts', + clientToken: 'BarrelClient', + optionsToken: 'createBarrelClientOptions', + include: [ + 'qraftAPIClient', + '@openapi-qraft/react/callbacks/useQuery', + 'getStores', + 'createBarrelClientOptions', + ], + exclude: [ + 'AliasAPIClientContext', + 'createAPIClientOptions', + 'allCallbacks', + '@openapi-qraft/react/callbacks/useMutation', + 'getPets', + 'createPet', + 'createBarrelPrecreatedAPIClient', + ], + }), + contextScenario({ + name: 'file-context-relative', + entry: 'src/file-context-relative.ts', + include: [ + '@openapi-qraft/react/callbacks/useMutation', + 'createPet', + 'RelativeAPIClientContext', + ], + exclude: [ + 'qraftAPIClient(', + 'allCallbacks', + '@openapi-qraft/react/callbacks/useQuery', + 'getPets', + 'getStores', + ], + }), + precreatedScenario({ + name: 'file-precreated-relative', + entry: 'src/file-precreated-relative.ts', + clientToken: 'RelativeClient', + optionsToken: 'buildRelativeClientOptions', + include: [ + 'qraftAPIClient', + '@openapi-qraft/react/callbacks/useMutation', + 'createPet', + 'buildRelativeClientOptions', + ], + exclude: [ + 'RelativeAPIClientContext', + 'createAPIClientOptions', + 'allCallbacks', + '@openapi-qraft/react/callbacks/useQuery', + 'getPets', + 'getStores', + 'createBarrelClientOptions', + 'createRelativePrecreatedAPIClient', + ], + }), + contextScenario({ + name: 'file-context-alias', + entry: 'src/file-context-alias.ts', + include: [ + '@openapi-qraft/react/callbacks/useQuery', + 'getStores', + 'AliasDirectAPIClientContext', + ], + exclude: [ + 'qraftAPIClient(', + 'allCallbacks', + '@openapi-qraft/react/callbacks/useMutation', + 'getPets', + 'createPet', + ], + }), + precreatedScenario({ + name: 'file-precreated-alias', + entry: 'src/file-precreated-alias.ts', + clientToken: 'AliasDirectClient', + optionsToken: 'createAliasDirectClientOptions', + include: [ + 'qraftAPIClient', + '@openapi-qraft/react/callbacks/useQuery', + 'getStores', + 'createAliasDirectClientOptions', + ], + exclude: [ + 'AliasDirectAPIClientContext', + 'createAPIClientOptions', + 'allCallbacks', + '@openapi-qraft/react/callbacks/useMutation', + 'getPets', + 'createPet', + 'createAliasDirectPrecreatedAPIClient', + ], + }), + contextScenario({ + name: 'file-context-relative-ext', + entry: 'src/file-context-relative-ext.ts', + include: [ + '@openapi-qraft/react/callbacks/useMutation', + 'createPet', + 'RelativeExtAPIClientContext', + ], + exclude: [ + 'qraftAPIClient(', + 'allCallbacks', + '@openapi-qraft/react/callbacks/useQuery', + 'getStores', + 'getPets', + ], + }), + precreatedScenario({ + name: 'file-precreated-relative-ext', + entry: 'src/file-precreated-relative-ext.ts', + clientToken: 'RelativeExtClient', + optionsToken: 'createRelativeExtClientOptions', + include: [ + 'qraftAPIClient', + '@openapi-qraft/react/callbacks/useMutation', + 'createPet', + 'createRelativeExtClientOptions', + ], + exclude: [ + 'RelativeExtAPIClientContext', + 'createAPIClientOptions', + 'allCallbacks', + '@openapi-qraft/react/callbacks/useQuery', + 'getStores', + 'getPets', + 'createRelativeExtPrecreatedAPIClient', + ], + }), + mixedScenario({ + name: 'mixed-context-precreated-mirrors', + entry: 'src/mixed-context-precreated-mirrors.ts', + include: [ + 'qraftAPIClient', + 'qraftReactAPIClient', + '@openapi-qraft/react/callbacks/useQuery', + '@openapi-qraft/react/callbacks/useMutation', + 'getPets', + 'getPets.schema', + 'getStores', + 'getStores.schema', + 'createPet', + 'createPet.schema', + 'BarrelAPIClientContext', + 'RelativeAPIClientContext', + 'RelativeExtAPIClientContext', + 'AliasAPIClientContext', + 'AliasDirectAPIClientContext', + 'createBarrelClientOptions', + 'buildRelativeClientOptions', + 'createAliasDirectClientOptions', + 'createRelativeExtClientOptions', + 'barrelPrecreatedFromRelativeApi_pets_getPets', + 'barrelPrecreatedFromAliasApi_stores_getStores', + 'fileRelativePrecreatedApi_pets_createPet', + 'fileAliasPrecreatedApi_stores_getStores', + 'fileRelativeExtPrecreatedApi_pets_createPet', + ], + exclude: [], + }), + apiOnlyScenario({ + name: 'node-api-helper-selection', + entry: 'src/node-api-helper-selection.ts', + include: ['getQueryKey', 'invalidateQueries', 'setQueryData', 'getPets'], + exclude: ['createNodeAPIClient'], + }), + { + name: 'barrel-mixed-helper-selection', + mode: 'mixed', + entry: 'src/barrel-mixed-helper-selection.ts', + include: unique([ + qraftReactAPIClientPattern, + qraftAPIClientPattern, + 'useQuery', + 'getQueryKey', + 'BarrelAPIClientContext', + ]), + exclude: [], + }, + contextScenario({ + name: 'file-context-query-hash-user-load', + entry: 'src/file-context-query-hash-user-load.ts', + include: [ + '@openapi-qraft/react/callbacks/useQuery', + /method:\s*["']get["']/, + 'QueryHashAPIClientContext', + ], + exclude: [ + 'qraftAPIClient(', + 'allCallbacks', + /method:\s*["']post["']|mediaType/, + 'virtual:qraft-query-hash-api', + 'createQueryHashAPIClient', + '@openapi-qraft/react/callbacks/useMutation', + 'getStores', + 'createPet', + ], + }), + apiOnlyScenario({ + name: 'node-api-virtual-load-only', + bundlers: ['esbuild'], + entry: 'src/node-api-virtual-load-only.ts', + include: ['getQueryKey', 'invalidateQueries', 'setQueryData', 'getPets'], + exclude: [ + 'createVirtualNodeAPIClient', + 'virtual:qraft-node-api', + 'createNodeAPIClient', + ], + }), +]; + +const precreatedClientEntrypoints = [ + { + kind: 'precreatedClient', + client: { + exportName: 'BarrelClient', + moduleSpecifier: '@/precreated/clients/barrel', + }, + factory: { + exportName: 'createBarrelPrecreatedAPIClient', + moduleSpecifier: '@/precreated/clients/barrel', // re-export of './generated-api/create-barrel-precreated-api-client.ts' + }, + services: { + moduleSpecifierBase: '@/generated-api', + }, + optionsFactory: { + exportName: 'createBarrelClientOptions', + moduleSpecifier: '@/precreated/clients/barrel', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'RelativeClient', + moduleSpecifier: './precreated/clients/file-relative.ts', + }, + factory: { + exportName: 'createRelativePrecreatedAPIClient', + moduleSpecifier: + './generated-api/create-relative-precreated-api-client.ts', + }, + services: { + moduleSpecifierBase: './generated-api', + }, + optionsFactory: { + exportName: 'buildRelativeClientOptions', + moduleSpecifier: './precreated/options/barrel', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'AliasDirectClient', + moduleSpecifier: '@/precreated/clients/file-alias.ts', + }, + factory: { + exportName: 'createAliasDirectPrecreatedAPIClient', + moduleSpecifier: + './generated-api/create-alias-direct-precreated-api-client.ts', + }, + services: { + moduleSpecifierBase: './generated-api', + }, + optionsFactory: { + exportName: 'createAliasDirectClientOptions', + moduleSpecifier: '@/precreated/options', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'RelativeExtClient', + moduleSpecifier: './precreated/clients/file-relative-ext.ts', + }, + factory: { + exportName: 'createRelativeExtPrecreatedAPIClient', + moduleSpecifier: + './generated-api/create-relative-ts-precreated-api-client.ts', + }, + services: { + moduleSpecifierBase: './generated-api', + }, + optionsFactory: { + exportName: 'createRelativeExtClientOptions', + moduleSpecifier: './precreated/options/direct.ts', + }, + }, +]; + +const clientFactoryEntrypoints = [ + { + kind: 'clientFactory', + factory: { + exportName: 'createBarrelAPIClient', + moduleSpecifier: './generated-api', + }, + reactContext: { + exportName: 'BarrelAPIClientContext', + moduleSpecifier: './generated-api', + }, + }, + { + kind: 'clientFactory', + factory: { + exportName: 'createRelativeAPIClient', + moduleSpecifier: '@/generated-api/create-relative-api-client', + }, + services: { + moduleSpecifierBase: '@/generated-api', + }, + reactContext: { + exportName: 'RelativeAPIClientContext', + moduleSpecifier: './generated-api/RelativeAPIClientContext', + }, + }, + { + kind: 'clientFactory', + factory: { + exportName: 'createRelativeExtAPIClient', + moduleSpecifier: './generated-api/create-relative-ts-api-client.ts', + }, + services: { + moduleSpecifierBase: './generated-api', + }, + reactContext: { + exportName: 'RelativeExtAPIClientContext', + moduleSpecifier: '@/generated-api/RelativeExtAPIClientContext', + }, + }, + { + kind: 'clientFactory', + factory: { + exportName: 'createAliasAPIClient', + moduleSpecifier: '@/generated-api', + }, + reactContext: { + exportName: 'AliasAPIClientContext', + moduleSpecifier: '@/generated-api', + }, + }, + { + kind: 'clientFactory', + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: './generated-api/create-node-api-client', + }, + services: { + moduleSpecifierBase: './generated-api', + }, + }, + { + kind: 'clientFactory', + factory: { + exportName: 'createAliasDirectAPIClient', + moduleSpecifier: './generated-api/create-alias-direct-api-client', + }, + services: { + moduleSpecifierBase: './generated-api', + }, + reactContext: { + exportName: 'AliasDirectAPIClientContext', + moduleSpecifier: './generated-api/AliasDirectAPIClientContext', + }, + }, +]; + +export const entrypoints = [ + ...clientFactoryEntrypoints, + ...precreatedClientEntrypoints, +]; + +export function getScenario(name) { + const scenario = scenarios.find((candidate) => candidate.name === name); + + if (!scenario) { + throw new Error(`Unknown tree-shaking scenario: ${name}`); + } + + return scenario; +} + +export function supportsScenarioBundler(bundler, scenario) { + return !scenario.bundlers || scenario.bundlers.includes(bundler); +} + +export function getBundlerOutputDir(bundler, scenario) { + return resolve(process.cwd(), 'dist', bundler, scenario.name); +} + +export function getBundlePath(bundler, scenario) { + return resolve(getBundlerOutputDir(bundler, scenario), `${scenario.name}.js`); +} + +export function getBundleMapPath(bundler, scenario) { + return resolve( + getBundlerOutputDir(bundler, scenario), + `${scenario.name}.js.map` + ); +} + +export function isExternalModuleRequest(request) { + if (!request) { + return false; + } + + if (request.startsWith('@/')) { + return false; + } + + if ( + request.startsWith('.') || + request.startsWith('/') || + request.startsWith('file:') || + request.startsWith('data:') || + request.startsWith('node:') + ) { + return request.startsWith('node:'); + } + + return !isAbsolute(request); +} diff --git a/e2e/projects/tree-shaking-bundlers/src/barrel-context-alias.ts b/e2e/projects/tree-shaking-bundlers/src/barrel-context-alias.ts new file mode 100644 index 000000000..2eecbc7e6 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/barrel-context-alias.ts @@ -0,0 +1,5 @@ +import { createAliasAPIClient } from '@/generated-api'; + +const api = createAliasAPIClient(); + +export const result = api.stores.getStores.useQuery(); diff --git a/e2e/projects/tree-shaking-bundlers/src/barrel-context-relative.ts b/e2e/projects/tree-shaking-bundlers/src/barrel-context-relative.ts new file mode 100644 index 000000000..21f2eff04 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/barrel-context-relative.ts @@ -0,0 +1,5 @@ +import { createBarrelAPIClient } from './generated-api'; + +const api = createBarrelAPIClient(); + +export const result = api.pets.getPets.useQuery(); diff --git a/e2e/projects/tree-shaking-bundlers/src/barrel-mixed-helper-selection.ts b/e2e/projects/tree-shaking-bundlers/src/barrel-mixed-helper-selection.ts new file mode 100644 index 000000000..446411f1a --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/barrel-mixed-helper-selection.ts @@ -0,0 +1,10 @@ +import { createBarrelAPIClient } from './generated-api'; +import { createNodeAPIClient } from './generated-api/create-node-api-client'; + +const contextApi = createBarrelAPIClient(); +const nodeApiUtility = createNodeAPIClient(); + +export const result = [ + contextApi.pets.getPets.useQuery(), + nodeApiUtility.pets.getPets.getQueryKey(), +]; diff --git a/e2e/projects/tree-shaking-bundlers/src/barrel-precreated-alias.ts b/e2e/projects/tree-shaking-bundlers/src/barrel-precreated-alias.ts new file mode 100644 index 000000000..44a7cfa05 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/barrel-precreated-alias.ts @@ -0,0 +1,3 @@ +import { BarrelClient } from '@/precreated/clients/barrel'; + +export const result = BarrelClient.stores.getStores.useQuery(); diff --git a/e2e/projects/tree-shaking-bundlers/src/barrel-precreated-relative.ts b/e2e/projects/tree-shaking-bundlers/src/barrel-precreated-relative.ts new file mode 100644 index 000000000..3fb416b56 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/barrel-precreated-relative.ts @@ -0,0 +1,3 @@ +import { BarrelClient } from './precreated/clients/barrel'; + +export const result = BarrelClient.pets.getPets.useQuery(); diff --git a/e2e/projects/tree-shaking-bundlers/src/file-context-alias.ts b/e2e/projects/tree-shaking-bundlers/src/file-context-alias.ts new file mode 100644 index 000000000..1ee4df95f --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/file-context-alias.ts @@ -0,0 +1,5 @@ +import { createAliasDirectAPIClient } from '@/generated-api/create-alias-direct-api-client'; + +const api = createAliasDirectAPIClient(); + +export const result = api.stores.getStores.useQuery(); diff --git a/e2e/projects/tree-shaking-bundlers/src/file-context-query-hash-user-load.ts b/e2e/projects/tree-shaking-bundlers/src/file-context-query-hash-user-load.ts new file mode 100644 index 000000000..09654096b --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/file-context-query-hash-user-load.ts @@ -0,0 +1,5 @@ +import { createQueryHashAPIClient } from 'virtual:qraft-query-hash-api'; + +const api = createQueryHashAPIClient(); + +export const result = api.pets.getPets.useQuery(); diff --git a/e2e/projects/tree-shaking-bundlers/src/file-context-relative-ext.ts b/e2e/projects/tree-shaking-bundlers/src/file-context-relative-ext.ts new file mode 100644 index 000000000..50a51660f --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/file-context-relative-ext.ts @@ -0,0 +1,5 @@ +import { createRelativeExtAPIClient } from './generated-api/create-relative-ts-api-client.ts'; + +const api = createRelativeExtAPIClient(); + +export const result = api.pets.createPet.useMutation(); diff --git a/e2e/projects/tree-shaking-bundlers/src/file-context-relative.ts b/e2e/projects/tree-shaking-bundlers/src/file-context-relative.ts new file mode 100644 index 000000000..f2c56b99e --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/file-context-relative.ts @@ -0,0 +1,5 @@ +import { createRelativeAPIClient } from './generated-api/create-relative-api-client'; + +const api = createRelativeAPIClient(); + +export const result = api.pets.createPet.useMutation(); diff --git a/e2e/projects/tree-shaking-bundlers/src/file-precreated-alias.ts b/e2e/projects/tree-shaking-bundlers/src/file-precreated-alias.ts new file mode 100644 index 000000000..d17c9ddcf --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/file-precreated-alias.ts @@ -0,0 +1,3 @@ +import { AliasDirectClient } from '@/precreated/clients/file-alias'; + +export const result = AliasDirectClient.stores.getStores.useQuery(); diff --git a/e2e/projects/tree-shaking-bundlers/src/file-precreated-relative-ext.ts b/e2e/projects/tree-shaking-bundlers/src/file-precreated-relative-ext.ts new file mode 100644 index 000000000..9ec61657c --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/file-precreated-relative-ext.ts @@ -0,0 +1,3 @@ +import { RelativeExtClient } from './precreated/clients/file-relative-ext.ts'; + +export const result = RelativeExtClient.pets.createPet.useMutation(); diff --git a/e2e/projects/tree-shaking-bundlers/src/file-precreated-relative.ts b/e2e/projects/tree-shaking-bundlers/src/file-precreated-relative.ts new file mode 100644 index 000000000..31cc6c35e --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/file-precreated-relative.ts @@ -0,0 +1,3 @@ +import { RelativeClient } from './precreated/clients/file-relative'; + +export const result = RelativeClient.pets.createPet.useMutation(); diff --git a/e2e/projects/tree-shaking-bundlers/src/mixed-context-precreated-mirrors.ts b/e2e/projects/tree-shaking-bundlers/src/mixed-context-precreated-mirrors.ts new file mode 100644 index 000000000..4cfaf3b4f --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/mixed-context-precreated-mirrors.ts @@ -0,0 +1,63 @@ +import { + createAliasAPIClient as createAliasFromAliasAPIClient, + createBarrelAPIClient as createBarrelFromAliasAPIClient, +} from '@/generated-api'; +import { createAliasDirectAPIClient as createAliasDirectFromAliasAPIClient } from '@/generated-api/create-alias-direct-api-client'; +import { createRelativeAPIClient as createRelativeFromAliasAPIClient } from '@/generated-api/create-relative-api-client'; +import { createRelativeExtAPIClient as createRelativeExtFromAliasAPIClient } from '@/generated-api/create-relative-ts-api-client.js'; +import { BarrelClient as barrelPrecreatedFromAliasApi } from '@/precreated/clients/barrel'; +import { AliasDirectClient as fileAliasPrecreatedApi } from '@/precreated/clients/file-alias'; +import { + createAliasAPIClient as createAliasFromRelativeAPIClient, + createBarrelAPIClient as createBarrelFromRelativeAPIClient, +} from './generated-api'; +import { createAliasDirectAPIClient as createAliasDirectFromRelativeAPIClient } from './generated-api/create-alias-direct-api-client'; +import { createRelativeAPIClient as createRelativeFromRelativeAPIClient } from './generated-api/create-relative-api-client'; +import { createRelativeExtAPIClient as createRelativeExtFromRelativeAPIClient } from './generated-api/create-relative-ts-api-client.ts'; +import { BarrelClient as barrelPrecreatedFromRelativeApi } from './precreated/clients/barrel'; +import { RelativeClient as fileRelativePrecreatedApi } from './precreated/clients/file-relative'; +import { RelativeExtClient as fileRelativeExtPrecreatedApi } from './precreated/clients/file-relative-ext.ts'; + +const barrelFromRelativeApi = createBarrelFromRelativeAPIClient(); +const barrelFromAliasApi = createBarrelFromAliasAPIClient(); +const relativeFromRelativeApi = createRelativeFromRelativeAPIClient(); +const relativeFromAliasApi = createRelativeFromAliasAPIClient(); +const relativeExtFromRelativeApi = createRelativeExtFromRelativeAPIClient(); +const relativeExtFromAliasApi = createRelativeExtFromAliasAPIClient(); +const aliasFromRelativeApi = createAliasFromRelativeAPIClient(); +const aliasFromAliasApi = createAliasFromAliasAPIClient(); +const aliasDirectFromRelativeApi = createAliasDirectFromRelativeAPIClient(); +const aliasDirectFromAliasApi = createAliasDirectFromAliasAPIClient(); + +export const result = [ + barrelFromRelativeApi.pets.getPets.useQuery(), + barrelFromRelativeApi.pets.getPets.schema, + barrelFromAliasApi.pets.getPets.useQuery(), + barrelFromAliasApi.pets.getPets.schema, + relativeFromRelativeApi.pets.createPet.useMutation(), + relativeFromRelativeApi.pets.createPet.schema, + relativeFromAliasApi.pets.createPet.useMutation(), + relativeFromAliasApi.pets.createPet.schema, + relativeExtFromRelativeApi.pets.createPet.useMutation(), + relativeExtFromRelativeApi.pets.createPet.schema, + relativeExtFromAliasApi.pets.createPet.useMutation(), + relativeExtFromAliasApi.pets.createPet.schema, + aliasFromRelativeApi.stores.getStores.useQuery(), + aliasFromRelativeApi.stores.getStores.schema, + aliasFromAliasApi.stores.getStores.useQuery(), + aliasFromAliasApi.stores.getStores.schema, + aliasDirectFromRelativeApi.stores.getStores.useQuery(), + aliasDirectFromRelativeApi.stores.getStores.schema, + aliasDirectFromAliasApi.stores.getStores.useQuery(), + aliasDirectFromAliasApi.stores.getStores.schema, + barrelPrecreatedFromRelativeApi.pets.getPets.useQuery(), + barrelPrecreatedFromRelativeApi.pets.getPets.schema, + barrelPrecreatedFromAliasApi.stores.getStores.useQuery(), + barrelPrecreatedFromAliasApi.stores.getStores.schema, + fileRelativePrecreatedApi.pets.createPet.useMutation(), + fileRelativePrecreatedApi.pets.createPet.schema, + fileAliasPrecreatedApi.stores.getStores.useQuery(), + fileAliasPrecreatedApi.stores.getStores.schema, + fileRelativeExtPrecreatedApi.pets.createPet.useMutation(), + fileRelativeExtPrecreatedApi.pets.createPet.schema, +]; diff --git a/e2e/projects/tree-shaking-bundlers/src/node-api-helper-selection.ts b/e2e/projects/tree-shaking-bundlers/src/node-api-helper-selection.ts new file mode 100644 index 000000000..2b9a8a8ed --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/node-api-helper-selection.ts @@ -0,0 +1,19 @@ +import type { CreateAPIClientOptions } from '@openapi-qraft/react'; +import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; +import { createNodeAPIClient } from './generated-api'; + +const nodeOptions = { + queryClient: new QueryClient(), + baseUrl: 'http://localhost:3000', + requestFn, +} satisfies CreateAPIClientOptions; + +const nodeApiUtility = createNodeAPIClient(); +const nodeApi = createNodeAPIClient(nodeOptions); + +export const result = [ + nodeApiUtility.pets.getPets.getQueryKey(), + nodeApi.pets.getPets.invalidateQueries(), + nodeApi.pets.getPets.setQueryData(undefined, () => undefined), +]; diff --git a/e2e/projects/tree-shaking-bundlers/src/node-api-virtual-load-only.ts b/e2e/projects/tree-shaking-bundlers/src/node-api-virtual-load-only.ts new file mode 100644 index 000000000..9ce604d3a --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/node-api-virtual-load-only.ts @@ -0,0 +1,18 @@ +import type { CreateAPIClientOptions } from '@openapi-qraft/react'; +import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; +import { createVirtualNodeAPIClient } from 'virtual:qraft-node-api'; + +const nodeOptions = { + queryClient: new QueryClient(), + baseUrl: 'http://localhost:3000', + requestFn, +} satisfies CreateAPIClientOptions; + +const virtualNodeApi = createVirtualNodeAPIClient(nodeOptions); + +export const result = [ + virtualNodeApi.pets.getPets.getQueryKey(), + virtualNodeApi.pets.getPets.invalidateQueries(), + virtualNodeApi.pets.getPets.setQueryData(undefined, () => undefined), +]; diff --git a/e2e/projects/tree-shaking-bundlers/src/precreated/clients/barrel/client.ts b/e2e/projects/tree-shaking-bundlers/src/precreated/clients/barrel/client.ts new file mode 100644 index 000000000..b3ab68989 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/precreated/clients/barrel/client.ts @@ -0,0 +1,6 @@ +import { createBarrelPrecreatedAPIClient } from '../../../generated-api/create-barrel-precreated-api-client'; +import { createBarrelClientOptions } from '../../../precreated/options/barrel'; + +export const BarrelClient = createBarrelPrecreatedAPIClient( + createBarrelClientOptions() +); diff --git a/e2e/projects/tree-shaking-bundlers/src/precreated/clients/barrel/index.ts b/e2e/projects/tree-shaking-bundlers/src/precreated/clients/barrel/index.ts new file mode 100644 index 000000000..edccb8789 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/precreated/clients/barrel/index.ts @@ -0,0 +1,3 @@ +export { createBarrelPrecreatedAPIClient } from '../../../generated-api/create-barrel-precreated-api-client'; +export { createBarrelClientOptions } from '../../../precreated/options/barrel'; +export { BarrelClient } from './client'; diff --git a/e2e/projects/tree-shaking-bundlers/src/precreated/clients/file-alias.ts b/e2e/projects/tree-shaking-bundlers/src/precreated/clients/file-alias.ts new file mode 100644 index 000000000..cd58924d0 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/precreated/clients/file-alias.ts @@ -0,0 +1,6 @@ +import { createAliasDirectPrecreatedAPIClient } from '@/generated-api/create-alias-direct-precreated-api-client'; +import { createAliasDirectClientOptions } from '@/precreated/options'; + +export const AliasDirectClient = createAliasDirectPrecreatedAPIClient( + createAliasDirectClientOptions() +); diff --git a/e2e/projects/tree-shaking-bundlers/src/precreated/clients/file-relative-ext.ts b/e2e/projects/tree-shaking-bundlers/src/precreated/clients/file-relative-ext.ts new file mode 100644 index 000000000..424809964 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/precreated/clients/file-relative-ext.ts @@ -0,0 +1,6 @@ +import { createRelativeExtPrecreatedAPIClient } from '../../generated-api/create-relative-ts-precreated-api-client.ts'; +import { createRelativeExtClientOptions } from '../../precreated/options/direct'; + +export const RelativeExtClient = createRelativeExtPrecreatedAPIClient( + createRelativeExtClientOptions() +); diff --git a/e2e/projects/tree-shaking-bundlers/src/precreated/clients/file-relative.ts b/e2e/projects/tree-shaking-bundlers/src/precreated/clients/file-relative.ts new file mode 100644 index 000000000..05e1d3158 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/precreated/clients/file-relative.ts @@ -0,0 +1,6 @@ +import { createRelativePrecreatedAPIClient } from '../../generated-api/create-relative-precreated-api-client'; +import { buildRelativeClientOptions } from '../options/barrel/create-relative-client-options'; + +export const RelativeClient = createRelativePrecreatedAPIClient( + buildRelativeClientOptions() +); diff --git a/e2e/projects/tree-shaking-bundlers/src/precreated/options/barrel/create-api-client-options.ts b/e2e/projects/tree-shaking-bundlers/src/precreated/options/barrel/create-api-client-options.ts new file mode 100644 index 000000000..917b29330 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/precreated/options/barrel/create-api-client-options.ts @@ -0,0 +1,9 @@ +import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; + + +export const createBarrelClientOptions = () => ({ + queryClient: new QueryClient(), + baseUrl: 'http://localhost:3000', + requestFn, +}); diff --git a/e2e/projects/tree-shaking-bundlers/src/precreated/options/barrel/create-relative-client-options.ts b/e2e/projects/tree-shaking-bundlers/src/precreated/options/barrel/create-relative-client-options.ts new file mode 100644 index 000000000..efcef2504 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/precreated/options/barrel/create-relative-client-options.ts @@ -0,0 +1,8 @@ +import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; + +export const buildRelativeClientOptions = () => ({ + queryClient: new QueryClient(), + baseUrl: 'http://localhost:3000', + requestFn, +}); diff --git a/e2e/projects/tree-shaking-bundlers/src/precreated/options/barrel/index.ts b/e2e/projects/tree-shaking-bundlers/src/precreated/options/barrel/index.ts new file mode 100644 index 000000000..70f84f24d --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/precreated/options/barrel/index.ts @@ -0,0 +1,4 @@ +export { + createBarrelClientOptions, +} from './create-api-client-options'; +export { buildRelativeClientOptions } from './create-relative-client-options'; diff --git a/e2e/projects/tree-shaking-bundlers/src/precreated/options/direct.ts b/e2e/projects/tree-shaking-bundlers/src/precreated/options/direct.ts new file mode 100644 index 000000000..5b0a2a096 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/precreated/options/direct.ts @@ -0,0 +1,14 @@ +import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; + +export const createAliasDirectClientOptions = () => ({ + queryClient: new QueryClient(), + baseUrl: 'http://localhost:3000', + requestFn, +}); + +export const createRelativeExtClientOptions = () => ({ + queryClient: new QueryClient(), + baseUrl: 'http://localhost:3000', + requestFn, +}); diff --git a/e2e/projects/tree-shaking-bundlers/src/precreated/options/index.ts b/e2e/projects/tree-shaking-bundlers/src/precreated/options/index.ts new file mode 100644 index 000000000..64146b299 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/src/precreated/options/index.ts @@ -0,0 +1,5 @@ +export { + buildRelativeClientOptions, + createBarrelClientOptions, +} from './barrel'; +export { createAliasDirectClientOptions } from './direct'; diff --git a/e2e/projects/tree-shaking-bundlers/tsconfig.json b/e2e/projects/tree-shaking-bundlers/tsconfig.json new file mode 100644 index 000000000..b0804156d --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "noEmit": true, + "outDir": "./dist", + "rootDir": "./src", + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "baseUrl": ".", + "paths": { + "@/*": [ + "src/*" + ] + }, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true + }, + "include": [ + "src", + "scripts" + ], + "exclude": [ + "vite.config.ts" + ] +} diff --git a/e2e/projects/tree-shaking-bundlers/vite.config.ts b/e2e/projects/tree-shaking-bundlers/vite.config.ts new file mode 100644 index 000000000..7a157e6f4 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/vite.config.ts @@ -0,0 +1,41 @@ +import { resolve } from 'node:path'; +import { qraftTreeShakeVite } from '@openapi-qraft/tree-shaking-plugin/vite'; +import { defineConfig } from 'vite'; +import { getTreeShakePluginOptions } from './scripts/module-access-fixtures.mjs'; +import { getScenario } from './scripts/scenarios.mjs'; +import { + getBundlerOutputDir, + isExternalModuleRequest, +} from './scripts/shared.mjs'; + +export default defineConfig(({ mode }) => { + const scenario = getScenario(mode); + + return { + plugins: [ + qraftTreeShakeVite(getTreeShakePluginOptions(scenario)), + ], + resolve: { + tsconfigPaths: true, + }, + build: { + emptyOutDir: true, + minify: false, + sourcemap: true, + target: 'es2020', + outDir: getBundlerOutputDir('vite', scenario), + rollupOptions: { + input: { + [scenario.name]: resolve(process.cwd(), scenario.entry), + }, + external: isExternalModuleRequest, + output: { + format: 'es', + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name].js', + assetFileNames: 'assets/[name][extname]', + }, + }, + }, + }; +}); diff --git a/e2e/projects/tree-shaking-bundlers/webpack.config.mjs b/e2e/projects/tree-shaking-bundlers/webpack.config.mjs new file mode 100644 index 000000000..af318aff4 --- /dev/null +++ b/e2e/projects/tree-shaking-bundlers/webpack.config.mjs @@ -0,0 +1,104 @@ +import { resolve } from 'node:path'; +import { qraftTreeShakeWebpack } from '@openapi-qraft/tree-shaking-plugin/webpack'; +import TerserPlugin from 'terser-webpack-plugin'; +import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; +import { getTreeShakePluginOptions } from './scripts/module-access-fixtures.mjs'; +import { + getBundlerOutputDir, + getScenario, + isExternalModuleRequest, +} from './scripts/shared.mjs'; + +const scenario = getScenario(process.env.QRAFT_TREE_SHAKE_SCENARIO ?? ''); + +export default { + mode: 'production', + target: 'web', + devtool: 'source-map', + entry: { + [scenario.name]: resolve(process.cwd(), scenario.entry), + }, + experiments: { + outputModule: true, + }, + output: { + path: getBundlerOutputDir('webpack', scenario), + filename: '[name].js', + chunkFilename: 'chunks/[name].js', + assetModuleFilename: 'assets/[name][ext]', + module: true, + clean: true, + }, + resolve: { + alias: { + '@': resolve(process.cwd(), 'src'), + }, + plugins: [ + new TsconfigPathsPlugin({ + configFile: resolve(process.cwd(), 'tsconfig.json'), + }), + ], + extensions: ['.ts', '.tsx', '.mts', '.cts', '.mjs', '.js'], + extensionAlias: { + '.js': ['.js', '.ts'], + '.mjs': ['.mjs', '.mts'], + '.cjs': ['.cjs', '.cts'], + }, + }, + externalsType: 'module', + externals: [ + ({ request }, callback) => { + if (request && isExternalModuleRequest(request)) { + callback(null, `module ${request}`); + return; + } + + callback(); + }, + ], + module: { + rules: [ + { + test: /\.[cm]?[jt]sx?$/, + exclude: /node_modules/, + loader: 'esbuild-loader', + options: { + loader: 'ts', + target: 'es2020', + }, + }, + ], + }, + optimization: { + usedExports: true, + sideEffects: true, + innerGraph: true, + minimize: true, + minimizer: [ + new TerserPlugin({ + terserOptions: { + module: true, + compress: { + dead_code: true, + collapse_vars: false, + evaluate: false, + inline: false, + reduce_vars: false, + passes: 1, + }, + format: { + beautify: true, + comments: false, + }, + keep_classnames: true, + keep_fnames: true, + mangle: false, + }, + extractComments: false, + }), + ], + }, + plugins: [ + qraftTreeShakeWebpack(getTreeShakePluginOptions(scenario)), + ], +}; diff --git a/package.json b/package.json index bc3c5c911..bd069c1ec 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "turbo run build", - "build:publishable": "turbo run build --filter '@openapi-qraft/*' --filter '@qraft/*' --force", + "build:publishable": "turbo run build --filter '@openapi-qraft/*' --filter '@qraft/*'", "dev": "turbo run dev", "dev:qraft": "turbo run dev --filter '@openapi-qraft/*' --filter '@qraft/*'", "test": "turbo run test --continue --output-logs=new-only", diff --git a/packages/rollup-config/rollup.config.ts b/packages/rollup-config/rollup.config.ts index 70617d771..fa9734959 100644 --- a/packages/rollup-config/rollup.config.ts +++ b/packages/rollup-config/rollup.config.ts @@ -1,4 +1,11 @@ -import type { OutputOptions, Plugin, RollupLog, RollupOptions } from 'rollup'; +import type { + OutputOptions, + Plugin, + RollupLog, + RollupOptions, + TreeshakingOptions, + TreeshakingPreset, +} from 'rollup'; import fs from 'node:fs'; import { dirname, extname } from 'node:path'; import commonjs from '@rollup/plugin-commonjs'; @@ -14,6 +21,7 @@ type Options = { externalDependencies?: string[]; /** Generate only ESM output (skip CommonJS) */ esmOnly?: boolean; + treeshake?: boolean | TreeshakingPreset | TreeshakingOptions; }; /** @@ -110,7 +118,7 @@ export const rollupConfig = ( warn(warning); } }, - treeshake: { + treeshake: options.treeshake ?? { preset: 'recommended', moduleSideEffects: false, }, diff --git a/packages/test-utils/src/vitestFsMock.ts b/packages/test-utils/src/vitestFsMock.ts index f76cf42f8..3585d72e9 100644 --- a/packages/test-utils/src/vitestFsMock.ts +++ b/packages/test-utils/src/vitestFsMock.ts @@ -1,42 +1,53 @@ import { vi } from 'vitest'; -const createUnionFs = vi.hoisted( - () => async (fsOriginal: typeof import('node:fs')) => { - const { Volume, createFsFromVolume } = await import('memfs'); - const { ufs } = await import('unionfs'); - - const memFs = createFsFromVolume(Volume.fromJSON({})); - const union = ufs.use(memFs as never).use(fsOriginal as never); - - if (union.promises && typeof fsOriginal.promises?.rm === 'function') { - const memFsPromises = ( - memFs as { - promises?: { rm?: (path: string, options?: object) => Promise }; - } - ).promises; - ( - union.promises as { - rm: (path: string, options?: object) => Promise; - } - ).rm = async (path, options) => { - if (typeof memFsPromises?.rm === 'function') { - try { - return await memFsPromises.rm(path, options); - } catch { - return await fsOriginal.promises.rm(path, options); - } - } - return await fsOriginal.promises.rm(path, options); - }; - } +type VirtualFs = typeof import('node:fs') & { + promises: typeof import('node:fs/promises'); +}; - return union; +async function createVirtualFs(fsOriginal: typeof import('node:fs')) { + const { Volume, createFsFromVolume } = await import('memfs'); + const { ufs } = await import('unionfs'); + + const memFs = createFsFromVolume(Volume.fromJSON({})); + const union = ufs.use(memFs as never).use(fsOriginal as never); + + if (union.promises && typeof fsOriginal.promises?.rm === 'function') { + const memFsPromises = ( + memFs as { + promises?: { rm?: (path: string, options?: object) => Promise }; + } + ).promises; + ( + union.promises as { + rm: (path: string, options?: object) => Promise; + } + ).rm = async (path, options) => { + if (typeof memFsPromises?.rm === 'function') { + try { + return await memFsPromises.rm(path, options); + } catch { + return await fsOriginal.promises.rm(path, options); + } + } + return await fsOriginal.promises.rm(path, options); + }; } -); + + return union as unknown as VirtualFs; +} + +const getVirtualFs = vi.hoisted(() => { + let pending: Promise | null = null; + + return async (fsOriginal: typeof import('node:fs')) => { + pending ??= createVirtualFs(fsOriginal); + return pending; + }; +}); vi.mock('node:fs', async (importOriginal) => { return { - default: await createUnionFs( + default: await getVirtualFs( await importOriginal() ), }; @@ -44,6 +55,28 @@ vi.mock('node:fs', async (importOriginal) => { vi.mock('fs', async (importOriginal) => { return { - default: await createUnionFs(await importOriginal()), + default: await getVirtualFs(await importOriginal()), + }; +}); + +vi.mock('node:fs/promises', async () => { + const fsModule = await import('node:fs'); + const promises = (fsModule.default ?? fsModule) + .promises as typeof import('node:fs/promises'); + + return { + default: promises, + ...promises, + }; +}); + +vi.mock('fs/promises', async () => { + const fsModule = await import('fs'); + const promises = (fsModule.default ?? fsModule) + .promises as typeof import('node:fs/promises'); + + return { + default: promises, + ...promises, }; }); diff --git a/packages/tree-shaking-plugin/README.md b/packages/tree-shaking-plugin/README.md new file mode 100644 index 000000000..46ce34c47 --- /dev/null +++ b/packages/tree-shaking-plugin/README.md @@ -0,0 +1,627 @@ +# @openapi-qraft/tree-shaking-plugin + +Tree-shaking plugin for OpenAPI Qraft API clients. Use it with Vite, Rollup, Webpack, Rspack, or esbuild through [unplugin](https://github.com/unjs/unplugin). + +## Install + +```bash +npm install --save-dev @openapi-qraft/tree-shaking-plugin +``` + +## What gets optimized + +There is no special runtime magic here. `qraftReactAPIClient` and `qraftAPIClient` are ordinary runtime functions, and the plugin rewrites generated full-client usage into smaller tree-shake-friendly calls. + +The rewritten code stays type-safe because the plugin preserves the generated types while narrowing the emitted runtime imports. + +The configuration below shows a single `apis.pets` entry in Redocly so it is clear where these client families live. It shows both generated client families this README refers to: a full Node.js client and a full React client. Both are generated with complete coverage so the plugin can tree-shake what is actually used. + +```yaml +apis: + pets: + root: ./openapi.json + x-openapi-qraft: + plugin: + tanstack-query-react: true + openapi-typescript: true + output-dir: src/api + create-api-client-fn: + # Generated client factories are emitted from modules like ./create-node-api-client and ./create-react-api-client + createNodeAPIClient: + filename: create-node-api-client + services: all + callbacks: all + createReactAPIClient: + filename: create-react-api-client + context: APIClientContext + services: all + callbacks: all +``` + +## Supported client modes + +- `kind: 'clientFactory'` for factory imports such as `createReactAPIClient` and the resulting `reactAPIClient`. + + ```ts + import { createReactAPIClient } from './api'; + + const reactAPIClient = createReactAPIClient(); + + export function PetList() { + return reactAPIClient.pets.getPets.useQuery(); + } + ``` + +- `kind: 'precreatedClient'` for clients that are already created and exported from another module, for example `export const nodeAPIClient = createNodeAPIClient(createNodeAPIClientOptions())`. + + ```ts filename=src/client.ts + // src/client.ts + import { createNodeAPIClient } from './api'; + import { createNodeAPIClientOptions } from './client-options'; + + export const nodeAPIClient = createNodeAPIClient( + createNodeAPIClientOptions() + ); + ``` + + ```ts filename=src/App.tsx + // src/App.tsx + import { nodeAPIClient } from './client'; + + export function PetList() { + return nodeAPIClient.pets.getPets.useQuery(); + } + ``` + +## Setup + +The setup snippets below use this `entrypoints` value: + +```ts +const entrypoints = [ + { + kind: 'clientFactory', + factory: { exportName: 'createReactAPIClient', moduleSpecifier: './api' }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: './api', + }, + }, +]; +``` + +### Vite + +```ts +import { qraftTreeShakeVite } from '@openapi-qraft/tree-shaking-plugin/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [qraftTreeShakeVite({ entrypoints })], +}); +``` + +### Rollup + +```ts +import { qraftTreeShakeRollup } from '@openapi-qraft/tree-shaking-plugin/rollup'; + +export default { + plugins: [qraftTreeShakeRollup({ entrypoints })], +}; +``` + +### Webpack + +```ts +const { + qraftTreeShakeWebpack, +} = require('@openapi-qraft/tree-shaking-plugin/webpack'); + +module.exports = { + plugins: [qraftTreeShakeWebpack({ entrypoints })], +}; +``` + +### Rspack + +Rspack uses the same plugin entrypoint, but it also needs the resolver package as an optional peer dependency: + +```bash +npm install --save-dev @rspack/resolver +``` + +If you use TypeScript path aliases or explicit `.js` imports, make sure your Rspack `resolve` config is set up accordingly: + +```ts +resolve: { + tsConfig: path.resolve(process.cwd(), 'tsconfig.json'), + extensionAlias: { + '.js': ['.ts', '.js'], + '.mjs': ['.mts', '.mjs'], + '.cjs': ['.cts', '.cjs'], + }, +}, +``` + +```ts +import { qraftTreeShakeRspack } from '@openapi-qraft/tree-shaking-plugin/rspack'; + +export default { + plugins: [qraftTreeShakeRspack({ entrypoints })], +}; +``` + +Rspack resolution is reconstructed from `compiler.options.resolve` through `@rspack/resolver`. Keep aliases, `tsConfig`, and extension aliases in Rspack config so the plugin can inspect generated modules, and prefer explicit `moduleAccess.resolve` for setups that depend on custom Rspack resolver plugins or defaults not represented in `compiler.options.resolve`. + +### esbuild + +```ts +import { qraftTreeShakeEsbuild } from '@openapi-qraft/tree-shaking-plugin/esbuild'; +import { build } from 'esbuild'; + +await build({ + plugins: [qraftTreeShakeEsbuild({ entrypoints })], +}); +``` + +## Configuration + +### `entrypoints` + +`entrypoints` describes the generated client surfaces that the plugin is allowed to optimize. Every target uses named exports and bundler-resolvable module specifiers, either relative to the bundler's resolution root or alias/third-party imports. + +```ts +qraftTreeShakeVite({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createReactAPIClient', moduleSpecifier: './api' }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: './api', + }, + }, + { + kind: 'precreatedClient', + client: { exportName: 'nodeAPIClient', moduleSpecifier: './client' }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: './create-node-api-client', + }, + services: { + moduleSpecifierBase: './api', + directory: './services', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], +}); +``` + +#### `kind: 'clientFactory'` + +Use this when your application imports a factory such as `createReactAPIClient` and creates clients at the call site. + +**⬇️ Input** + +```ts +import { createReactAPIClient } from './api'; + +const reactAPIClient = createReactAPIClient(); + +export function App() { + return reactAPIClient.pets.getPets.useQuery(); +} +``` + +**⬆️ Output** + +```ts +import { qraftReactAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/useQuery'; +import { APIClientContext } from './api'; +import { getPets } from './api/services/PetsService'; + +const reactAPIClient_pets_getPets = qraftReactAPIClient( + getPets, + { useQuery }, + APIClientContext +); + +export function App() { + return reactAPIClient_pets_getPets.useQuery(); +} +``` + +Configuration: + +```ts +entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createReactAPIClient', moduleSpecifier: './api' }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: './api', + }, + }, +]; +``` + +`factory` points at the generated client factory export. `reactContext` is optional; use it when zero-argument React clients should keep context-backed runtime semantics. Omit `reactContext` for explicit-options clients such as `createNodeAPIClient(options)`. When `reactContext.moduleSpecifier` is omitted, context imports inherit `factory.moduleSpecifier`; the plugin does not infer the context module from generated factory source. + +`services` is optional. When `services.moduleSpecifierBase` is omitted, operation imports inherit `factory.moduleSpecifier` as the public generated API root. When `services.directory` is omitted, the plugin assumes generated service modules live under `./services`. For example, a factory module of `@api/my-api` emits operation imports such as `@api/my-api/services/PetsService`. Set `services.moduleSpecifierBase` when the factory is imported from a file or barrel that is not also the public root for generated service modules, and set `services.directory` only when generated service modules live below a different directory. + +### Module access + +Normal Vite, Rollup, webpack, Rspack, and esbuild integrations do not need any extra configuration. The active bundler adapter resolves and loads generated modules for the tree-shaking transform. + +`moduleAccess.resolve` and `moduleAccess.load` are user override hooks inside the active adapter. User `resolve` runs before native bundler resolution. User `load` runs before native source loading when the adapter has it, then before a non-public best-effort adapter fallback for ordinary files. Return `null` from a user hook to continue to the next strategy. + +Use `moduleAccess.load` when a build relies on virtual modules or a custom source provider that the bundler adapter cannot load directly: + +```ts +qraftTreeShakeVite({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: 'virtual:qraft-api', + }, + }, + ], + moduleAccess: { + load: async (resolvedId) => { + return resolvedId === 'virtual:qraft-api' + ? "export { createAPIClient } from './actual-api';" + : null; + }, + }, +}); +``` + +`moduleAccess.load` receives the exact resolved id, including query/hash suffixes. The adapter-local source fallback is not configurable public API; if it misses or is unavailable, the plugin treats that as a load miss. + +If a resolved module cannot be loaded through module access for a configured transform candidate, `diagnostics` controls the result: `'error'` throws with a resolve/load trace, `'warn'` prints the trace and skips the candidate, and `'off'` skips it silently. + +#### `kind: 'precreatedClient'` + +Use this when the client is already created and exported from a module. + +**⬇️ Input** + +**File Name** `src/client.ts` + +```ts +import { createNodeAPIClient } from './api'; +import { createNodeAPIClientOptions } from './client-options'; + +export const nodeAPIClient = createNodeAPIClient(createNodeAPIClientOptions()); +``` + +**File Name** `src/client-options.ts` + +```ts +import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; + +export const clientOptions = { + requestFn, + queryClient: new QueryClient(), + baseUrl: 'https://api.example.com/v1', +} as const; + +export function createNodeAPIClientOptions() { + return clientOptions; +} +``` + +**File Name** `src/App.tsx` + +```ts +import { nodeAPIClient } from './client'; + +export function App() { + return nodeAPIClient.pets.getPets.useQuery(); +} +``` + +**⬆️ Output** + +**File Name** `src/App.tsx` + +```ts +import { qraftAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/useQuery'; +import { getPets } from './api/services/PetsService'; +import { createNodeAPIClientOptions } from './client-options'; + +const nodeAPIClient_pets_getPets = qraftAPIClient( + getPets, + { useQuery }, + createNodeAPIClientOptions() +); + +export function App() { + return nodeAPIClient_pets_getPets.useQuery(); +} +``` + +Configuration: + +```ts +entrypoints: [ + { + kind: 'precreatedClient', + client: { exportName: 'nodeAPIClient', moduleSpecifier: './client' }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: './create-node-api-client', + }, + services: { + moduleSpecifierBase: './api', + directory: './services', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, +]; +``` + +`client` points at the exported precreated client. `factory` points at the generated factory used to create that client. `optionsFactory` points at the function the plugin should call when it emits smaller `qraftAPIClient(...)` helpers. + +> Top-level generated clients still tree-shake. Bundlers can drop any generated operation that is never used in a chunk. + +`createNodeAPIClientOptions()` should return the same object each time. Keeping `queryClient` in a shared top-level `clientOptions` object makes that explicit and keeps the `QueryClient` instance stable. + +### Other options + +- `resolve` - custom resolver used as a fallback when the bundler cannot resolve a specifier. +- `moduleAccess.load` - custom source provider for virtual generated modules or non-standard source storage. +- `include` / `exclude` - filter which files are transformed. +- `diagnostics` - controls unresolved transform candidates: + - `'error'` (default) throws when configured source looks transformable but + generated metadata or operation ownership cannot be proven. + - `'warn'` prints a warning and skips the candidate. + - `'off'` skips unresolved candidates silently. + +## Transformation Examples + +### Context-based factories + +Use this when the client is created in component code and a nested callback creates a fresh client from the current context. + +The snippets below show only the files that matter in this flow, so the before/after shape stays easy to follow. + +**⬇️ Input** + +**File Name** `src/App.tsx` + +```ts +import { useContext } from 'react'; +import { APIClientContext, createReactAPIClient } from './api'; + +const reactAPIClient = createReactAPIClient(); + +function PetUpdateForm({ petId }: { petId: number }) { + const apiClientOptions = useContext(APIClientContext); + const petParams = { path: { petId } }; + + reactAPIClient.pets.updatePet.useMutation(undefined, { + async onMutate(variables) { + const miniQraft = createReactAPIClient(apiClientOptions); + await miniQraft.pets.getPetById.cancelQueries({ parameters: petParams }); + const prevPet = miniQraft.pets.getPetById.getQueryData(petParams); + miniQraft.pets.getPetById.setQueryData(petParams, (oldData) => ({ + ...oldData, + ...variables.body, + })); + return { prevPet }; + }, + async onSuccess(updatedPet) { + const miniQraft = createReactAPIClient(apiClientOptions); + miniQraft.pets.getPetById.setQueryData(petParams, updatedPet); + await miniQraft.pets.findPetsByStatus.invalidateQueries(); + }, + }); +} +``` + +**⬆️ Output** + +The `reactAPIClient_pets_*` bindings keep the client family and operation name together, which makes the rewrite easy to trace. + +**File Name** `src/App.tsx` + +```ts +import { qraftReactAPIClient } from '@openapi-qraft/react'; +import { cancelQueries } from '@openapi-qraft/react/callbacks/cancelQueries'; +import { getQueryData } from '@openapi-qraft/react/callbacks/getQueryData'; +import { invalidateQueries } from '@openapi-qraft/react/callbacks/invalidateQueries'; +import { setQueryData } from '@openapi-qraft/react/callbacks/setQueryData'; +import { useMutation } from '@openapi-qraft/react/callbacks/useMutation'; +import { useContext } from 'react'; +import { APIClientContext } from './api'; +import { + findPetsByStatus, + getPetById, + updatePet, +} from './api/services/PetsService'; + +const reactAPIClient_pets_updatePet = qraftReactAPIClient( + updatePet, + { useMutation }, + APIClientContext +); + +function PetUpdateForm({ petId }: { petId: number }) { + const apiClientOptions = useContext(APIClientContext); + const petParams = { path: { petId } }; + + reactAPIClient_pets_updatePet.useMutation(undefined, { + async onMutate(variables) { + const reactAPIClient_pets_getPetById = qraftReactAPIClient( + getPetById, + { cancelQueries, getQueryData, setQueryData }, + apiClientOptions + ); + await reactAPIClient_pets_getPetById.cancelQueries({ + parameters: petParams, + }); + const prevPet = reactAPIClient_pets_getPetById.getQueryData(petParams); + reactAPIClient_pets_getPetById.setQueryData(petParams, (oldData) => ({ + ...oldData, + ...variables.body, + })); + return { prevPet }; + }, + async onSuccess(updatedPet) { + const reactAPIClient_pets_getPetById = qraftReactAPIClient( + getPetById, + { setQueryData }, + apiClientOptions + ); + const reactAPIClient_pets_findPetsByStatus = qraftReactAPIClient( + findPetsByStatus, + { invalidateQueries }, + apiClientOptions + ); + reactAPIClient_pets_getPetById.setQueryData(petParams, updatedPet); + await reactAPIClient_pets_findPetsByStatus.invalidateQueries(); + }, + }); +} +``` + +### Precreated clients + +Use this when the client is exported from `client.ts` and the options factory lives in a separate module. + +The snippets below show the minimum files involved in the precreated flow. + +**⬇️ Input** + +**File Name** `src/client.ts` + +```ts +import { createNodeAPIClientOptions } from './client-options'; +import { createNodeAPIClient } from './create-node-api-client'; + +export const nodeAPIClient = createNodeAPIClient(createNodeAPIClientOptions()); +``` + +**File Name** `src/client-options.ts` + +```ts +import { requestFn } from '@openapi-qraft/react'; +import { QueryClient } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); + +export function createNodeAPIClientOptions() { + return { + requestFn, + queryClient, + baseUrl: 'https://api.example.com/v1', + }; +} +``` + +**File Name** `src/App.tsx` + +```ts +import { nodeAPIClient } from './client'; + +const petParams = { path: { petId: 1 } }; + +export function App() { + nodeAPIClient.pets.updatePet.useMutation(undefined, { + async onMutate(variables) { + await nodeAPIClient.pets.getPetById.cancelQueries({ + parameters: petParams, + }); + const prevPet = nodeAPIClient.pets.getPetById.getQueryData(petParams); + nodeAPIClient.pets.getPetById.setQueryData(petParams, (old) => ({ + ...old, + ...variables.body, + })); + return { prevPet }; + }, + async onSuccess(updatedPet) { + nodeAPIClient.pets.getPetById.setQueryData(petParams, updatedPet); + await nodeAPIClient.pets.findPetsByStatus.invalidateQueries(); + }, + }); +} +``` + +**⬆️ Output** + +The `nodeAPIClient_pets_*` bindings keep the client family and operation name together, which makes the rewrite easy to trace. + +**File Name** `src/App.tsx` + +```ts +import { qraftAPIClient } from '@openapi-qraft/react'; +import { cancelQueries } from '@openapi-qraft/react/callbacks/cancelQueries'; +import { getQueryData } from '@openapi-qraft/react/callbacks/getQueryData'; +import { invalidateQueries } from '@openapi-qraft/react/callbacks/invalidateQueries'; +import { setQueryData } from '@openapi-qraft/react/callbacks/setQueryData'; +import { useMutation } from '@openapi-qraft/react/callbacks/useMutation'; +import { + findPetsByStatus, + getPetById, + updatePet, +} from './api/services/PetsService'; +import { createNodeAPIClientOptions } from './client-options'; + +const nodeAPIClient_pets_updatePet = qraftAPIClient( + updatePet, + { useMutation }, + createNodeAPIClientOptions() +); +const nodeAPIClient_pets_getPetById = qraftAPIClient( + getPetById, + { cancelQueries, getQueryData, setQueryData }, + createNodeAPIClientOptions() +); +const nodeAPIClient_pets_findPetsByStatus = qraftAPIClient( + findPetsByStatus, + { invalidateQueries }, + createNodeAPIClientOptions() +); + +const petParams = { path: { petId: 1 } }; + +export function App() { + nodeAPIClient_pets_updatePet.useMutation(undefined, { + async onMutate(variables) { + await nodeAPIClient_pets_getPetById.cancelQueries({ + parameters: petParams, + }); + const prevPet = nodeAPIClient_pets_getPetById.getQueryData(petParams); + nodeAPIClient_pets_getPetById.setQueryData(petParams, (old) => ({ + ...old, + ...variables.body, + })); + return { prevPet }; + }, + async onSuccess(updatedPet) { + nodeAPIClient_pets_getPetById.setQueryData(petParams, updatedPet); + await nodeAPIClient_pets_findPetsByStatus.invalidateQueries(); + }, + }); +} +``` + +> **Why top-level clients?** +> +> The operation-specific clients are hoisted to the module top level so the bundler can see every referenced operation up front. +> **This does not block tree-shaking.** If a given chunk does not use one of these generated clients, normal bundler analysis can still drop it. diff --git a/packages/tree-shaking-plugin/eslint.config.js b/packages/tree-shaking-plugin/eslint.config.js new file mode 100644 index 000000000..5144ffb05 --- /dev/null +++ b/packages/tree-shaking-plugin/eslint.config.js @@ -0,0 +1,3 @@ +import config from '@openapi-qraft/eslint-config/eslint.vanilla.config'; + +export default config; diff --git a/packages/tree-shaking-plugin/package.json b/packages/tree-shaking-plugin/package.json new file mode 100644 index 000000000..110704154 --- /dev/null +++ b/packages/tree-shaking-plugin/package.json @@ -0,0 +1,118 @@ +{ + "name": "@openapi-qraft/tree-shaking-plugin", + "version": "2.15.0-beta.7", + "description": "Build plugin for optimizing OpenAPI Qraft context API clients for tree-shaking.", + "scripts": { + "build": "yarn clean && NODE_ENV=production rollup --config rollup.config.mjs && tsc --project tsconfig.build.json --emitDeclarationOnly", + "dev": "yarn build --watch --noEmitOnError false", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "eslint --max-warnings 0", + "clean": "rimraf dist/" + }, + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "module": "dist/esm/index.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + }, + "./vite": { + "types": "./dist/types/vite.d.ts", + "import": "./dist/esm/vite.js", + "require": "./dist/cjs/vite.cjs" + }, + "./rollup": { + "types": "./dist/types/rollup.d.ts", + "import": "./dist/esm/rollup.js", + "require": "./dist/cjs/rollup.cjs" + }, + "./webpack": { + "types": "./dist/types/webpack.d.ts", + "import": "./dist/esm/webpack.js", + "require": "./dist/cjs/webpack.cjs" + }, + "./rspack": { + "types": "./dist/types/rspack.d.ts", + "import": "./dist/esm/rspack.js", + "require": "./dist/cjs/rspack.cjs" + }, + "./esbuild": { + "types": "./dist/types/esbuild.d.ts", + "import": "./dist/esm/esbuild.js", + "require": "./dist/cjs/esbuild.cjs" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "*": [ + "dist/types/*" + ] + } + }, + "dependencies": { + "@babel/generator": "^7.29.0", + "@babel/parser": "^7.29.0", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "unplugin": "^2.3.10" + }, + "peerDependencies": { + "@rspack/resolver": "^0.4.0" + }, + "peerDependenciesMeta": { + "@rspack/resolver": { + "optional": true + } + }, + "devDependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "@openapi-qraft/eslint-config": "workspace:*", + "@openapi-qraft/rollup-config": "workspace:*", + "@qraft/test-utils": "workspace:*", + "@rspack/resolver": "^0.4.0", + "@types/babel__generator": "^7.27.0", + "@types/babel__traverse": "^7.28.0", + "@types/node": "^22.19.17", + "eslint": "^10.2.0", + "rimraf": "^6.1.3", + "rollup": "~4.60.1", + "typescript": "^5.9.3", + "vitest": "^4.1.4" + }, + "files": [ + "dist", + "src", + "!dist/**/*.test.*", + "!dist/**/*.spec.*", + "!src/**/*.test.*", + "!src/**/*.spec.*" + ], + "packageManager": "yarn@4.0.2", + "repository": { + "type": "git", + "url": "git+https://github.com/OpenAPI-Qraft/openapi-qraft.git", + "directory": "packages/tree-shaking-plugin" + }, + "bugs": { + "url": "https://github.com/OpenAPI-Qraft/openapi-qraft/issues" + }, + "homepage": "https://openapi-qraft.github.io/openapi-qraft/", + "keywords": [ + "openapi", + "qraft", + "tree-shaking", + "vite", + "webpack", + "rspack", + "esbuild", + "unplugin" + ] +} diff --git a/packages/tree-shaking-plugin/rollup.config.mjs b/packages/tree-shaking-plugin/rollup.config.mjs new file mode 100644 index 000000000..1cb86837e --- /dev/null +++ b/packages/tree-shaking-plugin/rollup.config.mjs @@ -0,0 +1,34 @@ +import { rollupConfig } from '@openapi-qraft/rollup-config'; +import packageJson from './package.json' with { type: 'json' }; + +const entries = [ + '.', + './vite', + './rollup', + './webpack', + './rspack', + './esbuild', +]; + +const config = entries.map((entry) => + rollupConfig( + { + import: packageJson.exports[entry].import, + require: packageJson.exports[entry].require, + }, + { + treeshake: false, + input: `src/${entry === '.' ? 'index' : entry.slice(2)}.ts`, + externalDependencies: [ + '@babel/generator', + '@babel/parser', + '@babel/traverse', + '@babel/types', + '@rspack/resolver', + 'unplugin', + ], + } + ) +); + +export default config; diff --git a/packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md b/packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md new file mode 100644 index 000000000..62e2bb365 --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/AGENTS.md @@ -0,0 +1,60 @@ +# Core Transform Test Guide + +This directory contains focused tests for `transformQraftTreeShaking`. Keep tests in the narrowest file that matches the behavior under test, and prefer extending an existing representative case over adding a near-duplicate snapshot. + +## Where To Put Tests + +- `create-api-client-fn.test.ts` + - Use for `entrypoints` with `kind: 'clientFactory'` that are context-based, zero-arg, no-context, custom factory names, generated context inference, factory barrels, and operation-level rewrites for generated factory clients. + - Put context-client callback coverage here when the file only uses `kind: 'clientFactory'` entrypoints. + +- `explicit-options.test.ts` + - Use for `createAPIClient(options)` clients where the argument is a Node.js-like/options object, including inline options, named options, sibling callback scopes, nested scopes, mutation lifecycle callbacks, and `void`/`await` preservation. + - Name options objects as `apiOptions`, `queryClientOptions`, or similarly explicit names. Reserve `apiContext` for real React context values from `useContext(...)`. + +- `precreated-api-client.test.ts` + - Use for `entrypoints` with `kind: 'precreatedClient'` imported from another module, including named/default exports, options module resolution, partial transforms, invalid config skips, namespace/dynamic import skips, and precreated collision safety. + +- `mixed-client-modes.test.ts` + - Use when one source file combines multiple `entrypoints` client modes, such as context `kind: 'clientFactory'`, explicit-options `kind: 'clientFactory'`, and configured `kind: 'precreatedClient'`. + - Keep React-like context usage realistic: `createAPIClient(apiContext!)` should usually be inside `useEffect` or another callback when `apiContext` comes from `useContext(...)`. + - Keep explicit top-level calls only in cases whose title is explicitly about top-level behavior. + +- `schema-and-imports.test.ts` + - Use for `.schema` rewrites, operation import identity, same-name operation aliasing, and import-source separation between generated roots. + +- `resolution-and-module-access.test.ts` + - Use for diagnostics behavior when generated modules cannot be resolved or loaded through module access. + - Also use for resolver behavior, `moduleAccess.resolve`, `moduleAccess.load`, fixture-relative resolution, legacy 4th-argument resolver compatibility, and empty/mismatched config safety. + - Direct imports of the raw production transform are allowed here only when testing legacy resolver/module-access entrypoints. + +- `unsupported-and-safety.test.ts` + - Use for unsupported syntax and safety behavior: raw client references, exported clients, computed properties, destructuring aliases, optional chains, and other cases where an unsafe rewrite must not happen. + +- `source-maps.test.ts` + - Use for source-map composition and traceability checks only. + +- `harness.test.ts` + - Use for tests of local test infrastructure in `harness.ts` and `fixtures.ts`, not transform behavior. + +## Shared Helpers + +- `fixtures.ts` owns generated API source strings, fixture file builders, fixture writes, and module access helpers. +- `harness.ts` owns transform execution setup, fixture-root detection, source-map forwarding, and `createTransformState` re-export. +- Do not copy fixture or resolver helpers into individual test files. Add shared helper capability only when at least two test files need it, or when it prevents a fixture from drifting away from the generated API shape used elsewhere. + +## Snapshot And Skip Policy + +- Inline snapshots are the primary contract for emitted transform shape. Keep them exact and readable. +- If a new or changed test exposes a real production gap that is not a snapshot-only mismatch and the fix is outside the current task scope, use `it.skip(...)` with a short English comment describing the production gap. +- Skips must remain rare and intentional. Before adding one, verify the fixture is valid and the failing behavior is not caused by test setup. + +## Maintenance Rule + +Update this file in the same change whenever any of these happen: + +- A test file in this directory is added, renamed, deleted, or changes ownership of a behavior category. +- A new client mode, callback class, resolver path, safety category, or source-map category gets a dedicated home. +- Shared helper responsibilities move between `fixtures.ts`, `harness.ts`, or a new helper file. +- A new `it.skip(...)` / `describe.skip(...)` is introduced, removed, or its reason changes. +- A test is moved because this guide pointed to the wrong file. diff --git a/packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts b/packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts new file mode 100644 index 000000000..a23b05c03 --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/create-api-client-fn.test.ts @@ -0,0 +1,1398 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + createFixtureModuleAccess, + getContextFixtureFiles, + PETS_SERVICE_TS, + PRECREATED_BASE_FILES, + SERVICES_INDEX_TS, + writeFixtureFiles, +} from './fixtures.js'; +import { + createFixture, + createTransformState, + transformQraftTreeShaking, +} from './harness.js'; + +describe('transformQraftTreeShaking clientFactory entrypoints', () => { + it('collects named and inline usages in one transform state', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + + const state = await createTransformState( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + api.pets.getPets.useQuery(); + createAPIClient({ queryClient: {} }).pets.findPetsByStatus.invalidateQueries(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + }, + fixtureModuleAccess + ); + + expect(state.namedUsages).toHaveLength(1); + expect(state.inlineUsages).toHaveLength(1); + }); + + it('imports an operation directly for a context API client', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { APIClientContext } from "./api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); + + it('uses explicit public service and context module specifiers for a generated factory', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const apiIndex = path.join(fixture, 'src/api/index.ts'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from '@api/internal/my-api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: '@api/internal/my-api', + }, + services: { + moduleSpecifierBase: '@api/my-api', + }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: '@api/my-api', + }, + }, + ], + async resolve(specifier) { + if (specifier === '@api/internal/my-api') return apiIndex; + return null; + }, + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "@api/my-api/services/PetsService"; + import { APIClientContext } from "@api/my-api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); + + it('throws when a zero-arg client uses context callbacks without configured reactContext', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); +api.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ) + ).rejects.toMatchObject({ + name: 'QraftTreeShakeError', + reason: { + code: 'context-client-unresolved', + }, + }); + }); + + it('skips zero-arg context callbacks without configured reactContext when diagnostics is off', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); +api.pets.getPets.useQuery(); +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result).toBeNull(); + }); + + it('rewrites zero-arg context-free callbacks without runtime input', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); +api.pets.getPets.getQueryKey(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPets } from "./api/services/PetsService"; + const api_pets_getPets = qraftAPIClient(getPets, { + getQueryKey + }); + api_pets_getPets.getQueryKey();" + `); + }); + + it('rewrites generated factories that receive services as an argument when services.moduleSpecifierBase is configured', async () => { + const fixture = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(fixture, { + 'src/api/createAPIClient.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { getQueryKey } from '@openapi-qraft/react/callbacks/index'; + +const defaultCallbacks = { getQueryKey } as const; + +export function createAPIClient(services, callbacks = defaultCallbacks) { + return qraftAPIClient(services, callbacks); +} +`, + 'src/api/services/index.ts': SERVICES_INDEX_TS, + 'src/api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/api/services/StoresService.ts': ` +export const storesService = {} as const; +`, + }); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api/createAPIClient'; +import { services } from './api/services/index'; + +const api = createAPIClient(services); + +export function App() { + return api.pets.getPets.getQueryKey(); +} +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api/createAPIClient', + }, + services: { + moduleSpecifierBase: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { services } from './api/services/index'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPets } from "./api/services/PetsService"; + const api_pets_getPets = qraftAPIClient(getPets, { + getQueryKey + }, services); + export function App() { + return api_pets_getPets.getQueryKey(); + }" + `); + }); + + it('rewrites generated factories that receive an operation argument when services.moduleSpecifierBase is configured', async () => { + const fixture = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(fixture, { + 'src/api/createAPIClient.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { getQueryKey } from '@openapi-qraft/react/callbacks/index'; + +const defaultCallbacks = { getQueryKey } as const; + +export function createAPIClient(operation, callbacks = defaultCallbacks) { + return qraftAPIClient(operation, callbacks); +} +`, + 'src/api/services/PetsService.ts': PETS_SERVICE_TS, + }); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api/createAPIClient'; +import { getPets } from './api/services/PetsService'; + +const api = createAPIClient(getPets); + +export function App() { + return api.pets.getPets.getQueryKey(); +} +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api/createAPIClient', + }, + services: { + moduleSpecifierBase: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { getPets } from './api/services/PetsService'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPets as _getPets } from "./api/services/PetsService"; + const api_pets_getPets = qraftAPIClient(_getPets, { + getQueryKey + }, getPets); + export function App() { + return api_pets_getPets.getQueryKey(); + }" + `); + }); + + it('aliases an imported operation when a local binding uses the same name', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); +// These bindings intentionally collide with generated names. +const getPets = async () => {}; +const _getPets = async () => {}; +const api_pets_getPets = () => {}; +const _api_pets_getPets = () => {}; + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets as _getPets2 } from "./api/services/PetsService"; + import { APIClientContext } from "./api"; + const _api_pets_getPets2 = qraftReactAPIClient(_getPets2, { + useQuery + }, APIClientContext); + // These bindings intentionally collide with generated names. + const getPets = async () => {}; + const _getPets = async () => {}; + const api_pets_getPets = () => {}; + const _api_pets_getPets = () => {}; + export function App() { + return _api_pets_getPets2.useQuery(); + }" + `); + }); + + it('does not alias a top-level generated client because of an inner scope binding', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +function helper() { + const api_pets_getPets = () => {}; + return api_pets_getPets; +} + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { APIClientContext } from "./api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + function helper() { + const api_pets_getPets = () => {}; + return api_pets_getPets; + } + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); + + it('supports a custom context name from the generated factory import', async () => { + const fixture = await createFixture({ + contextName: 'MyAPIContext', + contextModule: './MyAPIContext', + }); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'MyAPIContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { MyAPIContext } from "./api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, MyAPIContext); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); + + it('infers an aliased generated context from the qraftReactAPIClient third argument', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + await writeFixtureFiles(fixture, { + ...getContextFixtureFiles('APIClientContext', './APIClientContext', true), + 'src/api/index.ts': ` +import { qraftReactAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; +import { APIClientContext as InternalContext } from './APIClientContext'; +import { services } from './services/index'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(callbacks = defaultCallbacks) { + return qraftReactAPIClient(services, callbacks, InternalContext); +} +`, + }); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'InternalContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { InternalContext } from "./api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, InternalContext); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); + + it('supports an explicit context module for the generated factory', async () => { + const fixture = await createFixture({ + contextName: 'MyAPIContext', + contextModule: '@my-org/api/context', + importContext: false, + }); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'MyAPIContext', + moduleSpecifier: './api/MyAPIContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { MyAPIContext } from "./api/MyAPIContext"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, MyAPIContext); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); + + it('groups callbacks per operation and imports operationInvokeFn directly', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient({}); + +api.pets.getPets.getQueryKey({}); +api.pets.getPets(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPets } from "./api/services/PetsService"; + import { operationInvokeFn } from "@openapi-qraft/react/callbacks/operationInvokeFn"; + const api_pets_getPets = qraftAPIClient(getPets, { + getQueryKey, + operationInvokeFn + }, {}); + api_pets_getPets.getQueryKey({}); + api_pets_getPets();" + `); + }); + + it('uses qraftAPIClient for hook callbacks on explicit runtime options clients without configured context', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const requestOptions = { requestFn: async () => new Response() }; +const api = createAPIClient(requestOptions); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + const requestOptions = { + requestFn: async () => new Response() + }; + const api_pets_getPets = qraftAPIClient(getPets, { + useQuery + }, requestOptions); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); + + it('uses qraftAPIClient for hook callbacks on inline explicit runtime options clients without configured context', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const requestOptions = { requestFn: async () => new Response() }; + +export function App() { + return createAPIClient(requestOptions).pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + const requestOptions = { + requestFn: async () => new Response() + }; + export function App() { + return qraftAPIClient(getPets, { + useQuery + }, requestOptions).useQuery(); + }" + `); + }); + + it('does not shift inline rewrites when an earlier inline operation is unsupported', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const requestOptions = { requestFn: async () => new Response() }; + +export function App() { + createAPIClient(requestOptions).pets.getPets.useUnknown(); + return createAPIClient(requestOptions).pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { createAPIClient } from './api'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + const requestOptions = { + requestFn: async () => new Response() + }; + export function App() { + createAPIClient(requestOptions).pets.getPets.useUnknown(); + return qraftAPIClient(getPets, { + useQuery + }, requestOptions).useQuery(); + }" + `); + }); + + it('uses qraftAPIClient for hook callbacks on explicit runtime options clients with configured context', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const requestOptions = { requestFn: async () => new Response() }; +const api = createAPIClient(requestOptions); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + const requestOptions = { + requestFn: async () => new Response() + }; + const api_pets_getPets = qraftAPIClient(getPets, { + useQuery + }, requestOptions); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); + + it('rewrites context-free callbacks from zero-arg createAPIClient calls', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +function App() { + void createAPIClient().pets.findPetsByStatus.getQueryKey(); + const utilityClient = createAPIClient(); + void utilityClient.pets.findPetsByStatus.getQueryKey(); + api.pets.findPetsByStatus.getQueryKey(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { findPetsByStatus } from "./api/services/PetsService"; + const api_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + getQueryKey + }); + function App() { + void qraftAPIClient(findPetsByStatus, { + getQueryKey + }).getQueryKey(); + const utilityClient_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + getQueryKey + }); + void utilityClient_pets_findPetsByStatus.getQueryKey(); + api_pets_findPetsByStatus.getQueryKey(); + }" + `); + }); + + it('transforms factory imported via a barrel when the module config points to the direct file', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...PRECREATED_BASE_FILES, + 'src/api-barrel.ts': `export { createAPIClient } from './api';`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api-barrel'; + +const api = createAPIClient({ queryClient: {} }); +api.pets.getPets.invalidateQueries(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { getPets } from "./api/services/PetsService"; + const api_pets_getPets = qraftAPIClient(getPets, { + invalidateQueries + }, { + queryClient: {} + }); + api_pets_getPets.invalidateQueries();" + `); + }); + + it('transforms zero-arg and options calls to a no-context factory', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...PRECREATED_BASE_FILES, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const apiUtility = createAPIClient(); +const apiWithClient = createAPIClient({ queryClient: {} }); + +apiUtility.pets.getPets.getQueryKey(); +apiWithClient.pets.getPets.invalidateQueries(); +apiWithClient.pets.getPets.setQueryData(undefined, () => undefined); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPets } from "./api/services/PetsService"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { setQueryData } from "@openapi-qraft/react/callbacks/setQueryData"; + const apiUtility_pets_getPets = qraftAPIClient(getPets, { + getQueryKey + }); + const apiWithClient_pets_getPets = qraftAPIClient(getPets, { + invalidateQueries, + setQueryData + }, { + queryClient: {} + }); + apiUtility_pets_getPets.getQueryKey(); + apiWithClient_pets_getPets.invalidateQueries(); + apiWithClient_pets_getPets.setQueryData(undefined, () => undefined);" + `); + }); + + it('keeps APIClientContext when context-free and contextful callbacks share one client', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + api.pets.findPetsByStatus.getQueryKey(); + api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { findPetsByStatus } from "./api/services/PetsService"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { APIClientContext } from "./api"; + const api_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + getQueryKey + }); + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + export function App() { + api_pets_findPetsByStatus.getQueryKey(); + api_pets_getPets.useQuery(); + }" + `); + }); + + it('creates separate optimized clients for multiple operations across services', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +api.pets.createPet.useMutation(); +api.stores.getStores.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { APIClientContext } from "./api"; + import { useMutation } from "@openapi-qraft/react/callbacks/useMutation"; + import { createPet } from "./api/services/PetsService"; + import { getStores } from "./api/services/StoresService"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + const api_pets_createPet = qraftReactAPIClient(createPet, { + useMutation + }, APIClientContext); + const api_stores_getStores = qraftReactAPIClient(getStores, { + useQuery + }, APIClientContext); + api_pets_getPets.useQuery(); + api_pets_createPet.useMutation(); + api_stores_getStores.useQuery();" + `); + }); + + it('handles the same operation called via named and inline clients in the same scope', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + // Synthetic transform-shape coverage: `api.pets.getPets.invalidateQueries()` + // would not be a valid generated-client call for a context-based client. + // This test only verifies that named and inline clients for the same + // operation do not collide when rewritten in the same scope. + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, APIClientContext } from './api'; +import { useContext } from 'react'; + +const api = createAPIClient(); + +async function run() { + const apiContext = useContext(APIClientContext); + + api.pets.getPets.invalidateQueries(); + createAPIClient(apiContext!).pets.getPets.invalidateQueries(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { APIClientContext } from './api'; + import { useContext } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { getPets } from "./api/services/PetsService"; + const api_pets_getPets = qraftAPIClient(getPets, { + invalidateQueries + }, APIClientContext); + async function run() { + const apiContext = useContext(APIClientContext); + api_pets_getPets.invalidateQueries(); + qraftAPIClient(getPets, { + invalidateQueries + }, apiContext!).invalidateQueries(); + }" + `); + }); + + // Synthetic transform-shape coverage: this does not assert that `{ useQuery }` + // is a valid generated-client runtime options object. It verifies that a + // single expression argument keeps callback import/alias wiring stable. + it('optimizes synthetic one-arg object literals without validating options shape', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; +import { useQuery } from '@openapi-qraft/react/callbacks/useQuery'; + +const api = createAPIClient({ useQuery }); + +api.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { useQuery } from '@openapi-qraft/react/callbacks/useQuery'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery as _useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + const api_pets_getPets = qraftAPIClient(getPets, { + useQuery: _useQuery + }, { + useQuery + }); + api_pets_getPets.useQuery();" + `); + }); + + it('recognizes a custom factory name imported via a bare module specifier', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const apiIndex = path.join(fixture, 'src/api/index.ts'); + + const result = await transformQraftTreeShaking( + ` +import { createMyAPIClient } from '@api/my-api'; + +const api = createMyAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createMyAPIClient', + moduleSpecifier: '@api/my-api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + async resolve(specifier) { + if (specifier === '@api/my-api') return apiIndex; + return null; + }, + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "@api/my-api/services/PetsService"; + import { APIClientContext } from "@api/my-api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); + + it('supports two factory functions that share the same generated services', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, createExtraAPIClient } from './api'; + +const api = createAPIClient(); +const extraApi = createExtraAPIClient(); + +export function App() { + api.pets.getPets.useQuery(); + extraApi.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + { + kind: 'clientFactory', + factory: { + exportName: 'createExtraAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { APIClientContext } from "./api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + const extraApi_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + export function App() { + api_pets_getPets.useQuery(); + extraApi_pets_getPets.useQuery(); + }" + `); + }); + + it('rewrites representative suspense and infinite hook callbacks for context clients', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const reactApi = createAPIClient(); + +export function App() { + reactApi.pets.getPets.useSuspenseQuery(); + reactApi.pets.findPetsByStatus.useInfiniteQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useSuspenseQuery } from "@openapi-qraft/react/callbacks/useSuspenseQuery"; + import { getPets } from "./api/services/PetsService"; + import { APIClientContext } from "./api"; + import { useInfiniteQuery } from "@openapi-qraft/react/callbacks/useInfiniteQuery"; + import { findPetsByStatus } from "./api/services/PetsService"; + const reactApi_pets_getPets = qraftReactAPIClient(getPets, { + useSuspenseQuery + }, APIClientContext); + const reactApi_pets_findPetsByStatus = qraftReactAPIClient(findPetsByStatus, { + useInfiniteQuery + }, APIClientContext); + export function App() { + reactApi_pets_getPets.useSuspenseQuery(); + reactApi_pets_findPetsByStatus.useInfiniteQuery(); + }" + `); + }); +}); diff --git a/packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts b/packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts new file mode 100644 index 000000000..57aa41193 --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/explicit-options.test.ts @@ -0,0 +1,591 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createFixture, transformQraftTreeShaking } from './harness.js'; + +describe('transformQraftTreeShaking explicit options clients', () => { + it('splits explicit options clients across sibling callback scopes', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + // Synthetic transform-shape coverage: the named `api...invalidateQueries()` + // calls would not be valid generated-client calls for a context-based + // client. This test only verifies that `void` and `await` prefixes survive + // named and inline rewrites. + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, APIClientContext } from './api'; +import { useContext } from 'react'; + +const api = createAPIClient(); + +function PetUpdateItem({ petId }: { petId: number }) { + return api.pets.updatePet.useIsMutating(api.pets.updatePet.getMutationKey()); +} + +function PetUpdateForm({ petId }: { petId: number }) { + const apiContext = useContext(APIClientContext); + const petParams = { path: { petId } }; + + api.pets.updatePet.useMutation(undefined, { + mutationKey: api.pets.updatePet.getMutationKey(), + async onMutate(variables) { + const getQueryData = () => api.pets.updatePet.getMutationKey(); + const apiClient_pets_getPetById = () => null; + const apiClient = createAPIClient(apiContext!); + + await apiClient.pets.getPetById.cancelQueries({ parameters: petParams }); + const prevPet = apiClient.pets.getPetById.getQueryData(petParams); + + apiClient.pets.getPetById.setQueryData(petParams, (old) => ({ + ...old, + ...variables.body, + })); + + return { prevPet, getQueryData, apiClient_pets_getPetById }; + }, + }); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { APIClientContext } from './api'; + import { useContext } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useIsMutating } from "@openapi-qraft/react/callbacks/useIsMutating"; + import { updatePet } from "./api/services/PetsService"; + import { getMutationKey } from "@openapi-qraft/react/callbacks/getMutationKey"; + import { useMutation } from "@openapi-qraft/react/callbacks/useMutation"; + import { cancelQueries } from "@openapi-qraft/react/callbacks/cancelQueries"; + import { getPetById } from "./api/services/PetsService"; + import { getQueryData as _getQueryData } from "@openapi-qraft/react/callbacks/getQueryData"; + import { setQueryData } from "@openapi-qraft/react/callbacks/setQueryData"; + const api_pets_updatePet = qraftReactAPIClient(updatePet, { + useIsMutating, + getMutationKey + }, APIClientContext); + const _api_pets_updatePet = qraftReactAPIClient(updatePet, { + useMutation, + getMutationKey + }, APIClientContext); + function PetUpdateItem({ + petId + }: { + petId: number; + }) { + return api_pets_updatePet.useIsMutating(api_pets_updatePet.getMutationKey()); + } + function PetUpdateForm({ + petId + }: { + petId: number; + }) { + const apiContext = useContext(APIClientContext); + const petParams = { + path: { + petId + } + }; + _api_pets_updatePet.useMutation(undefined, { + mutationKey: _api_pets_updatePet.getMutationKey(), + async onMutate(variables) { + const getQueryData = () => _api_pets_updatePet.getMutationKey(); + const apiClient_pets_getPetById = () => null; + const _apiClient_pets_getPetById = qraftAPIClient(getPetById, { + cancelQueries, + getQueryData: _getQueryData, + setQueryData + }, apiContext!); + await _apiClient_pets_getPetById.cancelQueries({ + parameters: petParams + }); + const prevPet = _apiClient_pets_getPetById.getQueryData(petParams); + _apiClient_pets_getPetById.setQueryData(petParams, old => ({ + ...old, + ...variables.body + })); + return { + prevPet, + getQueryData, + apiClient_pets_getPetById + }; + } + }); + }" + `); + }); + + it('optimizes inline explicit options clients', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, APIClientContext } from './api'; +import { useContext } from 'react'; + +function PetUpdateForm() { + const apiContext = useContext(APIClientContext); + + createAPIClient(apiContext!).pets.getPetById.setQueryData( + { path: { petId: 1 } }, + { id: 1 } + ); + + createAPIClient(apiContext!).pets.findPetsByStatus.invalidateQueries(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { APIClientContext } from './api'; + import { useContext } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { setQueryData } from "@openapi-qraft/react/callbacks/setQueryData"; + import { getPetById } from "./api/services/PetsService"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { findPetsByStatus } from "./api/services/PetsService"; + function PetUpdateForm() { + const apiContext = useContext(APIClientContext); + qraftAPIClient(getPetById, { + setQueryData + }, apiContext!).setQueryData({ + path: { + petId: 1 + } + }, { + id: 1 + }); + qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, apiContext!).invalidateQueries(); + }" + `); + }); + + it('optimizes mutation callbacks across onMutate, onError, and onSuccess', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, APIClientContext } from './api'; +import { useContext } from 'react'; + +const api = createAPIClient(); + +function PetUpdateForm({ petId }: { petId: number }) { + const apiContext = useContext(APIClientContext); + const petParams = { path: { petId } }; + + const onUpdate = () => {}; + + api.pets.updatePet.useMutation(undefined, { + async onMutate(variables) { + const miniQraft = createAPIClient(apiContext!); + miniQraft.pets.getPetById.getQueryKey(); + await miniQraft.pets.getPetById.cancelQueries({ + parameters: petParams, + }); + + const prevPet = miniQraft.pets.getPetById.getQueryData(petParams); + + miniQraft.pets.getPetById.setQueryData(petParams, (oldData) => ({ + ...oldData, + ...variables.body, + })); + + return { prevPet }; + }, + async onError(_error, _variables, context) { + if (context?.prevPet) { + createAPIClient(apiContext!).pets.getPetById.setQueryData( + petParams, + context.prevPet + ); + } + }, + async onSuccess(updatedPet) { + const miniQraft = createAPIClient(apiContext!); + miniQraft.pets.getPetById.setQueryData(petParams, updatedPet); + miniQraft.pets.findPetsByStatus.getQueryKey(); + await miniQraft.pets.findPetsByStatus.invalidateQueries(); + onUpdate(); + }, + }); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { APIClientContext } from './api'; + import { useContext } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useMutation } from "@openapi-qraft/react/callbacks/useMutation"; + import { updatePet } from "./api/services/PetsService"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPetById } from "./api/services/PetsService"; + import { cancelQueries } from "@openapi-qraft/react/callbacks/cancelQueries"; + import { getQueryData } from "@openapi-qraft/react/callbacks/getQueryData"; + import { setQueryData } from "@openapi-qraft/react/callbacks/setQueryData"; + import { findPetsByStatus } from "./api/services/PetsService"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + const api_pets_updatePet = qraftReactAPIClient(updatePet, { + useMutation + }, APIClientContext); + function PetUpdateForm({ + petId + }: { + petId: number; + }) { + const apiContext = useContext(APIClientContext); + const petParams = { + path: { + petId + } + }; + const onUpdate = () => {}; + api_pets_updatePet.useMutation(undefined, { + async onMutate(variables) { + const miniQraft_pets_getPetById = qraftAPIClient(getPetById, { + getQueryKey, + cancelQueries, + getQueryData, + setQueryData + }, apiContext!); + miniQraft_pets_getPetById.getQueryKey(); + await miniQraft_pets_getPetById.cancelQueries({ + parameters: petParams + }); + const prevPet = miniQraft_pets_getPetById.getQueryData(petParams); + miniQraft_pets_getPetById.setQueryData(petParams, oldData => ({ + ...oldData, + ...variables.body + })); + return { + prevPet + }; + }, + async onError(_error, _variables, context) { + if (context?.prevPet) { + qraftAPIClient(getPetById, { + setQueryData + }, apiContext!).setQueryData(petParams, context.prevPet); + } + }, + async onSuccess(updatedPet) { + const miniQraft_pets_getPetById = qraftAPIClient(getPetById, { + setQueryData + }, apiContext!); + const miniQraft_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + getQueryKey, + invalidateQueries + }, apiContext!); + miniQraft_pets_getPetById.setQueryData(petParams, updatedPet); + miniQraft_pets_findPetsByStatus.getQueryKey(); + await miniQraft_pets_findPetsByStatus.invalidateQueries(); + onUpdate(); + } + }); + }" + `); + }); + + it('aliases generated names for explicit options clients inside nested function scopes', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, APIClientContext } from './api'; +import { useContext } from 'react'; + +const api = createAPIClient(); + +function PetUpdateForm({ petId }: { petId: number }) { + const apiContext = useContext(APIClientContext); + const petParams = { path: { petId } }; + + api.pets.updatePet.useMutation(undefined, { + async onMutate(variables) { + // These bindings intentionally collide with generated names in this callback scope. + const getQueryData = () => null; + const _getQueryData = () => null; + const apiClient_pets_getPetById = () => null; + const _apiClient_pets_getPetById = () => null; + const apiClient = createAPIClient(apiContext!); + + function syncPetPreview() { + // This binding intentionally collides with the optimized client name from the outer scope. + const _apiClient_pets_getPetById2 = () => null; + const apiClient = createAPIClient(apiContext!); + + apiClient.pets.getPetById.setQueryData(petParams, variables.body); + } + + await apiClient.pets.getPetById.cancelQueries({ parameters: petParams }); + const prevPet = apiClient.pets.getPetById.getQueryData(petParams); + + apiClient.pets.getPetById.setQueryData(petParams, (old) => ({ + ...old, + ...variables.body, + })); + + syncPetPreview(); + + return { prevPet }; + }, + }); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { APIClientContext } from './api'; + import { useContext } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useMutation } from "@openapi-qraft/react/callbacks/useMutation"; + import { updatePet } from "./api/services/PetsService"; + import { setQueryData } from "@openapi-qraft/react/callbacks/setQueryData"; + import { getPetById } from "./api/services/PetsService"; + import { cancelQueries } from "@openapi-qraft/react/callbacks/cancelQueries"; + import { getQueryData as _getQueryData2 } from "@openapi-qraft/react/callbacks/getQueryData"; + const api_pets_updatePet = qraftReactAPIClient(updatePet, { + useMutation + }, APIClientContext); + function PetUpdateForm({ + petId + }: { + petId: number; + }) { + const apiContext = useContext(APIClientContext); + const petParams = { + path: { + petId + } + }; + api_pets_updatePet.useMutation(undefined, { + async onMutate(variables) { + // These bindings intentionally collide with generated names in this callback scope. + const getQueryData = () => null; + const _getQueryData = () => null; + const apiClient_pets_getPetById = () => null; + const _apiClient_pets_getPetById = () => null; + const _apiClient_pets_getPetById4 = qraftAPIClient(getPetById, { + cancelQueries, + getQueryData: _getQueryData2, + setQueryData + }, apiContext!); + function syncPetPreview() { + // This binding intentionally collides with the optimized client name from the outer scope. + const _apiClient_pets_getPetById2 = () => null; + const _apiClient_pets_getPetById3 = qraftAPIClient(getPetById, { + setQueryData + }, apiContext!); + _apiClient_pets_getPetById3.setQueryData(petParams, variables.body); + } + await _apiClient_pets_getPetById4.cancelQueries({ + parameters: petParams + }); + const prevPet = _apiClient_pets_getPetById4.getQueryData(petParams); + _apiClient_pets_getPetById4.setQueryData(petParams, old => ({ + ...old, + ...variables.body + })); + syncPetPreview(); + return { + prevPet + }; + } + }); + }" + `); + }); + + it('preserves void and await prefixes for named and inline client calls', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, APIClientContext } from './api'; +import { useContext } from 'react'; + +async function run() { + const api = createAPIClient(); + const apiOptions = useContext(APIClientContext); + void api.pets.findPetsByStatus.invalidateQueries(); + await api.pets.findPetsByStatus.invalidateQueries(); + void createAPIClient(apiOptions!).pets.findPetsByStatus.invalidateQueries(); + await createAPIClient(apiOptions!).pets.findPetsByStatus.invalidateQueries(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { APIClientContext } from './api'; + import { useContext } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { findPetsByStatus } from "./api/services/PetsService"; + async function run() { + const api_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, APIClientContext); + const apiOptions = useContext(APIClientContext); + void api_pets_findPetsByStatus.invalidateQueries(); + await api_pets_findPetsByStatus.invalidateQueries(); + void qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, apiOptions!).invalidateQueries(); + await qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, apiOptions!).invalidateQueries(); + }" + `); + }); + + it('rewrites fetch, prefetch, and ensure callbacks for explicit options clients', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const queryClientOptions = { queryClient: {} }; +const optionsApi = createAPIClient(queryClientOptions); + +async function loadPets() { + await optionsApi.pets.getPets.fetchQuery(); + await optionsApi.pets.findPetsByStatus.prefetchQuery(); + return optionsApi.pets.getPetById.ensureQueryData({ parameters: { petId: 1 } }); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { fetchQuery } from "@openapi-qraft/react/callbacks/fetchQuery"; + import { getPets } from "./api/services/PetsService"; + import { prefetchQuery } from "@openapi-qraft/react/callbacks/prefetchQuery"; + import { findPetsByStatus } from "./api/services/PetsService"; + import { ensureQueryData } from "@openapi-qraft/react/callbacks/ensureQueryData"; + import { getPetById } from "./api/services/PetsService"; + const queryClientOptions = { + queryClient: {} + }; + const optionsApi_pets_getPets = qraftAPIClient(getPets, { + fetchQuery + }, queryClientOptions); + const optionsApi_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + prefetchQuery + }, queryClientOptions); + const optionsApi_pets_getPetById = qraftAPIClient(getPetById, { + ensureQueryData + }, queryClientOptions); + async function loadPets() { + await optionsApi_pets_getPets.fetchQuery(); + await optionsApi_pets_findPetsByStatus.prefetchQuery(); + return optionsApi_pets_getPetById.ensureQueryData({ + parameters: { + petId: 1 + } + }); + }" + `); + }); +}); diff --git a/packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts b/packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts new file mode 100644 index 000000000..b5febbd49 --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/fixtures.ts @@ -0,0 +1,230 @@ +import type { + QraftModuleAccess, + QraftModuleAccessOptions, +} from '../../lib/resolvers/common.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export const PRECREATED_API_INDEX_TS = ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { + operationInvokeFn, + useQuery, +} from '@openapi-qraft/react/callbacks/index'; +import { services } from './services/index'; + +const defaultCallbacks = { operationInvokeFn, useQuery } as const; + +export function createAPIClient(options?: { + baseUrl: string; + queryClient: unknown; + requestFn: (...args: unknown[]) => Promise; +}) { + return qraftAPIClient(services, defaultCallbacks, options); +} +`; + +export const SERVICES_INDEX_TS = ` +import { petsService } from './PetsService'; +import { storesService } from './StoresService'; + +export const services = { + pets: petsService, + stores: storesService, +} as const; +`; + +export const PETS_SERVICE_TS = ` +export const getPets = { schema: { method: 'get', url: '/pets' } }; +export const createPet = { schema: { method: 'post', url: '/pets' } }; +export const updatePet = { schema: { method: 'put', url: '/pets/{petId}' } }; +export const getPetById = { schema: { method: 'get', url: '/pets/{petId}' } }; +export const findPetsByStatus = { schema: { method: 'get', url: '/pets/findByStatus' } }; + +export const petsService = { + getPets, + createPet, + updatePet, + getPetById, + findPetsByStatus, +} as const; +`; + +export const STORES_SERVICE_TS = ` +export const getStores = { schema: { method: 'get', url: '/stores' } }; + +export const storesService = { + getStores, +} as const; +`; + +export const DEFAULT_PRECREATED_CLIENT_OPTIONS_TS = ` +export const createAPIClientOptions = () => ({ + baseUrl: 'http://localhost', + queryClient: {}, + requestFn: async () => ({ data: undefined, error: undefined }) +}); +`; + +export function getContextFixtureFiles( + contextName: string, + contextModule: string, + importContext: boolean, + apiDirName = 'api' +) { + const apiRoot = `src/${apiDirName}`; + + return { + [`${apiRoot}/index.ts`]: `${importContext ? `import { ${contextName} } from '${contextModule}';\n` : ''}${contextApiIndexTsBody(contextName)}`, + [`${apiRoot}/${contextName}.ts`]: `\nexport const ${contextName} = {};\n`, + [`${apiRoot}/services/index.ts`]: SERVICES_INDEX_TS, + [`${apiRoot}/services/PetsService.ts`]: PETS_SERVICE_TS, + [`${apiRoot}/services/StoresService.ts`]: STORES_SERVICE_TS, + } as const; +} + +export function contextApiIndexTsBody(contextName: string) { + return ` +import { qraftReactAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; +import { services } from './services/index'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(callbacks = defaultCallbacks) { + return qraftReactAPIClient(services, callbacks, ${contextName}); +} +export function createExtraAPIClient(callbacks = defaultCallbacks) { + return qraftReactAPIClient(services, callbacks, ${contextName}); +} +`; +} + +export const PRECREATED_BASE_FILES = { + 'src/api/index.ts': PRECREATED_API_INDEX_TS, + 'src/api/services/index.ts': SERVICES_INDEX_TS, + 'src/api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, +} as const; + +export function createPrecreatedFixtureFiles( + clientTs: string, + extraFiles: Record = {} +) { + return { + ...PRECREATED_BASE_FILES, + 'src/client.ts': clientTs, + ...extraFiles, + } as const; +} + +export async function writeFixtureFiles( + root: string, + files: Record +) { + for (const [relativePath, content] of Object.entries(files)) { + const fullPath = path.join(root, relativePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content); + } +} + +function createFixtureResolver(fixtureRoot: string) { + return async (specifier: string, importer: string) => { + if (specifier.startsWith('@/')) { + return resolveFixtureModule( + path.join(fixtureRoot, 'src'), + specifier.slice(2) + ); + } + + if (specifier.startsWith('.') || specifier.startsWith('/')) { + return resolveFixtureModule(path.dirname(importer), specifier); + } + + return null; + }; +} + +export function createFixtureModuleAccess( + fixtureRoot: string, + userAccess: QraftModuleAccessOptions = {} +): QraftModuleAccess { + const fixtureResolver = createFixtureResolver(fixtureRoot); + + return { + resolve: async (specifier, importer) => { + if (userAccess.resolve) { + try { + const resolved = await userAccess.resolve(specifier, importer); + if (resolved) return resolved; + } catch { + // Fall through to the fixture resolver. + } + } + + return fixtureResolver(specifier, importer); + }, + load: async (id) => { + if (userAccess.load) { + try { + const loaded = await userAccess.load(id); + if (loaded !== null && loaded !== undefined) return loaded; + } catch { + // Fall through to the fixture filesystem loader. + } + } + + try { + return await fs.readFile(id, 'utf8'); + } catch { + return null; + } + }, + }; +} + +export async function resolveFixtureModule( + baseDir: string, + importPath: string +) { + const base = path.resolve(baseDir, importPath); + const candidateBases = new Set([base]); + const extension = path.extname(importPath); + if ( + extension === '.js' || + extension === '.jsx' || + extension === '.mjs' || + extension === '.cjs' + ) { + candidateBases.add(base.slice(0, -extension.length)); + } + + const candidates = [...candidateBases].flatMap((candidateBase) => [ + candidateBase, + `${candidateBase}.ts`, + `${candidateBase}.tsx`, + `${candidateBase}.js`, + `${candidateBase}.jsx`, + `${candidateBase}.mts`, + `${candidateBase}.cts`, + path.join(candidateBase, 'index.ts'), + path.join(candidateBase, 'index.tsx'), + path.join(candidateBase, 'index.js'), + path.join(candidateBase, 'index.jsx'), + path.join(candidateBase, 'index.mts'), + path.join(candidateBase, 'index.cts'), + ]); + + for (const candidate of candidates) { + try { + const stat = await fs.stat(candidate); + if (stat.isFile()) return candidate; + } catch { + // Try the next candidate. + } + } + + return null; +} diff --git a/packages/tree-shaking-plugin/src/__tests__/core/harness.test.ts b/packages/tree-shaking-plugin/src/__tests__/core/harness.test.ts new file mode 100644 index 000000000..875664c48 --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/harness.test.ts @@ -0,0 +1,53 @@ +import '@qraft/test-utils/vitestFsMock'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { createFixtureModuleAccess } from './fixtures.js'; +import { createFixture, transformQraftTreeShaking } from './harness.js'; + +describe('transformQraftTreeShaking harness', () => { + it('preserves an explicit moduleAccess.load override', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + const load = vi.fn(async () => null); + const readFileSpy = vi.spyOn(fs, 'readFile'); + + try { + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: fixtureModuleAccess.resolve, + load, + }, + } + ); + + expect(result).toBeNull(); + expect(load).toHaveBeenCalledWith(path.join(fixture, 'src/api/index.ts')); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + } + }); +}); diff --git a/packages/tree-shaking-plugin/src/__tests__/core/harness.ts b/packages/tree-shaking-plugin/src/__tests__/core/harness.ts new file mode 100644 index 000000000..e1057fe3b --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/harness.ts @@ -0,0 +1,98 @@ +import '@qraft/test-utils/vitestFsMock'; +import type { SourceMapInput } from '@jridgewell/trace-mapping'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { transformQraftTreeShaking as transformQraftTreeShakingImpl } from '../../core.js'; +import { createGeneratedMetadataCache } from '../../lib/transform/generated-metadata.js'; +import { createTransformState } from '../../lib/transform/state.js'; +import { + createFixtureModuleAccess, + getContextFixtureFiles, + writeFixtureFiles, +} from './fixtures.js'; + +export type TransformOptions = Parameters< + typeof transformQraftTreeShakingImpl +>[2]; + +type FixtureOptions = { + contextName?: string; + contextModule?: string; + importContext?: boolean; + apiDirName?: string; +}; + +export async function transformQraftTreeShaking( + code: string, + id: string, + options: TransformOptions, + inputSourceMap?: SourceMapInput +) { + const fixtureRoot = getFixtureRootFromSourceFile(id); + const moduleAccess = createFixtureModuleAccess(fixtureRoot, { + resolve: options.moduleAccess?.resolve ?? options.resolve, + }); + const generatedMetadataCache = createGeneratedMetadataCache(); + const sourceFilters = {}; + + if (options.moduleAccess?.load) { + return transformQraftTreeShakingImpl( + code, + id, + options, + { + ...moduleAccess, + load: options.moduleAccess.load, + }, + inputSourceMap, + generatedMetadataCache, + sourceFilters + ); + } + + return transformQraftTreeShakingImpl( + code, + id, + options, + moduleAccess, + inputSourceMap, + generatedMetadataCache, + sourceFilters + ); +} + +export async function createFixture(options: FixtureOptions = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')); + const contextName = options.contextName ?? 'APIClientContext'; + const contextModule = options.contextModule ?? `./${contextName}`; + const importContext = options.importContext ?? true; + + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + contextName, + contextModule, + importContext, + options.apiDirName + ), + }); + + return root; +} + +function getFixtureRootFromSourceFile(id: string) { + const normalizedPath = path.normalize(id); + const parts = normalizedPath.split(path.sep); + const srcIndex = parts.lastIndexOf('src'); + + if (srcIndex > 0) { + const fixtureRoot = parts.slice(0, srcIndex).join(path.sep); + if (fixtureRoot) { + return fixtureRoot; + } + } + + return path.dirname(path.dirname(id)); +} + +export { createTransformState }; diff --git a/packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts b/packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts new file mode 100644 index 000000000..947de7bc5 --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/mixed-client-modes.test.ts @@ -0,0 +1,816 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + getContextFixtureFiles, + PETS_SERVICE_TS, + PRECREATED_API_INDEX_TS, + SERVICES_INDEX_TS, + STORES_SERVICE_TS, + writeFixtureFiles, +} from './fixtures.js'; +import { transformQraftTreeShaking } from './harness.js'; + +describe('transformQraftTreeShaking mixed client modes', () => { + it('keeps original clients independently for partial mixed-mode transforms', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './context-api'; +import { APIClient } from './precreated-client'; +import { useEffect } from 'react'; + +const api = createAPIClient(); + +export function App() { + api.pets.getPets.useQuery(); + console.log(api); + + useEffect(() => { + APIClient.pets.getPets.invalidateQueries(); + console.log(APIClient); + }, []); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './context-api', + }, + reactContext: { + exportName: 'ContextAPIClientContext', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './precreated-client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './precreated-api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './precreated-client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { createAPIClient } from './context-api'; + import { APIClient } from './precreated-client'; + import { useEffect } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./context-api/services/PetsService"; + import { ContextAPIClientContext } from "./context-api"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { getPets as _getPets } from "./precreated-api/services/PetsService"; + import { createAPIClientOptions } from "./precreated-client-options"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, ContextAPIClientContext); + const APIClient_pets_getPets = qraftAPIClient(_getPets, { + invalidateQueries + }, createAPIClientOptions()); + const api = createAPIClient(); + export function App() { + api_pets_getPets.useQuery(); + console.log(api); + useEffect(() => { + APIClient_pets_getPets.invalidateQueries(); + console.log(APIClient); + }, []); + }" + `); + }); + + it('supports context-based and explicit-options client factory entrypoints in one file', async () => { + const fixture = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + fixture, + getContextFixtureFiles( + 'APIClientContext', + './APIClientContext', + true, + 'api' + ) + ); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, APIClientContext } from './api'; +import { useContext, useEffect } from 'react'; + +const api = createAPIClient(); + +export function App() { + const apiContext = useContext(APIClientContext); + + api.pets.getPets.useQuery(); + useEffect(() => { + void createAPIClient(apiContext!).pets.findPetsByStatus.invalidateQueries(); + }, [apiContext]); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { APIClientContext } from './api'; + import { useContext, useEffect } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { findPetsByStatus } from "./api/services/PetsService"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + export function App() { + const apiContext = useContext(APIClientContext); + api_pets_getPets.useQuery(); + useEffect(() => { + void qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, apiContext!).invalidateQueries(); + }, [apiContext]); + }" + `); + }); + + it('keeps same-operation rewrites separate across all client modes', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, ContextAPIClientContext } from './context-api'; +import { APIClient } from './precreated-client'; +import { useContext, useEffect } from 'react'; + +const contextApi = createAPIClient(); + +export function App() { + const apiContext = useContext(ContextAPIClientContext); + + contextApi.pets.getPets.useQuery(); + useEffect(() => { + void createAPIClient(apiContext!).pets.getPets.invalidateQueries(); + }, [apiContext]); + APIClient.pets.getPets.getQueryKey(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './context-api', + }, + reactContext: { + exportName: 'ContextAPIClientContext', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './precreated-client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './precreated-api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './precreated-client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { ContextAPIClientContext } from './context-api'; + import { useContext, useEffect } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./context-api/services/PetsService"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPets as _getPets } from "./precreated-api/services/PetsService"; + import { createAPIClientOptions } from "./precreated-client-options"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + const contextApi_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, ContextAPIClientContext); + const APIClient_pets_getPets = qraftAPIClient(_getPets, { + getQueryKey + }, createAPIClientOptions()); + export function App() { + const apiContext = useContext(ContextAPIClientContext); + contextApi_pets_getPets.useQuery(); + useEffect(() => { + void qraftAPIClient(getPets, { + invalidateQueries + }, apiContext!).invalidateQueries(); + }, [apiContext]); + APIClient_pets_getPets.getQueryKey(); + }" + `); + }); + + it('supports top-level client factory and precreated client entrypoints in one file', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './context-api'; +import { APIClient } from './precreated-client'; + +const api = createAPIClient(); +const apiOptions = { requestFn: () => undefined }; + +api.pets.getPets.getQueryKey(); +createAPIClient(apiOptions).pets.findPetsByStatus.invalidateQueries(); +APIClient.stores.getStores.getQueryKey(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './context-api', + }, + reactContext: { + exportName: 'ContextAPIClientContext', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './precreated-client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './precreated-api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './precreated-client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPets } from "./context-api/services/PetsService"; + import { getStores } from "./precreated-api/services/StoresService"; + import { createAPIClientOptions } from "./precreated-client-options"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { findPetsByStatus } from "./context-api/services/PetsService"; + const api_pets_getPets = qraftAPIClient(getPets, { + getQueryKey + }); + const APIClient_stores_getStores = qraftAPIClient(getStores, { + getQueryKey + }, createAPIClientOptions()); + const apiOptions = { + requestFn: () => undefined + }; + api_pets_getPets.getQueryKey(); + qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, apiOptions).invalidateQueries(); + APIClient_stores_getStores.getQueryKey();" + `); + }); + + it('supports client factory and precreated client entrypoints in one file', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { + createAPIClient as createContextAPIClient, + ContextAPIClientContext, +} from './context-api'; +import { APIClient } from './precreated-client'; +import { useContext, useEffect } from 'react'; + +const contextApi = createContextAPIClient(); + +export function App() { + const apiContext = useContext(ContextAPIClientContext); + + contextApi.pets.getPets.useQuery(); + useEffect(() => { + void createContextAPIClient(apiContext!).pets.findPetsByStatus.invalidateQueries(); + }, [apiContext]); + APIClient.stores.getStores.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './context-api', + }, + reactContext: { + exportName: 'ContextAPIClientContext', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './precreated-client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './precreated-api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './precreated-client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { ContextAPIClientContext } from './context-api'; + import { useContext, useEffect } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./context-api/services/PetsService"; + import { getStores } from "./precreated-api/services/StoresService"; + import { createAPIClientOptions } from "./precreated-client-options"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { findPetsByStatus } from "./context-api/services/PetsService"; + const contextApi_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, ContextAPIClientContext); + const APIClient_stores_getStores = qraftAPIClient(getStores, { + useQuery + }, createAPIClientOptions()); + export function App() { + const apiContext = useContext(ContextAPIClientContext); + contextApi_pets_getPets.useQuery(); + useEffect(() => { + void qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, apiContext!).invalidateQueries(); + }, [apiContext]); + APIClient_stores_getStores.useQuery(); + }" + `); + }); + + it('keeps generated names collision-safe across mixed client modes', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, ContextAPIClientContext } from './context-api'; +import { APIClient } from './precreated-client'; +import { useContext, useEffect } from 'react'; + +const api = createAPIClient(); + +// These bindings intentionally collide with generated names across modes. +const api_pets_getPets = () => null; +const APIClient_pets_getPets = () => null; + +export function App() { + const apiContext = useContext(ContextAPIClientContext); + + api.pets.getPets.getQueryKey(); + useEffect(() => { + void createAPIClient(apiContext!).pets.getPets.invalidateQueries(); + }, [apiContext]); + APIClient.pets.getPets.getQueryKey(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './context-api', + }, + reactContext: { + exportName: 'ContextAPIClientContext', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './precreated-client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './precreated-api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './precreated-client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { ContextAPIClientContext } from './context-api'; + import { useContext, useEffect } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { getQueryKey } from "@openapi-qraft/react/callbacks/getQueryKey"; + import { getPets } from "./context-api/services/PetsService"; + import { getPets as _getPets } from "./precreated-api/services/PetsService"; + import { createAPIClientOptions } from "./precreated-client-options"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + const _api_pets_getPets = qraftAPIClient(getPets, { + getQueryKey + }); + const _APIClient_pets_getPets = qraftAPIClient(_getPets, { + getQueryKey + }, createAPIClientOptions()); + // These bindings intentionally collide with generated names across modes. + const api_pets_getPets = () => null; + const APIClient_pets_getPets = () => null; + export function App() { + const apiContext = useContext(ContextAPIClientContext); + _api_pets_getPets.getQueryKey(); + useEffect(() => { + void qraftAPIClient(getPets, { + invalidateQueries + }, apiContext!).invalidateQueries(); + }, [apiContext]); + _APIClient_pets_getPets.getQueryKey(); + }" + `); + }); + + it('keeps helper selection separate across context, explicit-options, and precreated modes', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './context-api'; +import { APIClient } from './precreated-client'; + +const contextApi = createAPIClient(); +const explicitOptions = { requestFn: async () => new Response() }; +const explicitApi = createAPIClient(explicitOptions); + +export function App() { + contextApi.pets.getPets.useQuery(); + explicitApi.pets.findPetsByStatus.useQuery(); + APIClient.stores.getStores.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './context-api', + }, + reactContext: { + exportName: 'ContextAPIClientContext', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './precreated-client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './precreated-api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './precreated-client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./context-api/services/PetsService"; + import { ContextAPIClientContext } from "./context-api"; + import { findPetsByStatus } from "./context-api/services/PetsService"; + import { getStores } from "./precreated-api/services/StoresService"; + import { createAPIClientOptions } from "./precreated-client-options"; + const contextApi_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, ContextAPIClientContext); + const APIClient_stores_getStores = qraftAPIClient(getStores, { + useQuery + }, createAPIClientOptions()); + const explicitOptions = { + requestFn: async () => new Response() + }; + const explicitApi_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + useQuery + }, explicitOptions); + export function App() { + contextApi_pets_getPets.useQuery(); + explicitApi_pets_findPetsByStatus.useQuery(); + APIClient_stores_getStores.useQuery(); + }" + `); + }); + + it('keeps callback-class rewrites separate across context and precreated modes', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'ContextAPIClientContext', + './ContextAPIClientContext', + true, + 'context-api' + ), + 'src/precreated-api/index.ts': PRECREATED_API_INDEX_TS, + 'src/precreated-api/services/index.ts': SERVICES_INDEX_TS, + 'src/precreated-api/services/PetsService.ts': PETS_SERVICE_TS, + 'src/precreated-api/services/StoresService.ts': STORES_SERVICE_TS, + 'src/precreated-client-options.ts': DEFAULT_PRECREATED_CLIENT_OPTIONS_TS, + 'src/precreated-client.ts': ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient, ContextAPIClientContext } from './context-api'; +import { APIClient } from './precreated-client'; +import { useContext, useEffect } from 'react'; + +const reactApi = createAPIClient(); + +export function App() { + const apiContext = useContext(ContextAPIClientContext); + reactApi.pets.getPets.useSuspenseQuery(); + useEffect(() => { + void createAPIClient(apiContext!).pets.findPetsByStatus.fetchQuery(); + }, [apiContext]); + APIClient.pets.getPets.getInfiniteQueryKey(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './context-api', + }, + reactContext: { + exportName: 'ContextAPIClientContext', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './precreated-client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './precreated-api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './precreated-client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { ContextAPIClientContext } from './context-api'; + import { useContext, useEffect } from 'react'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useSuspenseQuery } from "@openapi-qraft/react/callbacks/useSuspenseQuery"; + import { getPets } from "./context-api/services/PetsService"; + import { getInfiniteQueryKey } from "@openapi-qraft/react/callbacks/getInfiniteQueryKey"; + import { getPets as _getPets } from "./precreated-api/services/PetsService"; + import { createAPIClientOptions } from "./precreated-client-options"; + import { fetchQuery } from "@openapi-qraft/react/callbacks/fetchQuery"; + import { findPetsByStatus } from "./context-api/services/PetsService"; + const reactApi_pets_getPets = qraftReactAPIClient(getPets, { + useSuspenseQuery + }, ContextAPIClientContext); + const APIClient_pets_getPets = qraftAPIClient(_getPets, { + getInfiniteQueryKey + }, createAPIClientOptions()); + export function App() { + const apiContext = useContext(ContextAPIClientContext); + reactApi_pets_getPets.useSuspenseQuery(); + useEffect(() => { + void qraftAPIClient(findPetsByStatus, { + fetchQuery + }, apiContext!).fetchQuery(); + }, [apiContext]); + APIClient_pets_getPets.getInfiniteQueryKey(); + }" + `); + }); +}); diff --git a/packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts b/packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts new file mode 100644 index 000000000..ceaf9e753 --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/precreated-api-client.test.ts @@ -0,0 +1,874 @@ +import type { TransformOptions } from './harness.js'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createPrecreatedFixtureFiles, writeFixtureFiles } from './fixtures.js'; +import { transformQraftTreeShaking } from './harness.js'; + +describe('transformQraftTreeShaking precreatedClient entrypoints', () => { + it('imports an operation directly for a precreated named API client', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient as API } from './client'; + +export function App() { + return API.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { createAPIClientOptions } from "./client-options"; + const API_pets_getPets = qraftAPIClient(getPets, { + useQuery + }, createAPIClientOptions()); + export function App() { + return API_pets_getPets.useQuery(); + }" + `); + }); + + it('keeps precreated optimized client names collision-safe inside shadowed callbacks', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +const petParams = { path: { petId: 1 } }; + +export function App() { + APIClient.pets.updatePet.useMutation(undefined, { + async onMutate(variables) { + // These locals intentionally shadow the generated optimized client name. + const APIClient_pets_getPetById = () => null; + await APIClient.pets.getPetById.cancelQueries({ parameters: petParams }); + const prevPet = APIClient.pets.getPetById.getQueryData(petParams); + APIClient.pets.getPetById.setQueryData(petParams, (old) => ({ + ...old, + ...variables.body, + })); + return { prevPet }; + }, + async onSuccess(updatedPet) { + // These locals intentionally shadow the generated optimized client name. + const APIClient_pets_getPetById = () => null; + APIClient.pets.getPetById.setQueryData(petParams, updatedPet); + await APIClient.pets.findPetsByStatus.invalidateQueries(); + }, + }); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useMutation } from "@openapi-qraft/react/callbacks/useMutation"; + import { updatePet } from "./api/services/PetsService"; + import { createAPIClientOptions } from "./client-options"; + import { cancelQueries } from "@openapi-qraft/react/callbacks/cancelQueries"; + import { getPetById } from "./api/services/PetsService"; + import { getQueryData } from "@openapi-qraft/react/callbacks/getQueryData"; + import { setQueryData } from "@openapi-qraft/react/callbacks/setQueryData"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { findPetsByStatus } from "./api/services/PetsService"; + const APIClient_pets_updatePet = qraftAPIClient(updatePet, { + useMutation + }, createAPIClientOptions()); + const _APIClient_pets_getPetById = qraftAPIClient(getPetById, { + cancelQueries, + getQueryData, + setQueryData + }, createAPIClientOptions()); + const _APIClient_pets_getPetById2 = qraftAPIClient(getPetById, { + setQueryData + }, createAPIClientOptions()); + const APIClient_pets_findPetsByStatus = qraftAPIClient(findPetsByStatus, { + invalidateQueries + }, createAPIClientOptions()); + const petParams = { + path: { + petId: 1 + } + }; + export function App() { + APIClient_pets_updatePet.useMutation(undefined, { + async onMutate(variables) { + // These locals intentionally shadow the generated optimized client name. + const APIClient_pets_getPetById = () => null; + await _APIClient_pets_getPetById.cancelQueries({ + parameters: petParams + }); + const prevPet = _APIClient_pets_getPetById.getQueryData(petParams); + _APIClient_pets_getPetById.setQueryData(petParams, old => ({ + ...old, + ...variables.body + })); + return { + prevPet + }; + }, + async onSuccess(updatedPet) { + // These locals intentionally shadow the generated optimized client name. + const APIClient_pets_getPetById = () => null; + _APIClient_pets_getPetById2.setQueryData(petParams, updatedPet); + await APIClient_pets_findPetsByStatus.invalidateQueries(); + } + }); + }" + `); + }); + + it('supports a precreated default API client export', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +const APIClient = createAPIClient(createAPIClientOptions()); +export default APIClient; +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import API from './client'; + +API.pets.getPets.invalidateQueries(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'default', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { invalidateQueries } from "@openapi-qraft/react/callbacks/invalidateQueries"; + import { getPets } from "./api/services/PetsService"; + import { createAPIClientOptions } from "./client-options"; + const API_pets_getPets = qraftAPIClient(getPets, { + invalidateQueries + }, createAPIClientOptions()); + API_pets_getPets.invalidateQueries();" + `); + }); + + it('imports precreated client options from a separate module', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +APIClient.pets.getPets(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { operationInvokeFn } from "@openapi-qraft/react/callbacks/operationInvokeFn"; + import { getPets } from "./api/services/PetsService"; + import { createAPIClientOptions } from "./client-options"; + const APIClient_pets_getPets = qraftAPIClient(getPets, { + operationInvokeFn + }, createAPIClientOptions()); + APIClient_pets_getPets();" + `); + }); + + it('imports precreated client options from a fixture-relative module', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles( + ` +import { createAPIClient } from './api'; +import { buildRelativeClientOptions } from './precreated/options/barrel'; + +export const APIClient = createAPIClient(buildRelativeClientOptions()); +`, + { + 'src/precreated/options/barrel/index.ts': ` +export { + createBarrelClientOptions, + buildRelativeClientOptions, +} from './create-api-client-options'; +`, + 'src/precreated/options/barrel/create-api-client-options.ts': ` +export const createBarrelClientOptions = () => ({ + queryClient: {} +}); + +export const buildRelativeClientOptions = createBarrelClientOptions; +`, + } + ) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +APIClient.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'buildRelativeClientOptions', + moduleSpecifier: './precreated/options/barrel', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { buildRelativeClientOptions } from "./precreated/options/barrel"; + const APIClient_pets_getPets = qraftAPIClient(getPets, { + useQuery + }, buildRelativeClientOptions()); + APIClient_pets_getPets.useQuery();" + `); + }); + + it('optimizes a precreated client imported through a barrel entrypoint when services.moduleSpecifierBase is configured', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles('', { + 'src/barrel/index.ts': ` +export { createBarrelPrecreatedAPIClient } from '../generated-api'; +export { BarrelClient } from '../barrel-client'; +export { createOptions } from '../barrel-options'; +`, + 'src/generated-api.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; +import { services } from './api/services/index'; + +const defaultCallbacks = { useQuery } as const; + +export function createBarrelPrecreatedAPIClient(options?: { queryClient: unknown }) { + return qraftAPIClient(services, defaultCallbacks, options); +} +`, + 'src/barrel-client.ts': ` +import { createBarrelPrecreatedAPIClient, createOptions } from './barrel'; + +export const BarrelClient = createBarrelPrecreatedAPIClient(createOptions()); +`, + 'src/barrel-options.ts': ` +export const createOptions = () => ({ + queryClient: {} +}); +`, + }) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { BarrelClient } from './barrel'; + +BarrelClient.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'BarrelClient', + moduleSpecifier: './barrel', + }, + factory: { + exportName: 'createBarrelPrecreatedAPIClient', + moduleSpecifier: './barrel', + }, + optionsFactory: { + exportName: 'createOptions', + moduleSpecifier: './barrel', + }, + services: { + moduleSpecifierBase: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { createOptions } from "./barrel"; + const BarrelClient_pets_getPets = qraftAPIClient(getPets, { + useQuery + }, createOptions()); + BarrelClient_pets_getPets.useQuery();" + `); + }); + + it('imports precreated client options from the same module as the client', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; + +export const createAPIClientOptions = () => ({ + queryClient: {} +}); + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient, createAPIClientOptions } from './client'; + +APIClient.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { createAPIClientOptions } from './client'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + const APIClient_pets_getPets = qraftAPIClient(getPets, { + useQuery + }, createAPIClientOptions()); + APIClient_pets_getPets.useQuery();" + `); + }); + + it('skips a precreated client created by a local same-named factory', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +const createAPIClient = (options?: unknown) => ({ options }); + +export const APIClient = createAPIClient({}); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +APIClient.pets.getPets.useQuery(); +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client', + }, + }, + ], + } + ); + + expect(result).toBeNull(); + }); + + it('rewrites a precreated client whose generated factory has no static services import when services.moduleSpecifierBase is configured', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles( + ` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; +import { getPets } from './api/services/PetsService'; + +export const APIClient = createAPIClient( + { + pets: { + getPets + } + }, + createAPIClientOptions() +); +`, + { + 'src/api/index.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(services, options) { + return qraftAPIClient(services, defaultCallbacks, options); +} +`, + } + ) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +APIClient.pets.getPets.useQuery(); +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + services: { + moduleSpecifierBase: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { createAPIClientOptions } from "./client-options"; + const APIClient_pets_getPets = qraftAPIClient(getPets, { + useQuery + }, createAPIClientOptions()); + APIClient_pets_getPets.useQuery();" + `); + }); + + it('skips a precreated client when the imported factory module does not match the configured one', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles( + ` +import { createAPIClient } from './wrong-factory'; + +export const APIClient = createAPIClient({}); +`, + { + 'src/wrong-factory.ts': ` +export function createAPIClient() { + return {}; +} +`, + } + ) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +APIClient.pets.getPets.useQuery(); +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client', + }, + }, + ], + } + ); + + expect(result).toBeNull(); + }); + + it('skips namespace and dynamic imports of precreated clients', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +export const APIClient = createAPIClient({}); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + const options = { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client', + }, + }, + ], + } satisfies TransformOptions; + + await expect( + transformQraftTreeShaking( + ` +import * as clientModule from './client'; + +clientModule.APIClient.pets.getPets.useQuery(); +`, + sourceFile, + options + ) + ).resolves.toBeNull(); + + await expect( + transformQraftTreeShaking( + ` +const clientModule = await import('./client'); + +clientModule.APIClient.pets.getPets.useQuery(); +`, + sourceFile, + options + ) + ).resolves.toBeNull(); + }); + + it('keeps a partially transformed precreated client import', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +APIClient.pets.getPets.useQuery(); +console.log(APIClient); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { APIClient } from './client'; + import { qraftAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { createAPIClientOptions } from "./client-options"; + const APIClient_pets_getPets = qraftAPIClient(getPets, { + useQuery + }, createAPIClientOptions()); + APIClient_pets_getPets.useQuery(); + console.log(APIClient);" + `); + }); + + it('rewrites query-client state callbacks for precreated clients', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +APIClient.pets.getPets.getQueryState(); +APIClient.pets.getPets.isFetching(); +APIClient.pets.updatePet.isMutating(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftAPIClient } from "@openapi-qraft/react"; + import { getQueryState } from "@openapi-qraft/react/callbacks/getQueryState"; + import { getPets } from "./api/services/PetsService"; + import { createAPIClientOptions } from "./client-options"; + import { isFetching } from "@openapi-qraft/react/callbacks/isFetching"; + import { isMutating } from "@openapi-qraft/react/callbacks/isMutating"; + import { updatePet } from "./api/services/PetsService"; + const APIClient_pets_getPets = qraftAPIClient(getPets, { + getQueryState, + isFetching + }, createAPIClientOptions()); + const APIClient_pets_updatePet = qraftAPIClient(updatePet, { + isMutating + }, createAPIClientOptions()); + APIClient_pets_getPets.getQueryState(); + APIClient_pets_getPets.isFetching(); + APIClient_pets_updatePet.isMutating();" + `); + }); +}); diff --git a/packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts b/packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts new file mode 100644 index 000000000..7402c075a --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/resolution-and-module-access.test.ts @@ -0,0 +1,867 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { transformQraftTreeShaking as transformQraftTreeShakingImpl } from '../../core.js'; +import { + createQraftModuleAccess, + createUserResolverStrategy, + createUserSourceLoaderStrategy, +} from '../../lib/resolvers/common.js'; +import { createGeneratedMetadataCache } from '../../lib/transform/generated-metadata.js'; +import { + createFixtureModuleAccess, + PRECREATED_API_INDEX_TS, +} from './fixtures.js'; +import { + createFixture, + createTransformState, + transformQraftTreeShaking, +} from './harness.js'; + +describe('transformQraftTreeShaking resolution and module access', () => { + it('throws by default when a configured transform candidate cannot load generated source', async () => { + const sourceFile = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), + 'src/App.tsx' + ); + + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; +createAPIClient().pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: async () => '/virtual/api/index.ts', + load: async () => null, + }, + } + ) + ).rejects.toMatchObject({ + name: 'QraftTreeShakeError', + reason: expect.objectContaining({ + code: 'entrypoint-source-unavailable', + moduleAccessTrace: expect.arrayContaining([ + expect.objectContaining({ + kind: 'resolve', + target: './api', + stages: expect.arrayContaining([ + expect.objectContaining({ + name: 'user', + result: 'hit', + value: '/virtual/api/index.ts', + }), + ]), + }), + expect.objectContaining({ + kind: 'load', + target: '/virtual/api/index.ts', + stages: expect.arrayContaining([ + expect.objectContaining({ + name: 'user', + result: 'miss', + }), + ]), + }), + ]), + }), + }); + }); + + it('scopes unresolved entrypoint trace to that entrypoint', async () => { + const sourceFile = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), + 'src/App.tsx' + ); + + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; +createAPIClient().pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createUnusedAPIClient', + moduleSpecifier: './unused-api', + }, + }, + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: async (specifier) => + specifier === './unused-api' + ? '/virtual/unused-api/index.ts' + : '/virtual/api/index.ts', + load: async () => null, + }, + } + ) + ).rejects.toMatchObject({ + name: 'QraftTreeShakeError', + reason: expect.objectContaining({ + code: 'entrypoint-source-unavailable', + entrypointKey: JSON.stringify([ + 'generatedFactory', + 'createAPIClient', + './api', + './api', + './services', + '', + ]), + moduleAccessTrace: expect.not.arrayContaining([ + expect.objectContaining({ + target: './unused-api', + }), + expect.objectContaining({ + target: '/virtual/unused-api/index.ts', + }), + ]), + }), + }); + }); + + it('replays cached null resolve and load trace for later unresolved diagnostics', async () => { + const sourceFile = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), + 'src/App.tsx' + ); + const resolve = vi.fn(async () => null); + const load = vi.fn(async () => null); + const moduleAccess = createQraftModuleAccess( + [createUserResolverStrategy(resolve)], + [createUserSourceLoaderStrategy(load)] + ); + + await expect( + transformQraftTreeShakingImpl( + ` +import { createAPIClient } from './api'; +createAPIClient().pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createUnusedAPIClient', + moduleSpecifier: './api', + }, + }, + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + }, + moduleAccess, + undefined, + createGeneratedMetadataCache(), + {} + ) + ).rejects.toMatchObject({ + name: 'QraftTreeShakeError', + reason: expect.objectContaining({ + code: 'entrypoint-source-unavailable', + entrypointKey: JSON.stringify([ + 'generatedFactory', + 'createAPIClient', + './api', + './api', + './services', + '', + ]), + moduleAccessTrace: expect.arrayContaining([ + expect.objectContaining({ + kind: 'resolve', + target: './api', + stages: expect.arrayContaining([ + expect.objectContaining({ + name: 'user', + result: 'miss', + }), + ]), + }), + ]), + }), + }); + expect(resolve).toHaveBeenCalledTimes(1); + expect(load).not.toHaveBeenCalled(); + }); + + it('replays cached null load trace for later unresolved diagnostics', async () => { + const sourceFile = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), + 'src/App.tsx' + ); + const resolve = vi.fn(async () => '/virtual/api/index.ts'); + const load = vi.fn(async () => null); + const moduleAccess = createQraftModuleAccess( + [createUserResolverStrategy(resolve)], + [createUserSourceLoaderStrategy(load)] + ); + + await expect( + transformQraftTreeShakingImpl( + ` +import { createAPIClient } from './api'; +createAPIClient().pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createUnusedAPIClient', + moduleSpecifier: './api', + }, + }, + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + }, + moduleAccess, + undefined, + createGeneratedMetadataCache(), + {} + ) + ).rejects.toMatchObject({ + name: 'QraftTreeShakeError', + reason: expect.objectContaining({ + code: 'entrypoint-source-unavailable', + entrypointKey: JSON.stringify([ + 'generatedFactory', + 'createAPIClient', + './api', + './api', + './services', + '', + ]), + moduleAccessTrace: expect.arrayContaining([ + expect.objectContaining({ + kind: 'resolve', + target: './api', + stages: expect.arrayContaining([ + expect.objectContaining({ + name: 'user', + result: 'hit', + value: '/virtual/api/index.ts', + }), + ]), + }), + expect.objectContaining({ + kind: 'load', + target: '/virtual/api/index.ts', + stages: expect.arrayContaining([ + expect.objectContaining({ + name: 'user', + result: 'miss', + }), + ]), + }), + ]), + }), + }); + expect(resolve).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('throws by default when a usage-before-declaration local client cannot load generated source', async () => { + const sourceFile = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), + 'src/App.tsx' + ); + + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +export function App() { + return api.pets.getPets.useQuery(); +} + +const api = createAPIClient(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: async () => '/virtual/api/index.ts', + load: async () => null, + }, + } + ) + ).rejects.toMatchObject({ + name: 'QraftTreeShakeError', + reason: expect.objectContaining({ + code: 'entrypoint-source-unavailable', + }), + }); + }); + + it('skips unresolved transform candidates when diagnostics is off', async () => { + const sourceFile = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), + 'src/App.tsx' + ); + + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; +createAPIClient().pets.getPets.useQuery(); +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: async () => '/virtual/api/index.ts', + load: async () => null, + }, + } + ) + ).resolves.toBeNull(); + }); + + it('throws by default when a configured precreated transform candidate cannot resolve generated source', async () => { + const sourceFile = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), + 'src/App.tsx' + ); + + await expect( + transformQraftTreeShaking( + ` +import { APIClient } from './client'; +APIClient.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + moduleAccess: { + resolve: async (specifier) => + specifier === './client' ? '/virtual/client.ts' : null, + load: async () => null, + }, + } + ) + ).rejects.toMatchObject({ + name: 'QraftTreeShakeError', + reason: expect.objectContaining({ + code: 'entrypoint-source-unavailable', + }), + }); + }); + + it('skips unresolved precreated transform candidates when diagnostics is off', async () => { + const sourceFile = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), + 'src/App.tsx' + ); + + await expect( + transformQraftTreeShaking( + ` +import { APIClient } from './client'; +APIClient.pets.getPets.useQuery(); +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + moduleAccess: { + resolve: async (specifier) => + specifier === './client' ? '/virtual/client.ts' : null, + load: async () => null, + }, + } + ) + ).resolves.toBeNull(); + }); + + it('warns and skips unresolved transform candidates when diagnostics is warn', async () => { + const sourceFile = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'qraft-tree-shaking-')), + 'src/App.tsx' + ); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; +createAPIClient().pets.getPets.useQuery(); +`, + sourceFile, + { + diagnostics: 'warn', + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: async () => '/virtual/api/index.ts', + load: async () => null, + }, + } + ) + ).resolves.toBeNull(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('entrypoint-source-unavailable') + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('resolve "./api" from "') + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(' user: hit /virtual/api/index.ts') + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('load "/virtual/api/index.ts":') + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(' user: miss') + ); + } finally { + warn.mockRestore(); + } + }); + + it('uses explicit module access when creating a transform state', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + const load = vi.fn(fixtureModuleAccess.load); + + const state = await createTransformState( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + }, + { + resolve: fixtureModuleAccess.resolve, + load, + } + ); + + expect(state.clients).toHaveLength(1); + expect(state.namedUsages).toHaveLength(1); + expect(load).toHaveBeenCalledWith(path.join(fixture, 'src/api/index.ts')); + }); + + it('optimizes when generated source is available only through exact resolved ids', async () => { + const sourceFile = '/virtual/src/App.tsx'; + const factoryId = '/virtual/src/api/index.ts?generated#factory'; + const load = vi.fn(async (id: string) => { + if (id === factoryId) return PRECREATED_API_INDEX_TS; + return null; + }); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient({ queryClient: {} }); + +export function App() { + return api.pets.getPets(); +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: async (specifier, importer) => { + if (specifier === './api') return factoryId; + if ( + specifier === './services/index' && + importer === '/virtual/src/api/index.ts' + ) { + throw new Error('services index should not be resolved'); + } + return null; + }, + load, + }, + } + ); + + expect(result?.code).toContain( + 'import { getPets } from "./api/services/PetsService";' + ); + expect(result?.code).not.toContain('?generated'); + expect(load.mock.calls.map(([id]) => id)).toEqual([factoryId]); + }); + + it('resolves a factory module through the fixture resolver when the bundler cannot', async () => { + const fixture = await createFixture({ apiDirName: 'generated-api' }); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + await fs.writeFile( + sourceFile, + ` +import { createAPIClient } from './generated-api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +` + ); + + const result = await transformQraftTreeShaking( + await fs.readFile(sourceFile, 'utf8'), + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './generated-api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./generated-api/services/PetsService"; + import { APIClientContext } from "./generated-api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + export function App() { + return api_pets_getPets.useQuery(); + }" + `); + }); + + it('does not read generated modules from the filesystem when moduleAccess.load returns null', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureResolver = createFixtureModuleAccess(fixture).resolve; + const readFileSpy = vi.spyOn(fs, 'readFile'); + const load = vi.fn(async () => null); + + try { + const result = await transformQraftTreeShakingImpl( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + return api.pets.getPets.useQuery(); +} +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + }, + { + resolve: fixtureResolver, + load, + }, + undefined, + createGeneratedMetadataCache(), + {} + ); + + expect(result).toBeNull(); + expect(load).toHaveBeenCalledWith(path.join(fixture, 'src/api/index.ts')); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + } + }); + + it('does not match a same-named import that resolves to a different module', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + // Write an unrelated module that exports a same-named symbol but is NOT + // configured as a factory. + const otherFile = path.join(fixture, 'src/other.ts'); + await fs.writeFile( + otherFile, + `export function createAPIClient() { return { ping: () => 'pong' }; }\n` + ); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './other'; + +const lookalike = createAPIClient(); + +lookalike.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result).toBeNull(); + }); + + it('throws by default when a configured factory import specifier cannot be resolved', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from 'unresolvable-module'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: 'unresolvable-module', + }, + }, + ], + resolve: () => null, + } + ) + ).rejects.toMatchObject({ + name: 'QraftTreeShakeError', + reason: expect.objectContaining({ + code: 'entrypoint-source-unavailable', + }), + }); + }); + + it('skips an unresolved configured factory import specifier when diagnostics is off', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from 'unresolvable-module'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: 'unresolvable-module', + }, + }, + ], + resolve: () => null, + } + ) + ).resolves.toBeNull(); + }); + + it('does not report unrelated unresolved same-named precreated imports', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './other-client'; + +APIClient.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + resolve: () => null, + } + ); + + expect(result).toBeNull(); + }); + + it('skips when entrypoints are empty', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +`, + sourceFile, + { entrypoints: [] } + ); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts b/packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts new file mode 100644 index 000000000..6a6881330 --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/schema-and-imports.test.ts @@ -0,0 +1,235 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + createPrecreatedFixtureFiles, + getContextFixtureFiles, + PETS_SERVICE_TS, + writeFixtureFiles, +} from './fixtures.js'; +import { createFixture, transformQraftTreeShaking } from './harness.js'; + +describe('transformQraftTreeShaking schema and imports', () => { + it('rewrites schema accesses from context-based and zero-arg createAPIClient calls', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +export function App() { + api.pets.findPetsByStatus.schema; + createAPIClient().pets.findPetsByStatus.schema; +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { findPetsByStatus } from "./api/services/PetsService"; + export function App() { + findPetsByStatus.schema; + findPetsByStatus.schema; + }" + `); + }); + + it('rewrites schema accesses from precreated API clients directly to operations', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { APIClient } from './client'; + +export function App() { + return APIClient.pets.findPetsByStatus.schema; +} +`, + sourceFile, + { + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { findPetsByStatus } from "./api/services/PetsService"; + export function App() { + return findPetsByStatus.schema; + }" + `); + }); + + it('rewrites schema access for generic factories when services.moduleSpecifierBase is configured', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + 'src/api/createAPIClient.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; + +export function createAPIClient(services) { + return qraftAPIClient(services, {}); +} +`, + 'src/api/services/PetsService.ts': PETS_SERVICE_TS, + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api/createAPIClient'; +import { getPets } from './api/services/PetsService'; + +const api = createAPIClient({ pets: { getPets } }); +api.pets.getPets.schema; +`, + sourceFile, + { + diagnostics: 'off', + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api/createAPIClient', + }, + services: { + moduleSpecifierBase: './api', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { getPets } from './api/services/PetsService'; + import { getPets as _getPets } from "./api/services/PetsService"; + _getPets.schema;" + `); + }); + + it('aliases same-named schema operation imports from different generated roots', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qraft-tree-shaking-') + ); + await writeFixtureFiles(root, { + ...getContextFixtureFiles( + 'APIClientContext', + './APIClientContext', + true, + 'context-api' + ), + ...createPrecreatedFixtureFiles( + ` +import { createAPIClient } from './precreated-api'; +import { createAPIClientOptions } from './precreated-client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + { + 'src/precreated-api/index.ts': ` +import { qraftAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; +import { services } from './services/index'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(options?: { queryClient: unknown }) { + return qraftAPIClient(services, defaultCallbacks, options); +} +`, + } + ), + }); + const sourceFile = path.join(root, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './context-api'; +import { APIClient } from './client'; + +const contextApi = createAPIClient(); + +contextApi.pets.getPets.schema; +APIClient.pets.getPets.schema; +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './context-api', + }, + }, + { + kind: 'precreatedClient', + client: { + exportName: 'APIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './precreated-api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './precreated-client-options', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { getPets } from "./context-api/services/PetsService"; + import { getPets as _getPets } from "./precreated-api/services/PetsService"; + getPets.schema; + _getPets.schema;" + `); + }); +}); diff --git a/packages/tree-shaking-plugin/src/__tests__/core/source-maps.test.ts b/packages/tree-shaking-plugin/src/__tests__/core/source-maps.test.ts new file mode 100644 index 000000000..131e00ed2 --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/source-maps.test.ts @@ -0,0 +1,96 @@ +import type { SourceMapInput } from '@jridgewell/trace-mapping'; +import path from 'node:path'; +import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'; +import { describe, expect, it } from 'vitest'; +import { createFixture, transformQraftTreeShaking } from './harness.js'; + +describe('transformQraftTreeShaking source maps', () => { + it('keeps a rewritten user call site traceable through an incoming source map', async () => { + const fixture = await createFixture(); + const generatedSourceFile = path.join(fixture, 'src/App.generated.tsx'); + const originalSourceFile = path.join(fixture, 'src/App.tsx'); + const code = [ + "import { createAPIClient } from './api';", + '', + 'const api = createAPIClient();', + '', + 'export function App() {', + ' return api.pets.getPets.useQuery();', + '}', + ].join('\n'); + const inputSourceMap = createIdentitySourceMap( + generatedSourceFile, + originalSourceFile, + code + ); + + const result = await transformQraftTreeShaking( + code, + generatedSourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + }, + inputSourceMap + ); + + if (!result) { + throw new Error('Expected transform result'); + } + + const generatedLineIndex = result.code + .split('\n') + .findIndex((line) => line.includes('api_pets_getPets.useQuery()')); + + if (generatedLineIndex === -1) { + throw new Error('Expected rewritten user call site in generated output'); + } + + const generatedLine = generatedLineIndex + 1; + const generatedColumn = result.code + .split('\n') + [generatedLineIndex].indexOf('api_pets_getPets'); + + const traceMapInput = result.map! as SourceMapInput; + + const position = originalPositionFor(new TraceMap(traceMapInput), { + line: generatedLine, + column: generatedColumn, + }); + + expect(position).toMatchObject({ + source: originalSourceFile, + line: 6, + }); + }); +}); + +function createIdentitySourceMap( + generatedSourceFile: string, + originalSourceFile: string, + source: string +): SourceMapInput { + const lineCount = source.split('\n').length; + const mappings = Array.from({ length: lineCount }, (_, index) => + index === 0 ? 'AAAA' : 'AACA' + ).join(';'); + + return { + version: 3, + file: generatedSourceFile, + names: [], + sources: [originalSourceFile], + sourcesContent: [source], + mappings, + }; +} diff --git a/packages/tree-shaking-plugin/src/__tests__/core/unsupported-and-safety.test.ts b/packages/tree-shaking-plugin/src/__tests__/core/unsupported-and-safety.test.ts new file mode 100644 index 000000000..d8ea0bfa0 --- /dev/null +++ b/packages/tree-shaking-plugin/src/__tests__/core/unsupported-and-safety.test.ts @@ -0,0 +1,468 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createFixtureModuleAccess } from './fixtures.js'; +import { createFixture, transformQraftTreeShaking } from './harness.js'; + +describe('transformQraftTreeShaking unsupported and safety', () => { + it('keeps the original client when an unsupported reference remains', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +// Unsupported raw client reference keeps the original client binding alive. +console.log(api); +api.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result?.code).toMatchInlineSnapshot(` + "import { createAPIClient } from './api'; + import { qraftReactAPIClient } from "@openapi-qraft/react"; + import { useQuery } from "@openapi-qraft/react/callbacks/useQuery"; + import { getPets } from "./api/services/PetsService"; + import { APIClientContext } from "./api"; + const api_pets_getPets = qraftReactAPIClient(getPets, { + useQuery + }, APIClientContext); + const api = createAPIClient(); + + // Unsupported raw client reference keeps the original client binding alive. + console.log(api); + api_pets_getPets.useQuery();" + `); + }); + + it('skips exported clients', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +export const api = createAPIClient(); + +api.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result).toBeNull(); + }); + + it('does not report unavailable generated source for exported clients', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +export const api = createAPIClient(); + +api.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: fixtureModuleAccess.resolve, + load: async () => null, + }, + } + ); + + expect(result).toBeNull(); + }); + + it('does not rewrite computed member access', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); +const serviceName = 'pets'; + +api[serviceName].getPets.useQuery(); +api.pets['getPets'].useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result).toBeNull(); + }); + + it('does not rewrite destructured client aliases', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); +const { pets } = api; + +pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result).toBeNull(); + }); + + it('does not rewrite optional member chains until short-circuit semantics can be preserved', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api?.pets?.getPets?.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + } + ); + + expect(result).toBeNull(); + }); + + it('does not report unavailable generated source for optional member chains', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api?.pets?.getPets?.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: fixtureModuleAccess.resolve, + load: async () => null, + }, + } + ); + + expect(result).toBeNull(); + }); + + it('does not report unavailable generated source for unsupported callbacks', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets.unsupportedCallback(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: fixtureModuleAccess.resolve, + load: async () => null, + }, + } + ); + + expect(result).toBeNull(); + }); + + it('does not report unavailable generated source for operation property reads', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets; +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: fixtureModuleAccess.resolve, + load: async () => null, + }, + } + ); + + expect(result).toBeNull(); + }); + + it('does not report unavailable generated source for inline operation property reads', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +createAPIClient().pets.getPets; +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: fixtureModuleAccess.resolve, + load: async () => null, + }, + } + ); + + expect(result).toBeNull(); + }); + + it('reports unavailable generated source for shadowed outer clients by binding', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + + await expect( + transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +function nested() { + const api = createAPIClient({ requestFn: async () => new Response() }); + return api; +} + +api.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: fixtureModuleAccess.resolve, + load: async () => null, + }, + } + ) + ).rejects.toMatchObject({ + name: 'QraftTreeShakeError', + reason: expect.objectContaining({ + code: 'entrypoint-source-unavailable', + }), + }); + }); + + it('does not report unavailable generated source for local clients with unsupported arity', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const a = {}; +const b = {}; +const api = createAPIClient(a, b); + +api.pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: fixtureModuleAccess.resolve, + load: async () => null, + }, + } + ); + + expect(result).toBeNull(); + }); + + it('does not report unavailable generated source for inline clients with unsupported arity', async () => { + const fixture = await createFixture(); + const sourceFile = path.join(fixture, 'src/App.tsx'); + const fixtureModuleAccess = createFixtureModuleAccess(fixture); + + const result = await transformQraftTreeShaking( + ` +import { createAPIClient } from './api'; + +const a = {}; +const b = {}; + +createAPIClient(a, b).pets.getPets.useQuery(); +`, + sourceFile, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + }, + ], + moduleAccess: { + resolve: fixtureModuleAccess.resolve, + load: async () => null, + }, + } + ); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/tree-shaking-plugin/src/core.ts b/packages/tree-shaking-plugin/src/core.ts new file mode 100644 index 000000000..0850406f3 --- /dev/null +++ b/packages/tree-shaking-plugin/src/core.ts @@ -0,0 +1,129 @@ +import type { GeneratorOptions as BabelGeneratorOptions } from '@babel/generator'; +// eslint-disable-next-line import-x/no-extraneous-dependencies +import type { SourceMapInput } from '@jridgewell/trace-mapping'; +import type { + QraftModuleAccess, + QraftModuleAccessOptions, + QraftResolver, +} from './lib/resolvers/common.js'; +import type { SourceFilterOptions } from './lib/transform/source-gate.js'; +import * as generateModule from '@babel/generator'; +import { resolveDefaultExport } from './lib/interop/resolve-default-export.js'; +import { normalizeEntrypoints } from './lib/transform/entrypoints.js'; +import { type GeneratedMetadataCache } from './lib/transform/generated-metadata.js'; +import { applyTransformMutations } from './lib/transform/mutate.js'; +import { shouldInspectSource } from './lib/transform/source-gate.js'; +import { createTransformState } from './lib/transform/state.js'; + +export type FilterPattern = string | RegExp | Array; + +export type ModuleExportTarget = { + exportName: string; + moduleSpecifier: string; +}; + +export type ReactContextTarget = { + exportName: string; + moduleSpecifier?: string; +}; + +export type ServicesTarget = { + moduleSpecifierBase?: string; + directory?: string; +}; + +export type QraftClientFactoryEntrypointConfig = { + kind: 'clientFactory'; + factory: ModuleExportTarget; + services?: ServicesTarget; + reactContext?: ReactContextTarget; +}; + +export type QraftPrecreatedClientEntrypointConfig = { + kind: 'precreatedClient'; + client: ModuleExportTarget; + factory: ModuleExportTarget; + optionsFactory: ModuleExportTarget; + services?: ServicesTarget; +}; + +export type QraftEntrypointConfig = + | QraftClientFactoryEntrypointConfig + | QraftPrecreatedClientEntrypointConfig; + +export type DiagnosticsLevel = 'error' | 'warn' | 'off'; + +export type { + QraftModuleAccess, + QraftModuleAccessOptions, + QraftResolver, +} from './lib/resolvers/common.js'; + +export type QraftTreeShakeOptions = { + entrypoints?: QraftEntrypointConfig[]; + resolve?: QraftResolver; + /** + * Advanced source-provider override. Normal bundler integrations provide + * this automatically; use it only for virtual modules or custom + * filesystems/source providers. + */ + moduleAccess?: QraftModuleAccessOptions; + include?: FilterPattern; + exclude?: FilterPattern; + diagnostics?: DiagnosticsLevel; +}; + +type GenerateFn = (typeof import('@babel/generator'))['default']; +type GeneratorOptions = Omit & { + inputSourceMap?: SourceMapInput; +}; + +const generate = resolveDefaultExport(generateModule); + +export async function transformQraftTreeShaking( + code: string, + id: string, + options: QraftTreeShakeOptions, + moduleAccess: QraftModuleAccess, + inputSourceMap: SourceMapInput | undefined, + generatedMetadataCache: GeneratedMetadataCache, + sourceFilters: SourceFilterOptions +) { + const entrypoints = normalizeEntrypoints(options); + if ( + !shouldInspectSource({ + code, + id, + entrypoints, + include: sourceFilters.include, + exclude: sourceFilters.exclude, + }) + ) { + return null; + } + + const state = await createTransformState( + code, + id, + options, + moduleAccess, + generatedMetadataCache + ); + if (!state.namedUsages.length && !state.inlineUsages.length) return null; + + applyTransformMutations(state); + + const generatorOptions = { + sourceMaps: true, + sourceFileName: id, + jsescOption: { minimal: true }, + inputSourceMap, + } satisfies GeneratorOptions; + + const result = generate(state.ast, generatorOptions); + + return { + code: result.code, + map: result.map, + }; +} diff --git a/packages/tree-shaking-plugin/src/esbuild.ts b/packages/tree-shaking-plugin/src/esbuild.ts new file mode 100644 index 000000000..4312b62a2 --- /dev/null +++ b/packages/tree-shaking-plugin/src/esbuild.ts @@ -0,0 +1,12 @@ +import { + createBuildStartHooks, + createQraftTreeShakePlugin, +} from './lib/plugin/create-qraft-tree-shake-plugin.js'; +import { type BundlerResolveContext } from './lib/resolvers/common.js'; +import { createEsbuildModuleAccess } from './lib/resolvers/esbuild.js'; + +export const qraftTreeShakeEsbuild = + createQraftTreeShakePlugin( + createEsbuildModuleAccess, + createBuildStartHooks + ).esbuild; diff --git a/packages/tree-shaking-plugin/src/index.ts b/packages/tree-shaking-plugin/src/index.ts new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/tree-shaking-plugin/src/index.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/tree-shaking-plugin/src/lib/interop/resolve-default-export.ts b/packages/tree-shaking-plugin/src/lib/interop/resolve-default-export.ts new file mode 100644 index 000000000..c8c86e31b --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/interop/resolve-default-export.ts @@ -0,0 +1,26 @@ +type DefaultExportWrapper = { + default?: unknown; +}; + +/** + * Resolves default exports from CJS/ESM interop wrappers. + */ +export function resolveDefaultExport(module: unknown): T { + if (hasDefaultExport(module)) { + const firstDefault = module.default; + + if (hasDefaultExport(firstDefault) && firstDefault.default != null) { + return firstDefault.default as T; + } + + if (firstDefault != null) { + return firstDefault as T; + } + } + + return module as T; +} + +function hasDefaultExport(value: unknown): value is DefaultExportWrapper { + return typeof value === 'object' && value !== null && 'default' in value; +} diff --git a/packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.test.ts b/packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.test.ts new file mode 100644 index 000000000..e7bba6e76 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.test.ts @@ -0,0 +1,59 @@ +import type { UnpluginContextMeta } from 'unplugin'; +import { describe, expect, it } from 'vitest'; +import { + createQraftTreeShakePlugin, + resolvePluginSourceFilterOptions, +} from './create-qraft-tree-shake-plugin.js'; + +describe('createQraftTreeShakePlugin', () => { + it('does not infer bundler lifecycle hooks from unplugin metadata', () => { + const webpackMeta = { + framework: 'webpack', + webpack: { compiler: {} as never }, + } satisfies UnpluginContextMeta; + + const plugin = createQraftTreeShakePlugin(() => ({ + resolve: async () => null, + load: async () => null, + })).raw({}, webpackMeta); + + expect(plugin).not.toHaveProperty('buildStart'); + expect(plugin).not.toHaveProperty('webpack'); + }); + + it('passes cache clearing to adapter-specific hooks', () => { + const plugin = createQraftTreeShakePlugin( + () => ({ + resolve: async () => null, + load: async () => null, + }), + ({ clearGeneratedMetadataCache }) => ({ + vite: { + handleHotUpdate: clearGeneratedMetadataCache, + }, + }) + ).raw({}, { framework: 'vite' }); + + expect(Array.isArray(plugin)).toBe(false); + if (Array.isArray(plugin)) return; + + expect(plugin.vite).toHaveProperty('handleHotUpdate'); + }); + + it('resolves default source filters at the plugin entrypoint', () => { + expect(resolvePluginSourceFilterOptions({})).toEqual({ + include: [/\.[cm]?[jt]sx?$/], + exclude: /node_modules/, + }); + + expect( + resolvePluginSourceFilterOptions({ + include: /\.custom$/, + exclude: /vendor/, + }) + ).toEqual({ + include: /\.custom$/, + exclude: /vendor/, + }); + }); +}); diff --git a/packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.ts b/packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.ts new file mode 100644 index 000000000..abf45c0ce --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/plugin/create-qraft-tree-shake-plugin.ts @@ -0,0 +1,90 @@ +import type { UnpluginFactory, UnpluginOptions } from 'unplugin'; +import type { QraftTreeShakeOptions } from '../../core.js'; +import { createUnplugin } from 'unplugin'; +import { transformQraftTreeShaking } from '../../core.js'; +import { type QraftModuleAccessFactory } from '../resolvers/common.js'; +import { createGeneratedMetadataCache } from '../transform/generated-metadata.js'; +import { type SourceFilterOptions } from '../transform/source-gate.js'; + +export const QRAFT_TREE_SHAKE_PLUGIN_NAME = + '@openapi-qraft/tree-shaking-plugin'; + +export type QraftResolverFactory = + QraftModuleAccessFactory; + +export type QraftTreeShakePluginHooks = Pick< + UnpluginOptions, + 'buildStart' | 'esbuild' | 'rollup' | 'rspack' | 'vite' | 'webpack' +>; + +export type QraftTreeShakePluginHooksContext = { + clearGeneratedMetadataCache: () => void; +}; + +export type QraftTreeShakePluginHooksFactory = ( + context: QraftTreeShakePluginHooksContext +) => Partial; + +const defaultPluginSourceFilters = { + include: [/\.[cm]?[jt]sx?$/], + exclude: /node_modules/, +} satisfies Required; + +export function resolvePluginSourceFilterOptions({ + include, + exclude, +}: SourceFilterOptions): Required { + return { + include: include ?? defaultPluginSourceFilters.include, + exclude: exclude ?? defaultPluginSourceFilters.exclude, + }; +} + +export const createBuildStartHooks: QraftTreeShakePluginHooksFactory = ({ + clearGeneratedMetadataCache, +}) => ({ + buildStart: clearGeneratedMetadataCache, +}); + +export function createQraftTreeShakePlugin( + createModuleAccess: QraftModuleAccessFactory, + createPluginHooks?: QraftTreeShakePluginHooksFactory +) { + const factory: UnpluginFactory = (options) => { + const generatedMetadataCache = createGeneratedMetadataCache(); + const sourceFilters = resolvePluginSourceFilterOptions(options); + const clearGeneratedMetadataCache = () => { + generatedMetadataCache.clear(); + }; + + return { + name: QRAFT_TREE_SHAKE_PLUGIN_NAME, + ...createPluginHooks?.({ clearGeneratedMetadataCache }), + transform: { + filter: { + id: { + include: sourceFilters.include, + exclude: sourceFilters.exclude, + }, + }, + handler(this: any, code, id) { + const moduleAccess = createModuleAccess(this, { + resolve: options.moduleAccess?.resolve ?? options.resolve, + load: options.moduleAccess?.load, + }); + return transformQraftTreeShaking( + code, + id, + options, + moduleAccess, + this.inputSourceMap, + generatedMetadataCache, + sourceFilters + ); + }, + }, + }; + }; + + return createUnplugin(factory); +} diff --git a/packages/tree-shaking-plugin/src/lib/resolvers/agnostic.ts b/packages/tree-shaking-plugin/src/lib/resolvers/agnostic.ts new file mode 100644 index 000000000..4cb5b3162 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/resolvers/agnostic.ts @@ -0,0 +1,15 @@ +import type { QraftModuleAccess, QraftModuleAccessOptions } from './common.js'; +import { + createQraftModuleAccess, + createUserResolverStrategy, + createUserSourceLoaderStrategy, +} from './common.js'; + +export function createAgnosticModuleAccess( + userAccess: QraftModuleAccessOptions = {} +): QraftModuleAccess { + return createQraftModuleAccess( + [createUserResolverStrategy(userAccess.resolve)], + [createUserSourceLoaderStrategy(userAccess.load)] + ); +} diff --git a/packages/tree-shaking-plugin/src/lib/resolvers/common.ts b/packages/tree-shaking-plugin/src/lib/resolvers/common.ts new file mode 100644 index 000000000..19db4eb1c --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/resolvers/common.ts @@ -0,0 +1,596 @@ +export { stripQueryAndHash } from '../transform/path-rendering.js'; + +export type QraftResolver = ( + specifier: string, + importer: string +) => Promise | string | null; + +export type QraftSourceLoader = ( + resolvedId: string +) => Promise | string | null; + +export type QraftModuleAccess = { + resolve: QraftResolver; + load: QraftSourceLoader; +}; + +export type QraftModuleAccessStrategyName = + | 'native' + | 'user' + | 'adapter-fallback'; + +export type QraftModuleAccessStrategyMetadata = { + resolve: QraftModuleAccessStrategyName[]; + load: QraftModuleAccessStrategyName[]; +}; + +export type QraftModuleAccessTraceStage = { + name: QraftModuleAccessStrategyName; + result: 'hit' | 'miss' | 'error'; + value?: string; + message?: string; +}; + +export type QraftModuleAccessTraceEntry = + | { + kind: 'resolve'; + target: string; + importer: string; + stages: QraftModuleAccessTraceStage[]; + } + | { + kind: 'load'; + target: string; + stages: QraftModuleAccessTraceStage[]; + }; + +type QraftModuleAccessTraceState = { + entries: Array<{ sequence: number; entry: QraftModuleAccessTraceEntry }>; + nextSequence: number; +}; + +const qraftModuleAccessStrategyMetadata = Symbol( + 'qraft.moduleAccessStrategyMetadata' +); +const qraftModuleAccessTrace = Symbol('qraft.moduleAccessTrace'); + +export type QraftModuleAccessOptions = { + resolve?: QraftResolver; + load?: QraftSourceLoader; +}; + +export type QraftModuleAccessFactory = ( + ctx: TRuntimeContext, + userAccess?: QraftModuleAccessOptions +) => QraftModuleAccess; + +export type ResolveRequest = { + specifier: string; + importer: string; +}; + +export type ResolveStrategy = { + name: QraftModuleAccessStrategyName; + resolve: (request: ResolveRequest) => Promise | string | null; +}; + +export type RollupLikeResolve = ( + source: string, + importer?: string, + options?: { skipSelf?: boolean } +) => Promise<{ id: string; external?: boolean } | null | undefined>; + +export type RollupLikeFs = { + readFile?: ( + path: string, + encoding: 'utf8' + ) => Promise | string | Uint8Array; +}; + +export type LoadRequest = { + id: string; +}; + +export type LoadStrategy = { + name: QraftModuleAccessStrategyName; + load: (request: LoadRequest) => Promise | string | null; +}; + +export type EsbuildLikeBuild = { + resolve: ( + path: string, + options?: { resolveDir?: string; kind?: string; importer?: string } + ) => Promise<{ path: string; errors?: unknown[] }>; +}; + +export type BundlerNativeBuildContext = { + framework?: string; + build?: EsbuildLikeBuild; + compiler?: unknown; + compilation?: unknown; + loaderContext?: unknown; + inputSourceMap?: unknown; +}; + +export type BundlerResolveContext = { + resolve?: RollupLikeResolve; + fs?: RollupLikeFs; + getNativeBuildContext?: () => BundlerNativeBuildContext | null; +}; + +export function createQraftModuleAccess( + resolveStrategies: ResolveStrategy[], + loadStrategies: LoadStrategy[] +): QraftModuleAccess { + const trace = createModuleAccessTraceState(); + const recordTrace = (entry: QraftModuleAccessTraceEntry) => + recordModuleAccessTrace(trace, entry); + const access = { + resolve: createResolverChain(resolveStrategies, recordTrace), + load: createSourceLoaderChain(loadStrategies, recordTrace), + }; + + Object.defineProperty(access, qraftModuleAccessStrategyMetadata, { + value: { + resolve: resolveStrategies.map((strategy) => strategy.name), + load: loadStrategies.map((strategy) => strategy.name), + } satisfies QraftModuleAccessStrategyMetadata, + }); + Object.defineProperty(access, qraftModuleAccessTrace, { + value: trace, + }); + + return access; +} + +export function getQraftModuleAccessStrategyMetadata( + access: QraftModuleAccess +): QraftModuleAccessStrategyMetadata | null { + if (!(qraftModuleAccessStrategyMetadata in access)) return null; + + const metadata = Reflect.get(access, qraftModuleAccessStrategyMetadata); + if (!isQraftModuleAccessStrategyMetadata(metadata)) return null; + + return metadata; +} + +export function getQraftModuleAccessTrace( + access: QraftModuleAccess +): QraftModuleAccessTraceEntry[] { + const trace = getQraftModuleAccessTraceState(access); + if (!trace) return []; + + return trace.entries.map(({ entry }) => cloneModuleAccessTraceEntry(entry)); +} + +export function getQraftModuleAccessTraceSnapshot( + access: QraftModuleAccess +): number { + return getQraftModuleAccessTraceState(access)?.nextSequence ?? 0; +} + +export function getQraftModuleAccessTraceSince( + access: QraftModuleAccess, + snapshot: number +): QraftModuleAccessTraceEntry[] { + const trace = getQraftModuleAccessTraceState(access); + if (!trace) return []; + + return trace.entries + .filter(({ sequence }) => sequence >= snapshot) + .map(({ entry }) => cloneModuleAccessTraceEntry(entry)); +} + +export function createTraceableQraftModuleAccess( + access: QraftModuleAccess +): QraftModuleAccess { + if (qraftModuleAccessTrace in access) return access; + + const trace = createModuleAccessTraceState(); + const recordTrace = (entry: QraftModuleAccessTraceEntry) => + recordModuleAccessTrace(trace, entry); + const traceableAccess = { + async resolve(specifier: string, importer: string) { + try { + const resolved = await access.resolve(specifier, importer); + recordTrace({ + kind: 'resolve', + target: specifier, + importer, + stages: [ + resolved + ? { name: 'user', result: 'hit', value: resolved } + : { name: 'user', result: 'miss' }, + ], + }); + return resolved; + } catch (error) { + recordTrace({ + kind: 'resolve', + target: specifier, + importer, + stages: [ + { + name: 'user', + result: 'error', + message: formatTraceError(error), + }, + ], + }); + throw error; + } + }, + async load(id: string) { + try { + const loaded = await access.load(id); + recordTrace({ + kind: 'load', + target: id, + stages: [ + loaded !== null && loaded !== undefined + ? { name: 'user', result: 'hit' } + : { name: 'user', result: 'miss' }, + ], + }); + return loaded; + } catch (error) { + recordTrace({ + kind: 'load', + target: id, + stages: [ + { + name: 'user', + result: 'error', + message: formatTraceError(error), + }, + ], + }); + throw error; + } + }, + } satisfies QraftModuleAccess; + + Object.defineProperty(traceableAccess, qraftModuleAccessTrace, { + value: trace, + }); + + return traceableAccess; +} + +function isQraftModuleAccessStrategyMetadata( + metadata: unknown +): metadata is QraftModuleAccessStrategyMetadata { + if (typeof metadata !== 'object' || metadata === null) return false; + + const resolve = Reflect.get(metadata, 'resolve'); + const load = Reflect.get(metadata, 'load'); + return ( + Array.isArray(resolve) && + resolve.every(isQraftModuleAccessStrategyName) && + Array.isArray(load) && + load.every(isQraftModuleAccessStrategyName) + ); +} + +function isQraftModuleAccessStrategyName( + name: unknown +): name is QraftModuleAccessStrategyName { + return name === 'native' || name === 'user' || name === 'adapter-fallback'; +} + +export function createResolverChain( + strategies: ResolveStrategy[], + onTrace?: (entry: QraftModuleAccessTraceEntry) => void +): QraftResolver { + const cache = new Map< + string, + { + pending: Promise; + traceEntry?: QraftModuleAccessTraceEntry; + } + >(); + + return (specifier, importer) => { + const key = `${specifier}\0${importer}`; + const cached = cache.get(key); + if (cached) { + replayCachedTrace(cached, onTrace); + return cached.pending; + } + + const cacheEntry: { + pending: Promise; + traceEntry?: QraftModuleAccessTraceEntry; + } = { + pending: Promise.resolve(null), + }; + cacheEntry.pending = resolveWithStrategies( + strategies, + specifier, + importer, + (entry) => { + cacheEntry.traceEntry = cloneModuleAccessTraceEntry(entry); + onTrace?.(entry); + } + ); + cache.set(key, cacheEntry); + return cacheEntry.pending; + }; +} + +function replayCachedTrace( + cacheEntry: { + pending: Promise; + traceEntry?: QraftModuleAccessTraceEntry; + }, + onTrace?: (entry: QraftModuleAccessTraceEntry) => void +) { + if (!onTrace) return; + if (cacheEntry.traceEntry) { + onTrace(cloneModuleAccessTraceEntry(cacheEntry.traceEntry)); + return; + } + + void cacheEntry.pending.then( + () => { + if (cacheEntry.traceEntry) { + onTrace(cloneModuleAccessTraceEntry(cacheEntry.traceEntry)); + } + }, + () => undefined + ); +} + +async function resolveWithStrategies( + strategies: ResolveStrategy[], + specifier: string, + importer: string, + onTrace?: (entry: QraftModuleAccessTraceEntry) => void +): Promise { + const stages: QraftModuleAccessTraceStage[] = []; + + for (const strategy of strategies) { + try { + const resolved = await strategy.resolve({ specifier, importer }); + if (resolved) { + stages.push({ + name: strategy.name, + result: 'hit', + value: resolved, + }); + onTrace?.({ + kind: 'resolve', + target: specifier, + importer, + stages, + }); + return resolved; + } + stages.push({ name: strategy.name, result: 'miss' }); + } catch (error) { + stages.push({ + name: strategy.name, + result: 'error', + message: formatTraceError(error), + }); + // Try the next strategy. + } + } + + onTrace?.({ + kind: 'resolve', + target: specifier, + importer, + stages, + }); + return null; +} + +export function createUserResolverStrategy( + userResolve?: QraftResolver +): ResolveStrategy { + return { + name: 'user', + async resolve({ specifier, importer }) { + if (!userResolve) return null; + const resolved = await userResolve(specifier, importer); + return resolved || null; + }, + }; +} + +export function createSourceLoaderChain( + strategies: LoadStrategy[], + onTrace?: (entry: QraftModuleAccessTraceEntry) => void +): QraftSourceLoader { + const cache = new Map< + string, + { + pending: Promise; + traceEntry?: QraftModuleAccessTraceEntry; + } + >(); + + return (id) => { + const cached = cache.get(id); + if (cached) { + replayCachedTrace(cached, onTrace); + return cached.pending; + } + + const cacheEntry: { + pending: Promise; + traceEntry?: QraftModuleAccessTraceEntry; + } = { + pending: Promise.resolve(null), + }; + cacheEntry.pending = loadWithStrategies(strategies, id, (entry) => { + cacheEntry.traceEntry = cloneModuleAccessTraceEntry(entry); + onTrace?.(entry); + }).catch((error) => { + cache.delete(id); + throw error; + }); + cache.set(id, cacheEntry); + return cacheEntry.pending; + }; +} + +async function loadWithStrategies( + strategies: LoadStrategy[], + id: string, + onTrace?: (entry: QraftModuleAccessTraceEntry) => void +): Promise { + const stages: QraftModuleAccessTraceStage[] = []; + + for (const strategy of strategies) { + try { + const loaded = await strategy.load({ id }); + if (loaded !== null && loaded !== undefined) { + stages.push({ name: strategy.name, result: 'hit' }); + onTrace?.({ + kind: 'load', + target: id, + stages, + }); + return loaded; + } + stages.push({ name: strategy.name, result: 'miss' }); + } catch (error) { + stages.push({ + name: strategy.name, + result: 'error', + message: formatTraceError(error), + }); + onTrace?.({ + kind: 'load', + target: id, + stages, + }); + throw error; + } + } + + onTrace?.({ + kind: 'load', + target: id, + stages, + }); + return null; +} + +export function createUserSourceLoaderStrategy( + userLoad?: QraftSourceLoader +): LoadStrategy { + return { + name: 'user', + async load({ id }) { + if (!userLoad) return null; + const loaded = await userLoad(id); + return loaded ?? null; + }, + }; +} + +function getQraftModuleAccessTraceState( + access: QraftModuleAccess +): QraftModuleAccessTraceState | null { + if (!(qraftModuleAccessTrace in access)) return null; + + const trace = Reflect.get(access, qraftModuleAccessTrace); + if (!isQraftModuleAccessTraceState(trace)) return null; + + return trace; +} + +function createModuleAccessTraceState(): QraftModuleAccessTraceState { + return { + entries: [], + nextSequence: 0, + }; +} + +function recordModuleAccessTrace( + trace: QraftModuleAccessTraceState, + entry: QraftModuleAccessTraceEntry +) { + trace.entries.push({ + sequence: trace.nextSequence, + entry: cloneModuleAccessTraceEntry(entry), + }); + trace.nextSequence += 1; + if (trace.entries.length > 50) trace.entries.shift(); +} + +function cloneModuleAccessTraceEntry( + entry: QraftModuleAccessTraceEntry +): QraftModuleAccessTraceEntry { + return { + ...entry, + stages: entry.stages.map((stage) => ({ ...stage })), + }; +} + +function isQraftModuleAccessTraceState( + trace: unknown +): trace is QraftModuleAccessTraceState { + if (typeof trace !== 'object' || trace === null) return false; + + const entries = Reflect.get(trace, 'entries'); + const nextSequence = Reflect.get(trace, 'nextSequence'); + return ( + Array.isArray(entries) && + typeof nextSequence === 'number' && + entries.every(isQraftModuleAccessTraceRecord) + ); +} + +function isQraftModuleAccessTraceRecord(record: unknown) { + if (typeof record !== 'object' || record === null) return false; + + const sequence = Reflect.get(record, 'sequence'); + const entry = Reflect.get(record, 'entry'); + return typeof sequence === 'number' && isQraftModuleAccessTraceEntry(entry); +} + +function isQraftModuleAccessTraceEntry( + entry: unknown +): entry is QraftModuleAccessTraceEntry { + if (typeof entry !== 'object' || entry === null) return false; + + const kind = Reflect.get(entry, 'kind'); + const target = Reflect.get(entry, 'target'); + const stages = Reflect.get(entry, 'stages'); + if (kind !== 'resolve' && kind !== 'load') return false; + if (typeof target !== 'string') return false; + if (kind === 'resolve' && typeof Reflect.get(entry, 'importer') !== 'string') + return false; + return Array.isArray(stages) && stages.every(isQraftModuleAccessTraceStage); +} + +function isQraftModuleAccessTraceStage( + stage: unknown +): stage is QraftModuleAccessTraceStage { + if (typeof stage !== 'object' || stage === null) return false; + + const name = Reflect.get(stage, 'name'); + const result = Reflect.get(stage, 'result'); + const value = Reflect.get(stage, 'value'); + const message = Reflect.get(stage, 'message'); + return ( + isQraftModuleAccessStrategyName(name) && + (result === 'hit' || result === 'miss' || result === 'error') && + (value === undefined || typeof value === 'string') && + (message === undefined || typeof message === 'string') + ); +} + +function formatTraceError(error: unknown): string { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : String(error); + return message.length > 120 ? `${message.slice(0, 117)}...` : message; +} diff --git a/packages/tree-shaking-plugin/src/lib/resolvers/esbuild.ts b/packages/tree-shaking-plugin/src/lib/resolvers/esbuild.ts new file mode 100644 index 000000000..ba40316ee --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/resolvers/esbuild.ts @@ -0,0 +1,77 @@ +import type { + BundlerResolveContext, + LoadStrategy, + QraftModuleAccess, + QraftModuleAccessOptions, + ResolveStrategy, +} from './common.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + createQraftModuleAccess, + createUserResolverStrategy, + createUserSourceLoaderStrategy, + stripQueryAndHash, +} from './common.js'; + +function createEsbuildResolveStrategy( + ctx: BundlerResolveContext +): ResolveStrategy { + return { + name: 'native', + async resolve({ specifier, importer }) { + const native = ctx.getNativeBuildContext?.(); + if (native?.framework !== 'esbuild' || !native.build) return null; + + try { + const resolved = await native.build.resolve(specifier, { + resolveDir: path.dirname(importer), + kind: 'import-statement', + importer, + }); + if ( + resolved && + resolved.path && + (!resolved.errors || resolved.errors.length === 0) + ) { + return resolved.path; + } + } catch { + // fall through + } + + return null; + }, + }; +} + +// Esbuild exposes build.resolve but no arbitrary build.load API. Keep this +// fallback adapter-local; core transform must not read the filesystem directly. +function createEsbuildFileLoadStrategy(): LoadStrategy { + return { + name: 'adapter-fallback', + async load({ id }) { + try { + return await fs.readFile(stripQueryAndHash(id), 'utf8'); + } catch { + return null; + } + }, + }; +} + +export function createEsbuildModuleAccess( + ctx: BundlerResolveContext, + userAccess: QraftModuleAccessOptions = {} +): QraftModuleAccess { + return createQraftModuleAccess( + [ + createUserResolverStrategy(userAccess.resolve), + createEsbuildResolveStrategy(ctx), + ], + [ + createUserSourceLoaderStrategy(userAccess.load), + createEsbuildFileLoadStrategy(), + ] + ); +} diff --git a/packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts b/packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts new file mode 100644 index 000000000..556a41349 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/resolvers/resolvers.test.ts @@ -0,0 +1,623 @@ +import type { BundlerResolveContext } from './common.js'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { createAgnosticModuleAccess } from './agnostic.js'; +import { getQraftModuleAccessStrategyMetadata } from './common.js'; +import { createEsbuildModuleAccess } from './esbuild.js'; +import { createRollupLikeModuleAccess } from './rollup-like.js'; +import { createRspackModuleAccess } from './rspack.js'; +import { createWebpackLikeModuleAccess } from './webpack-like.js'; + +async function mktemp() { + return fs.mkdtemp(path.join(os.tmpdir(), 'qraft-resolver-')); +} + +describe('resolver composition', () => { + it('exposes named strategy order for adapter-created module access', () => { + expect( + getQraftModuleAccessStrategyMetadata(createAgnosticModuleAccess()) + ).toEqual({ + resolve: ['user'], + load: ['user'], + }); + expect( + getQraftModuleAccessStrategyMetadata(createRollupLikeModuleAccess({})) + ).toEqual({ + resolve: ['user', 'native'], + load: ['user', 'adapter-fallback'], + }); + expect( + getQraftModuleAccessStrategyMetadata(createWebpackLikeModuleAccess({})) + ).toEqual({ + resolve: ['user', 'native'], + load: ['user', 'native', 'adapter-fallback'], + }); + expect( + getQraftModuleAccessStrategyMetadata(createRspackModuleAccess({})) + ).toEqual({ + resolve: ['user', 'native'], + load: ['user', 'native', 'adapter-fallback'], + }); + expect( + getQraftModuleAccessStrategyMetadata(createEsbuildModuleAccess({})) + ).toEqual({ + resolve: ['user', 'native'], + load: ['user', 'adapter-fallback'], + }); + }); + + it('uses only the custom resolver in agnostic module access', async () => { + const importer = path.join(await mktemp(), 'src.ts'); + const customResolve = vi.fn(async () => null); + const access = createAgnosticModuleAccess({ resolve: customResolve }); + + await expect(access.resolve('./fallback', importer)).resolves.toBeNull(); + expect(customResolve).toHaveBeenCalledWith('./fallback', importer); + }); + + it('uses a custom module loader after custom resolution', async () => { + const resolve = vi.fn(async (specifier: string, importer: string) => { + expect(specifier).toBe('./api'); + expect(importer).toBe('/tmp/src/App.tsx'); + return '/tmp/src/api/index.ts'; + }); + const load = vi.fn(async (id: string) => { + expect(id).toBe('/tmp/src/api/index.ts'); + return 'export const marker = true;'; + }); + + const access = createAgnosticModuleAccess({ resolve, load }); + + await expect(access.resolve('./api', '/tmp/src/App.tsx')).resolves.toBe( + '/tmp/src/api/index.ts' + ); + await expect(access.load('/tmp/src/api/index.ts')).resolves.toBe( + 'export const marker = true;' + ); + expect(resolve).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('uses user resolve before native resolve when user resolve hits', async () => { + const nativeResolve = vi.fn(async () => ({ + id: '/tmp/from-native.ts', + external: false, + })); + const userResolve = vi.fn(async () => '/tmp/from-user.ts'); + const access = createRollupLikeModuleAccess( + { resolve: nativeResolve }, + { resolve: userResolve } + ); + + await expect(access.resolve('./api', '/tmp/App.tsx')).resolves.toBe( + '/tmp/from-user.ts' + ); + expect(nativeResolve).not.toHaveBeenCalled(); + }); + + it('uses native resolve after user resolve misses or errors', async () => { + const importer = '/tmp/App.tsx'; + const userResolve = vi + .fn() + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('user failed')); + const nativeResolve = vi + .fn() + .mockResolvedValueOnce({ + id: '/tmp/from-native-after-miss.ts', + external: false, + }) + .mockResolvedValueOnce({ + id: '/tmp/from-native-after-error.ts', + external: false, + }); + const access = createRollupLikeModuleAccess( + { resolve: nativeResolve }, + { resolve: userResolve } + ); + + await expect(access.resolve('./miss', importer)).resolves.toBe( + '/tmp/from-native-after-miss.ts' + ); + await expect(access.resolve('./error', importer)).resolves.toBe( + '/tmp/from-native-after-error.ts' + ); + expect(userResolve).toHaveBeenNthCalledWith(1, './miss', importer); + expect(userResolve).toHaveBeenNthCalledWith(2, './error', importer); + expect(nativeResolve).toHaveBeenCalledTimes(2); + }); + + it('returns null from load when no source loader is configured', async () => { + const access = createAgnosticModuleAccess({ + resolve: async () => '/tmp/src/api/index.ts', + }); + + await expect(access.load('/tmp/src/api/index.ts')).resolves.toBeNull(); + }); + + it('propagates loader errors and retries after a rejection', async () => { + const error = new Error('source loader failed'); + const load = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce('export const marker = true;'); + const access = createAgnosticModuleAccess({ + resolve: async () => '/tmp/src/api/index.ts', + load, + }); + + await expect(access.load('/tmp/src/api/index.ts')).rejects.toThrow(error); + await expect(access.load('/tmp/src/api/index.ts')).resolves.toBe( + 'export const marker = true;' + ); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('caches loaded source text including empty strings', async () => { + const load = vi.fn(async () => ''); + const access = createAgnosticModuleAccess({ + resolve: async () => '/tmp/src/api/index.ts', + load, + }); + + await expect(access.load('/tmp/src/api/index.ts')).resolves.toBe(''); + await expect(access.load('/tmp/src/api/index.ts')).resolves.toBe(''); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('uses the webpack loader resolver', async () => { + const resolve = vi.fn(async (context: string, request: string) => { + expect(context).toBe('/tmp/src'); + expect(request).toBe('@/generated-api'); + return '/tmp/generated-api/index.ts'; + }); + const ctx: BundlerResolveContext = { + getNativeBuildContext() { + return { + framework: 'webpack', + loaderContext: { + getResolve(options?: { dependencyType?: string }) { + expect(options).toEqual({ dependencyType: 'esm' }); + return resolve; + }, + }, + }; + }, + }; + + const access = createWebpackLikeModuleAccess(ctx); + await expect( + access.resolve('@/generated-api', '/tmp/src/app.ts') + ).resolves.toBe('/tmp/generated-api/index.ts'); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it('resolves tsconfig aliases through the rspack resolver', async () => { + const dir = await mktemp(); + const tsconfigPath = path.join(dir, 'tsconfig.json'); + const srcDir = path.join(dir, 'src', 'generated-api'); + await fs.mkdir(srcDir, { recursive: true }); + await fs.writeFile( + tsconfigPath, + JSON.stringify( + { + compilerOptions: { + baseUrl: '.', + paths: { + '@/generated-api': ['src/generated-api/index.ts'], + }, + }, + }, + null, + 2 + ) + ); + await fs.writeFile(path.join(srcDir, 'index.ts'), ''); + + const access = createRspackModuleAccess({ + getNativeBuildContext() { + return { + framework: 'rspack', + compiler: { + options: { + resolve: { + tsConfig: tsconfigPath, + }, + }, + }, + }; + }, + }); + + const expected = await fs.realpath(path.join(srcDir, 'index.ts')); + await expect( + access.resolve('@/generated-api', path.join(dir, 'src', 'app.ts')) + ).resolves.toBe(expected); + }); + + it('loads source through the rollup-like filesystem adapter', async () => { + const sourceFile = '/virtual/api.ts?raw#factory'; + const ctx: BundlerResolveContext = { + resolve: vi.fn(async () => ({ id: sourceFile, external: false })), + fs: { + readFile: vi.fn(async (id: string) => { + expect(id).toBe('/virtual/api.ts'); + return 'export const fromRollupFs = true;'; + }), + }, + }; + + const access = createRollupLikeModuleAccess(ctx); + await expect(access.resolve('./api', '/tmp/App.tsx')).resolves.toBe( + sourceFile + ); + await expect(access.load(sourceFile)).resolves.toBe( + 'export const fromRollupFs = true;' + ); + expect(ctx.fs?.readFile).toHaveBeenCalledTimes(1); + }); + + it('passes the exact rollup-like resolved id to a custom loader', async () => { + const exactResolvedId = '/tmp/api.ts?raw#fragment'; + const ctx: BundlerResolveContext = { + resolve: vi.fn(async () => ({ id: exactResolvedId, external: false })), + }; + const userLoad = vi.fn(async (id: string) => + id === exactResolvedId ? 'export const exact = true;' : null + ); + + const access = createRollupLikeModuleAccess(ctx, { load: userLoad }); + + await expect(access.resolve('./api', '/tmp/App.tsx')).resolves.toBe( + exactResolvedId + ); + await expect(access.load(exactResolvedId)).resolves.toBe( + 'export const exact = true;' + ); + expect(userLoad).toHaveBeenCalledWith(exactResolvedId); + }); + + it('uses the custom rollup-like loader before filesystem fallback', async () => { + const readFile = vi.fn(async () => 'export const fromFs = true;'); + const ctx: BundlerResolveContext = { + fs: { readFile }, + }; + const userLoad = vi.fn(async (id: string) => { + expect(id).toBe('/tmp/api.ts?raw#factory'); + return 'export const fromUserLoader = true;'; + }); + + const access = createRollupLikeModuleAccess(ctx, { + load: userLoad, + }); + + await expect(access.load('/tmp/api.ts?raw#factory')).resolves.toBe( + 'export const fromUserLoader = true;' + ); + expect(userLoad).toHaveBeenCalledTimes(1); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('loads source through webpack loadModule', async () => { + const loadModule = vi.fn( + (request: string, callback: (...args: unknown[]) => void) => { + expect(request).toBe('/tmp/generated-api/index.ts'); + callback( + null, + Buffer.from('export const fromWebpack = true;'), + null, + {} + ); + } + ); + + const access = createWebpackLikeModuleAccess({ + getNativeBuildContext() { + return { + framework: 'webpack', + loaderContext: { + getResolve: () => async () => '/tmp/generated-api/index.ts', + loadModule, + }, + }; + }, + }); + + await expect(access.load('/tmp/generated-api/index.ts')).resolves.toBe( + 'export const fromWebpack = true;' + ); + expect(loadModule).toHaveBeenCalledTimes(1); + }); + + it('uses webpack user load before loadModule', async () => { + const userLoad = vi.fn(async () => 'export const fromUser = true;'); + const loadModule = vi.fn( + (request: string, callback: (...args: unknown[]) => void) => { + expect(request).toBe('/tmp/generated-api/index.ts'); + callback(null, 'export const fromNative = true;', null, {}); + } + ); + + const access = createWebpackLikeModuleAccess( + { + getNativeBuildContext() { + return { + framework: 'webpack', + loaderContext: { + loadModule, + }, + }; + }, + }, + { load: userLoad } + ); + + await expect(access.load('/tmp/generated-api/index.ts')).resolves.toBe( + 'export const fromUser = true;' + ); + expect(loadModule).not.toHaveBeenCalled(); + }); + + it('uses webpack user load before input filesystem fallback', async () => { + const userLoad = vi.fn(async (id: string) => { + expect(id).toBe('/virtual/generated-api/index.ts?raw#factory'); + return 'export const fromUser = true;'; + }); + const loadModule = vi.fn( + (_request: string, callback: (...args: unknown[]) => void) => { + callback(new Error('missing')); + } + ); + const readFile = vi.fn( + (_id: string, callback: (error: Error | null, source?: Buffer) => void) => + callback(null, Buffer.from('export const fromFs = true;')) + ); + + const access = createWebpackLikeModuleAccess( + { + getNativeBuildContext() { + return { + framework: 'webpack', + loaderContext: { + loadModule, + fs: { readFile }, + }, + }; + }, + }, + { load: userLoad } + ); + + await expect( + access.load('/virtual/generated-api/index.ts?raw#factory') + ).resolves.toBe('export const fromUser = true;'); + expect(loadModule).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('loads source through webpack input filesystem when loadModule misses', async () => { + const loadModule = vi.fn( + (_request: string, callback: (...args: unknown[]) => void) => { + callback(new Error('missing')); + } + ); + const readFile = vi.fn( + ( + id: string, + callback: (error: Error | null, source?: Buffer) => void + ) => { + expect(id).toBe('/virtual/generated-api/index.ts'); + callback(null, Buffer.from('export const fromWebpackFs = true;')); + } + ); + + const access = createWebpackLikeModuleAccess({ + getNativeBuildContext() { + return { + framework: 'webpack', + loaderContext: { + loadModule, + fs: { + readFile, + }, + }, + }; + }, + }); + + await expect( + access.load('/virtual/generated-api/index.ts?raw#factory') + ).resolves.toBe('export const fromWebpackFs = true;'); + expect(loadModule).toHaveBeenCalledTimes(1); + expect(readFile).toHaveBeenCalledTimes(1); + }); + + it('loads source through rspack loadModule', async () => { + const loadModule = vi.fn( + (request: string, callback: (...args: unknown[]) => void) => { + expect(request).toBe('/tmp/generated-api/index.ts'); + callback( + null, + Buffer.from('export const fromRspack = true;'), + null, + {} + ); + } + ); + + const access = createRspackModuleAccess({ + getNativeBuildContext() { + return { + framework: 'rspack', + loaderContext: { + loadModule, + }, + }; + }, + }); + + await expect(access.load('/tmp/generated-api/index.ts')).resolves.toBe( + 'export const fromRspack = true;' + ); + expect(loadModule).toHaveBeenCalledTimes(1); + }); + + it('uses rspack user load before loadModule', async () => { + const userLoad = vi.fn(async () => 'export const fromUser = true;'); + const loadModule = vi.fn( + (request: string, callback: (...args: unknown[]) => void) => { + expect(request).toBe('/tmp/generated-api/index.ts'); + callback(null, 'export const fromNative = true;', null, {}); + } + ); + + const access = createRspackModuleAccess( + { + getNativeBuildContext() { + return { + framework: 'rspack', + loaderContext: { + loadModule, + }, + }; + }, + }, + { load: userLoad } + ); + + await expect(access.load('/tmp/generated-api/index.ts')).resolves.toBe( + 'export const fromUser = true;' + ); + expect(loadModule).not.toHaveBeenCalled(); + }); + + it('uses rspack user load before input filesystem fallback', async () => { + const userLoad = vi.fn(async (id: string) => { + expect(id).toBe('/virtual/generated-api/index.ts?raw#factory'); + return 'export const fromUser = true;'; + }); + const loadModule = vi.fn( + (_request: string, callback: (...args: unknown[]) => void) => { + callback(new Error('missing')); + } + ); + const readFile = vi.fn( + (_id: string, callback: (error: Error | null, source?: Buffer) => void) => + callback(null, Buffer.from('export const fromFs = true;')) + ); + + const access = createRspackModuleAccess( + { + getNativeBuildContext() { + return { + framework: 'rspack', + loaderContext: { + loadModule, + fs: { readFile }, + }, + }; + }, + }, + { load: userLoad } + ); + + await expect( + access.load('/virtual/generated-api/index.ts?raw#factory') + ).resolves.toBe('export const fromUser = true;'); + expect(loadModule).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('loads source through rspack input filesystem when loadModule misses', async () => { + const loadModule = vi.fn( + (_request: string, callback: (...args: unknown[]) => void) => { + callback(new Error('missing')); + } + ); + const readFile = vi.fn( + ( + id: string, + callback: (error: Error | null, source?: Buffer) => void + ) => { + expect(id).toBe('/virtual/generated-api/index.ts'); + callback(null, Buffer.from('export const fromRspackFs = true;')); + } + ); + + const access = createRspackModuleAccess({ + getNativeBuildContext() { + return { + framework: 'rspack', + loaderContext: { + loadModule, + fs: { + readFile, + }, + }, + }; + }, + }); + + await expect( + access.load('/virtual/generated-api/index.ts?raw#factory') + ).resolves.toBe('export const fromRspackFs = true;'); + expect(loadModule).toHaveBeenCalledTimes(1); + expect(readFile).toHaveBeenCalledTimes(1); + }); + + it('uses the custom source loader before esbuild file fallback', async () => { + const readFile = vi + .spyOn(fs, 'readFile') + .mockResolvedValue('export const fromFile = true;'); + const userLoad = vi.fn(async (id: string) => { + expect(id).toBe('/tmp/api.ts?raw#hash'); + return 'export const fromUserLoader = true;'; + }); + const access = createEsbuildModuleAccess( + { + getNativeBuildContext() { + return { + framework: 'esbuild', + build: { + resolve: async () => ({ path: '/tmp/api.ts', errors: [] }), + }, + }; + }, + }, + { + load: userLoad, + } + ); + + await expect(access.load('/tmp/api.ts?raw#hash')).resolves.toBe( + 'export const fromUserLoader = true;' + ); + expect(userLoad).toHaveBeenCalledTimes(1); + expect(readFile).not.toHaveBeenCalled(); + readFile.mockRestore(); + }); + + it('strips query and hash only when esbuild file fallback reads locally', async () => { + const readFile = vi + .spyOn(fs, 'readFile') + .mockResolvedValue('export const fromFile = true;'); + const access = createEsbuildModuleAccess({ + getNativeBuildContext() { + return { + framework: 'esbuild', + build: { + resolve: async () => ({ path: '/tmp/api.ts?raw#hash', errors: [] }), + }, + }; + }, + }); + + await expect(access.load('/tmp/api.ts?raw#hash')).resolves.toBe( + 'export const fromFile = true;' + ); + expect(readFile).toHaveBeenCalledWith('/tmp/api.ts', 'utf8'); + readFile.mockRestore(); + }); +}); diff --git a/packages/tree-shaking-plugin/src/lib/resolvers/rollup-like.ts b/packages/tree-shaking-plugin/src/lib/resolvers/rollup-like.ts new file mode 100644 index 000000000..b85e5ca91 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/resolvers/rollup-like.ts @@ -0,0 +1,72 @@ +import type { + BundlerResolveContext, + LoadStrategy, + QraftModuleAccess, + QraftModuleAccessOptions, + ResolveStrategy, +} from './common.js'; +import { + createQraftModuleAccess, + createUserResolverStrategy, + createUserSourceLoaderStrategy, + stripQueryAndHash, +} from './common.js'; + +function createRollupResolveStrategy( + ctx: BundlerResolveContext +): ResolveStrategy { + return { + name: 'native', + async resolve({ specifier, importer }) { + if (typeof ctx.resolve !== 'function') return null; + + try { + const resolved = await ctx.resolve(specifier, importer, { + skipSelf: true, + }); + if (resolved && typeof resolved.id === 'string' && !resolved.external) { + return resolved.id; + } + } catch { + // fall through + } + + return null; + }, + }; +} + +function createRollupFsLoadStrategy(ctx: BundlerResolveContext): LoadStrategy { + return { + name: 'adapter-fallback', + async load({ id }) { + if (typeof ctx.fs?.readFile !== 'function') return null; + + const fileId = stripQueryAndHash(id); + try { + const loaded = await ctx.fs.readFile(fileId, 'utf8'); + return typeof loaded === 'string' + ? loaded + : Buffer.from(loaded).toString('utf8'); + } catch { + return null; + } + }, + }; +} + +export function createRollupLikeModuleAccess( + ctx: BundlerResolveContext, + userAccess: QraftModuleAccessOptions = {} +): QraftModuleAccess { + return createQraftModuleAccess( + [ + createUserResolverStrategy(userAccess.resolve), + createRollupResolveStrategy(ctx), + ], + [ + createUserSourceLoaderStrategy(userAccess.load), + createRollupFsLoadStrategy(ctx), + ] + ); +} diff --git a/packages/tree-shaking-plugin/src/lib/resolvers/rspack.ts b/packages/tree-shaking-plugin/src/lib/resolvers/rspack.ts new file mode 100644 index 000000000..9c8884fa4 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/resolvers/rspack.ts @@ -0,0 +1,199 @@ +import type { TsconfigOptions } from '@rspack/resolver'; +import type { + BundlerResolveContext, + LoadStrategy, + QraftModuleAccess, + QraftModuleAccessOptions, + ResolveStrategy, +} from './common.js'; +import path from 'node:path'; +import { ResolverFactory } from '@rspack/resolver'; +import { + createQraftModuleAccess, + createUserResolverStrategy, + createUserSourceLoaderStrategy, + stripQueryAndHash, +} from './common.js'; + +type RspackResolveOptions = ConstructorParameters[0]; + +type RspackCompilerLike = { + options?: { + resolve?: RspackBundlerResolveOptions; + }; +}; + +type RspackBundlerResolveOptions = RspackResolveOptions & { + tsConfig?: string | TsconfigOptions; +}; + +type RspackInputFileSystem = { + readFile?: ( + path: string, + callback: ( + error: Error | null, + source?: string | Buffer | Uint8Array + ) => void + ) => void; +}; + +function getRspackInputFileSystem( + loaderContext: unknown +): RspackInputFileSystem | null { + if ( + typeof loaderContext !== 'object' || + loaderContext === null || + !('fs' in loaderContext) + ) { + return null; + } + + const { fs } = loaderContext; + if (typeof fs !== 'object' || fs === null || !('readFile' in fs)) { + return null; + } + + const readFile = fs.readFile; + if (typeof readFile !== 'function') { + return null; + } + + return { + readFile(path, callback) { + readFile(path, callback); + }, + } satisfies RspackInputFileSystem; +} + +const resolverCache = new WeakMap(); + +function normalizeRspackResolveOptions( + resolveOptions: RspackBundlerResolveOptions +): RspackResolveOptions { + const { tsConfig, ...rest } = resolveOptions; + + if (rest.tsconfig || !tsConfig) { + return rest; + } + + return { + ...rest, + tsconfig: + typeof tsConfig === 'string' ? { configFile: tsConfig } : tsConfig, + }; +} + +function createRspackResolveStrategy( + ctx: BundlerResolveContext +): ResolveStrategy { + return { + name: 'native', + async resolve({ specifier, importer }) { + const native = ctx.getNativeBuildContext?.(); + if (native?.framework !== 'rspack') return null; + + const compiler = native.compiler as RspackCompilerLike | undefined; + if (!compiler?.options?.resolve) return null; + + const cached = resolverCache.get(compiler); + const normalizedResolveOptions = normalizeRspackResolveOptions( + compiler.options.resolve + ); + const resolver = cached ?? new ResolverFactory(normalizedResolveOptions); + if (!cached) { + resolverCache.set(compiler, resolver); + } + + try { + const resolved = await resolver.async( + path.dirname(importer), + specifier + ); + if (resolved && typeof resolved.path === 'string') { + return resolved.path; + } + } catch { + // fall through + } + + return null; + }, + }; +} + +function createRspackLoadStrategy(ctx: BundlerResolveContext): LoadStrategy { + return { + name: 'native', + async load({ id }) { + const loaderContext = ctx.getNativeBuildContext?.()?.loaderContext; + const loadModule = + typeof loaderContext === 'object' && + loaderContext !== null && + 'loadModule' in loaderContext && + typeof loaderContext.loadModule === 'function' + ? loaderContext.loadModule + : null; + if (typeof loadModule !== 'function') return null; + + return new Promise((resolve) => { + loadModule( + id, + (error: Error | null, source: string | Buffer | null) => { + if (error || source === null || source === undefined) { + resolve(null); + return; + } + + resolve(Buffer.isBuffer(source) ? source.toString('utf8') : source); + } + ); + }); + }, + }; +} + +function createRspackInputFileSystemLoadStrategy( + ctx: BundlerResolveContext +): LoadStrategy { + return { + name: 'adapter-fallback', + async load({ id }) { + const loaderContext = ctx.getNativeBuildContext?.()?.loaderContext; + const inputFileSystem = getRspackInputFileSystem(loaderContext); + if (typeof inputFileSystem?.readFile !== 'function') { + return null; + } + + const fileId = stripQueryAndHash(id); + return new Promise((resolve) => { + inputFileSystem.readFile?.(fileId, (error, source) => { + if (error || source === null || source === undefined) { + resolve(null); + return; + } + + resolve( + Buffer.isBuffer(source) ? source.toString('utf8') : String(source) + ); + }); + }); + }, + }; +} + +export function createRspackModuleAccess( + ctx: BundlerResolveContext, + userAccess: QraftModuleAccessOptions = {} +): QraftModuleAccess { + return createQraftModuleAccess( + [ + createUserResolverStrategy(userAccess.resolve), + createRspackResolveStrategy(ctx), + ], + [ + createUserSourceLoaderStrategy(userAccess.load), + createRspackLoadStrategy(ctx), + createRspackInputFileSystemLoadStrategy(ctx), + ] + ); +} diff --git a/packages/tree-shaking-plugin/src/lib/resolvers/webpack-like.ts b/packages/tree-shaking-plugin/src/lib/resolvers/webpack-like.ts new file mode 100644 index 000000000..ef555d19d --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/resolvers/webpack-like.ts @@ -0,0 +1,191 @@ +import type { + BundlerResolveContext, + LoadStrategy, + QraftModuleAccess, + QraftModuleAccessOptions, + ResolveStrategy, +} from './common.js'; +import path from 'node:path'; +import { + createQraftModuleAccess, + createUserResolverStrategy, + createUserSourceLoaderStrategy, + stripQueryAndHash, +} from './common.js'; + +type WebpackResolveFn = ( + context: string, + request: string +) => Promise | string; + +type WebpackLoadModule = ( + request: string, + callback: ( + error: Error | null, + source: string | Buffer | null, + sourceMap: unknown, + module: unknown + ) => void +) => void; + +type WebpackInputFileSystem = { + readFile?: ( + path: string, + callback: ( + error: Error | null, + source?: string | Buffer | Uint8Array + ) => void + ) => void; +}; + +type WebpackLoaderContextLike = BundlerResolveContext & { + getResolve?: (options?: { dependencyType?: string }) => WebpackResolveFn; + loadModule?: WebpackLoadModule; +}; + +function getObjectProperty(value: unknown, key: string): unknown { + return typeof value === 'object' && value !== null + ? Reflect.get(value, key) + : undefined; +} + +function toWebpackInputFileSystem( + candidate: unknown +): WebpackInputFileSystem | null { + const readFile = getObjectProperty(candidate, 'readFile'); + if (typeof readFile !== 'function') return null; + + return { + readFile(path, callback) { + readFile.call(candidate, path, callback); + }, + } satisfies WebpackInputFileSystem; +} + +function getWebpackInputFileSystem( + loaderContext: unknown +): WebpackInputFileSystem | null { + return ( + toWebpackInputFileSystem(getObjectProperty(loaderContext, 'fs')) ?? + toWebpackInputFileSystem( + getObjectProperty( + getObjectProperty(loaderContext, '_compiler'), + 'inputFileSystem' + ) + ) ?? + toWebpackInputFileSystem( + getObjectProperty( + getObjectProperty(loaderContext, '_compilation'), + 'inputFileSystem' + ) + ) + ); +} + +function createWebpackResolveStrategy( + ctx: WebpackLoaderContextLike +): ResolveStrategy { + return { + name: 'native', + async resolve({ specifier, importer }) { + const loaderContext = ctx.getNativeBuildContext?.()?.loaderContext; + const getResolve = + typeof loaderContext === 'object' && + loaderContext !== null && + 'getResolve' in loaderContext && + typeof loaderContext.getResolve === 'function' + ? loaderContext.getResolve + : ctx.getResolve; + if (typeof getResolve !== 'function') return null; + + try { + const resolve = getResolve({ dependencyType: 'esm' }); + const resolved = await resolve(path.dirname(importer), specifier); + return typeof resolved === 'string' ? resolved : null; + } catch { + // fall through + } + + return null; + }, + }; +} + +function createWebpackLoadStrategy( + ctx: WebpackLoaderContextLike +): LoadStrategy { + return { + name: 'native', + async load({ id }) { + const loaderContext = ctx.getNativeBuildContext?.()?.loaderContext; + const loadModule = + typeof loaderContext === 'object' && + loaderContext !== null && + 'loadModule' in loaderContext && + typeof loaderContext.loadModule === 'function' + ? loaderContext.loadModule + : ctx.loadModule; + if (typeof loadModule !== 'function') return null; + + return new Promise((resolve) => { + loadModule( + id, + (error: Error | null, source: string | Buffer | null) => { + if (error || source === null || source === undefined) { + resolve(null); + return; + } + + resolve(Buffer.isBuffer(source) ? source.toString('utf8') : source); + } + ); + }); + }, + }; +} + +function createWebpackInputFileSystemLoadStrategy( + ctx: WebpackLoaderContextLike +): LoadStrategy { + return { + name: 'adapter-fallback', + async load({ id }) { + const loaderContext = ctx.getNativeBuildContext?.()?.loaderContext; + const inputFileSystem = getWebpackInputFileSystem(loaderContext); + if (typeof inputFileSystem?.readFile !== 'function') { + return null; + } + + const fileId = stripQueryAndHash(id); + return new Promise((resolve) => { + inputFileSystem.readFile?.(fileId, (error, source) => { + if (error || source === null || source === undefined) { + resolve(null); + return; + } + + resolve( + Buffer.isBuffer(source) ? source.toString('utf8') : String(source) + ); + }); + }); + }, + }; +} + +export function createWebpackLikeModuleAccess( + ctx: WebpackLoaderContextLike, + userAccess: QraftModuleAccessOptions = {} +): QraftModuleAccess { + return createQraftModuleAccess( + [ + createUserResolverStrategy(userAccess.resolve), + createWebpackResolveStrategy(ctx), + ], + [ + createUserSourceLoaderStrategy(userAccess.load), + createWebpackLoadStrategy(ctx), + createWebpackInputFileSystemLoadStrategy(ctx), + ] + ); +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/ast-utils.ts b/packages/tree-shaking-plugin/src/lib/transform/ast-utils.ts new file mode 100644 index 000000000..ad6170d42 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/ast-utils.ts @@ -0,0 +1,69 @@ +import type { NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; + +export function findExportReexport(ast: t.File, exportName: string) { + for (const statement of ast.program.body) { + if (!t.isExportNamedDeclaration(statement) || !statement.source) continue; + + for (const specifier of statement.specifiers) { + if (!t.isExportSpecifier(specifier)) continue; + if (!t.isIdentifier(specifier.exported)) continue; + if (specifier.exported.name !== exportName) continue; + if (!t.isIdentifier(specifier.local)) continue; + + return { + source: statement.source.value, + localName: specifier.local.name, + }; + } + } + + return null; +} + +export function getObjectPropertyKey(key: t.ObjectProperty['key']) { + if (t.isIdentifier(key)) return key.name; + if (t.isStringLiteral(key)) return key.value; + return null; +} + +export function getStaticMemberPath( + node: t.Expression | t.V8IntrinsicIdentifier +): string[] | null { + if (t.isCallExpression(node)) return []; + if (t.isIdentifier(node)) return [node.name]; + if (!t.isMemberExpression(node) && !t.isOptionalMemberExpression(node)) { + return null; + } + if (node.computed || !t.isIdentifier(node.property)) return null; + + const objectPath = getStaticMemberPath(node.object as t.Expression); + if (!objectPath) return null; + + return [...objectPath, node.property.name]; +} + +export function getStaticMemberRoot( + node: t.Expression | t.V8IntrinsicIdentifier +): t.Expression | t.V8IntrinsicIdentifier { + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + return getStaticMemberRoot(node.object as t.Expression); + } + return node; +} + +export function getUsageScopeKey(callPath: NodePath) { + const functionParent = callPath.getFunctionParent(); + if (!functionParent) { + return 'program'; + } + + const { node } = functionParent; + return [node.type, node.start ?? -1, node.end ?? -1].join(':'); +} + +export function isExpression( + node: t.Node | t.SpreadElement | t.ArgumentPlaceholder +) { + return t.isExpression(node); +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/callbacks.ts b/packages/tree-shaking-plugin/src/lib/transform/callbacks.ts new file mode 100644 index 000000000..ff5b4748f --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/callbacks.ts @@ -0,0 +1,65 @@ +type CallbackMetadata = { + needsOptions: boolean; + needsReactRuntime: boolean; +}; + +export const supportedCallbacks = { + cancelQueries: { needsOptions: true, needsReactRuntime: false }, + ensureInfiniteQueryData: { needsOptions: true, needsReactRuntime: false }, + ensureQueryData: { needsOptions: true, needsReactRuntime: false }, + fetchInfiniteQuery: { needsOptions: true, needsReactRuntime: false }, + fetchQuery: { needsOptions: true, needsReactRuntime: false }, + getInfiniteQueryData: { needsOptions: true, needsReactRuntime: false }, + getInfiniteQueryKey: { needsOptions: false, needsReactRuntime: false }, + getInfiniteQueryState: { needsOptions: true, needsReactRuntime: false }, + getMutationCache: { needsOptions: true, needsReactRuntime: false }, + getMutationKey: { needsOptions: false, needsReactRuntime: false }, + getQueriesData: { needsOptions: true, needsReactRuntime: false }, + getQueryData: { needsOptions: true, needsReactRuntime: false }, + getQueryKey: { needsOptions: false, needsReactRuntime: false }, + getQueryState: { needsOptions: true, needsReactRuntime: false }, + invalidateQueries: { needsOptions: true, needsReactRuntime: false }, + isFetching: { needsOptions: true, needsReactRuntime: false }, + isMutating: { needsOptions: true, needsReactRuntime: false }, + operationInvokeFn: { needsOptions: true, needsReactRuntime: false }, + prefetchInfiniteQuery: { needsOptions: true, needsReactRuntime: false }, + prefetchQuery: { needsOptions: true, needsReactRuntime: false }, + refetchQueries: { needsOptions: true, needsReactRuntime: false }, + removeQueries: { needsOptions: true, needsReactRuntime: false }, + resetQueries: { needsOptions: true, needsReactRuntime: false }, + setInfiniteQueryData: { needsOptions: true, needsReactRuntime: false }, + setQueriesData: { needsOptions: true, needsReactRuntime: false }, + setQueryData: { needsOptions: true, needsReactRuntime: false }, + useInfiniteQuery: { needsOptions: true, needsReactRuntime: true }, + useIsFetching: { needsOptions: true, needsReactRuntime: true }, + useIsMutating: { needsOptions: true, needsReactRuntime: true }, + useMutation: { needsOptions: true, needsReactRuntime: true }, + useMutationState: { needsOptions: true, needsReactRuntime: true }, + useQueries: { needsOptions: true, needsReactRuntime: true }, + useQuery: { needsOptions: true, needsReactRuntime: true }, + useSuspenseInfiniteQuery: { needsOptions: true, needsReactRuntime: true }, + useSuspenseQueries: { needsOptions: true, needsReactRuntime: true }, + useSuspenseQuery: { needsOptions: true, needsReactRuntime: true }, +} as const satisfies Readonly>; + +type SupportedCallbackName = keyof typeof supportedCallbacks; + +export function isSupportedCallbackName( + callbackName: string +): callbackName is SupportedCallbackName { + return callbackName in supportedCallbacks; +} + +export function callbackNeedsOptions(callbackName: string): boolean { + if (!isSupportedCallbackName(callbackName)) return true; + return supportedCallbacks[callbackName].needsOptions; +} + +export function callbackNeedsReactRuntime(callbackName: string): boolean { + if (!isSupportedCallbackName(callbackName)) return true; + return supportedCallbacks[callbackName].needsReactRuntime; +} + +export function callbackNeedsRuntimeContext(callbackName: string): boolean { + return callbackNeedsOptions(callbackName); +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts b/packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts new file mode 100644 index 000000000..8c0a1b7f2 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/diagnostics.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDiagnosticReporter, + QraftTreeShakeError, +} from './diagnostics.js'; + +describe('tree-shaking diagnostics', () => { + it('throws unresolved transform candidates by default', () => { + const reporter = createDiagnosticReporter({}); + + expect(() => + reporter.unresolved({ + layer: 'generated-metadata', + code: 'generated-services-import-missing', + message: 'Generated factory does not statically import services.', + entrypointKey: 'generatedFactory:createAPIClient:./api', + }) + ).toThrow(QraftTreeShakeError); + }); + + it('warns and continues when diagnostics is warn', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reporter = createDiagnosticReporter({ diagnostics: 'warn' }); + + expect( + reporter.unresolved({ + layer: 'generated-metadata', + code: 'entrypoint-source-unavailable', + message: 'Generated source was unavailable.', + entrypointKey: 'generatedFactory:createAPIClient:./api', + }) + ).toBeNull(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + '[openapi-qraft/tree-shaking-plugin] entrypoint-source-unavailable' + ) + ); + + warn.mockRestore(); + }); + + it('formats module access trace for unresolved warnings', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reporter = createDiagnosticReporter({ diagnostics: 'warn' }); + + try { + reporter.unresolved({ + layer: 'generated-metadata', + code: 'entrypoint-source-unavailable', + message: 'Generated source was unavailable.', + moduleAccessTrace: [ + { + kind: 'resolve', + target: './api', + importer: '/repo/src/App.tsx', + stages: [ + { name: 'native', result: 'miss' }, + { + name: 'user', + result: 'error', + message: 'Cannot resolve generated entrypoint', + }, + ], + }, + { + kind: 'load', + target: '/repo/src/api.ts?raw', + stages: [{ name: 'user', result: 'miss' }], + }, + ], + }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + [ + 'resolve "./api" from "/repo/src/App.tsx":', + ' native: miss', + ' user: error Cannot resolve generated entrypoint', + 'load "/repo/src/api.ts?raw":', + ' user: miss', + ].join('\n') + ) + ); + } finally { + warn.mockRestore(); + } + }); + + it('formats module access trace for unresolved errors', () => { + const reporter = createDiagnosticReporter({ diagnostics: 'error' }); + + expect(() => + reporter.unresolved({ + layer: 'generated-metadata', + code: 'entrypoint-source-unavailable', + message: 'Generated source was unavailable.', + moduleAccessTrace: [ + { + kind: 'load', + target: '/repo/src/api.ts', + stages: [{ name: 'user', result: 'miss' }], + }, + ], + }) + ).toThrow( + /entrypoint-source-unavailable[\s\S]*load "\/repo\/src\/api\.ts":[\s\S]*user: miss/ + ); + }); + + it('stays silent when diagnostics is off', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reporter = createDiagnosticReporter({ diagnostics: 'off' }); + + expect( + reporter.unresolved({ + layer: 'generated-metadata', + code: 'operation-source-unresolved', + message: 'Operation source was not resolved.', + }) + ).toBeNull(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('ordinary skips never throw or warn', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reporter = createDiagnosticReporter({}); + + expect( + reporter.ordinarySkip({ + layer: 'gate', + code: 'source-gate-no-signals', + message: 'Source contains no configured entrypoint signals.', + }) + ).toBeNull(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts b/packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts new file mode 100644 index 000000000..1eb4b1fdf --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/diagnostics.ts @@ -0,0 +1,89 @@ +import type { + QraftModuleAccessTraceEntry, + QraftModuleAccessTraceStage, +} from '../resolvers/common.js'; +import type { + DiagnosticLayer, + DiagnosticReason, + DiagnosticsLevel, + QraftTreeShakeOptions, +} from './types.js'; + +export type { DiagnosticLayer, DiagnosticReason, DiagnosticsLevel }; + +export class QraftTreeShakeError extends Error { + readonly reason: DiagnosticReason; + + constructor(reason: DiagnosticReason) { + super(formatDiagnosticReason(reason)); + this.name = 'QraftTreeShakeError'; + this.reason = reason; + } +} + +export type DiagnosticReporter = { + ordinarySkip(reason: DiagnosticReason): null; + unresolved(reason: DiagnosticReason): null; +}; + +export function createDiagnosticReporter( + options: Pick +): DiagnosticReporter { + const diagnostics = normalizeDiagnosticsLevel(options); + + return { + ordinarySkip() { + return null; + }, + unresolved(reason) { + if (diagnostics === 'error') { + throw new QraftTreeShakeError(reason); + } + + if (diagnostics === 'warn') { + console.warn(formatDiagnosticReason(reason)); + } + + return null; + }, + }; +} + +export function formatDiagnosticReason(reason: DiagnosticReason): string { + const entrypoint = reason.entrypointKey + ? ` entrypoint=${reason.entrypointKey}` + : ''; + const trace = formatModuleAccessTrace(reason.moduleAccessTrace); + + return `[openapi-qraft/tree-shaking-plugin] ${reason.code} (${reason.layer})${entrypoint}: ${reason.message}${trace}`; +} + +function formatModuleAccessTrace( + trace: QraftModuleAccessTraceEntry[] | undefined +): string { + if (!trace?.length) return ''; + + return `\n\n${trace.map(formatModuleAccessTraceEntry).join('\n')}`; +} + +function formatModuleAccessTraceEntry(entry: QraftModuleAccessTraceEntry) { + const header = + entry.kind === 'resolve' + ? `resolve ${JSON.stringify(entry.target)} from ${JSON.stringify(entry.importer)}:` + : `load ${JSON.stringify(entry.target)}:`; + const stages = entry.stages.map((stage) => ` ${formatTraceStage(stage)}`); + + return [header, ...stages].join('\n'); +} + +function formatTraceStage(stage: QraftModuleAccessTraceStage) { + const detail = stage.value ?? stage.message; + return `${stage.name}: ${stage.result}${detail ? ` ${detail}` : ''}`; +} + +function normalizeDiagnosticsLevel( + options: Pick +): DiagnosticsLevel { + if (options.diagnostics) return options.diagnostics; + return 'error'; +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts b/packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts new file mode 100644 index 000000000..b7276d1ed --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/entrypoints.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeEntrypoints } from './entrypoints.js'; + +describe('normalizeEntrypoints', () => { + it('normalizes omitted clientFactory services and context modules to the factory module specifier', () => { + expect( + normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createReactAPIClient', + moduleSpecifier: '@api/my-api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + }) + ).toEqual([ + { + kind: 'generatedFactory', + key: JSON.stringify([ + 'generatedFactory', + 'createReactAPIClient', + '@api/my-api', + '@api/my-api', + './services', + '@api/my-api', + ]), + factory: { + exportName: 'createReactAPIClient', + moduleSpecifier: '@api/my-api', + }, + services: { + moduleSpecifierBase: '@api/my-api', + directory: './services', + }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: '@api/my-api', + }, + }, + ]); + }); + + it('preserves explicit clientFactory services moduleSpecifierBase', () => { + const [entrypoint] = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createReactAPIClient', + moduleSpecifier: '@api/my-api', + }, + services: { + moduleSpecifierBase: '@api/my-public-root', + }, + }, + ], + }); + + expect(entrypoint).toMatchObject({ + kind: 'generatedFactory', + key: JSON.stringify([ + 'generatedFactory', + 'createReactAPIClient', + '@api/my-api', + '@api/my-public-root', + './services', + '', + ]), + services: { + moduleSpecifierBase: '@api/my-public-root', + directory: './services', + }, + }); + }); + + it('preserves explicit clientFactory services directory', () => { + const [entrypoint] = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createReactAPIClient', + moduleSpecifier: '@api/my-api', + }, + services: { + directory: './generated-services', + }, + }, + ], + }); + + expect(entrypoint).toMatchObject({ + kind: 'generatedFactory', + key: JSON.stringify([ + 'generatedFactory', + 'createReactAPIClient', + '@api/my-api', + '@api/my-api', + './generated-services', + '', + ]), + services: { + moduleSpecifierBase: '@api/my-api', + directory: './generated-services', + }, + }); + }); + + it('normalizes omitted precreatedClient services to the factory module specifier', () => { + expect( + normalizeEntrypoints({ + entrypoints: [ + { + kind: 'precreatedClient', + client: { + exportName: 'nodeAPIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: '@api/my-api', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + }) + ).toEqual([ + { + kind: 'precreatedClient', + key: JSON.stringify([ + 'precreatedClient', + 'nodeAPIClient', + './client', + 'createNodeAPIClient', + '@api/my-api', + 'createNodeAPIClientOptions', + './client-options', + '@api/my-api', + './services', + ]), + client: { + exportName: 'nodeAPIClient', + moduleSpecifier: './client', + }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: '@api/my-api', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + services: { + moduleSpecifierBase: '@api/my-api', + directory: './services', + }, + }, + ]); + }); + + it('encodes generatedFactory keys without colon ambiguity', () => { + const [entrypoint] = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: 'npm:@scope/pkg:client', + }, + services: { + moduleSpecifierBase: 'npm:@scope/pkg:services', + directory: './client/services', + }, + reactContext: { + exportName: 'APIClientContext', + moduleSpecifier: 'npm:@scope/pkg:context', + }, + }, + ], + }); + + expect(entrypoint.key).toBe( + JSON.stringify([ + 'generatedFactory', + 'createAPIClient', + 'npm:@scope/pkg:client', + 'npm:@scope/pkg:services', + './client/services', + 'npm:@scope/pkg:context', + ]) + ); + }); +}); diff --git a/packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts b/packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts new file mode 100644 index 000000000..e8c969c75 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/entrypoints.ts @@ -0,0 +1,113 @@ +import type { + ClientEntrypoint, + QraftPrecreatedClientEntrypointConfig, + QraftTreeShakeOptions, + ServicesTarget, +} from './types.js'; + +export const CONVENTIONAL_GENERATED_SERVICES_DIR = './services'; + +export function normalizeEntrypoints( + options: Pick +): ClientEntrypoint[] { + return (options.entrypoints ?? []).map((entrypoint) => { + if (entrypoint.kind === 'clientFactory') { + const services = normalizeServices( + entrypoint.factory.moduleSpecifier, + entrypoint.services + ); + const reactContext = normalizeReactContext( + entrypoint.factory.moduleSpecifier, + entrypoint.reactContext + ); + + return { + kind: 'generatedFactory', + key: composeGeneratedFactoryEntrypointKey( + entrypoint.factory.exportName, + entrypoint.factory.moduleSpecifier, + services.moduleSpecifierBase, + services.directory, + reactContext?.moduleSpecifier ?? '' + ), + factory: entrypoint.factory, + services, + reactContext, + }; + } + + return normalizePrecreatedEntrypoint(entrypoint); + }); +} + +function normalizePrecreatedEntrypoint( + config: QraftPrecreatedClientEntrypointConfig +): ClientEntrypoint { + const services = normalizeServices( + config.factory.moduleSpecifier, + config.services + ); + + return { + kind: 'precreatedClient', + key: composeEntrypointKey([ + 'precreatedClient', + config.client.exportName, + config.client.moduleSpecifier, + config.factory.exportName, + config.factory.moduleSpecifier, + config.optionsFactory.exportName, + config.optionsFactory.moduleSpecifier, + services.moduleSpecifierBase, + services.directory, + ]), + client: config.client, + factory: config.factory, + optionsFactory: config.optionsFactory, + services, + }; +} + +function composeGeneratedFactoryEntrypointKey( + exportName: string, + moduleSpecifier: string, + servicesModuleSpecifierBase: string, + servicesDirectory: string, + contextModuleSpecifier: string +) { + return composeEntrypointKey([ + 'generatedFactory', + exportName, + moduleSpecifier, + servicesModuleSpecifierBase, + servicesDirectory, + contextModuleSpecifier, + ]); +} + +function normalizeServices( + factoryModuleSpecifier: string, + services: ServicesTarget | undefined +) { + return { + moduleSpecifierBase: + services?.moduleSpecifierBase ?? factoryModuleSpecifier, + directory: services?.directory ?? CONVENTIONAL_GENERATED_SERVICES_DIR, + }; +} + +function normalizeReactContext( + factoryModuleSpecifier: string, + reactContext: { exportName: string; moduleSpecifier?: string } | undefined +) { + return reactContext + ? { + exportName: reactContext.exportName, + moduleSpecifier: reactContext.moduleSpecifier ?? factoryModuleSpecifier, + } + : null; +} + +function composeEntrypointKey(parts: string[]) { + return JSON.stringify(parts); +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/exported-declarations.test.ts b/packages/tree-shaking-plugin/src/lib/transform/exported-declarations.test.ts new file mode 100644 index 000000000..7fe4cc1e4 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/exported-declarations.test.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import * as t from '@babel/types'; +import { describe, expect, it } from 'vitest'; +import { + createFixtureModuleAccess, + writeFixtureFiles, +} from '../../__tests__/core/fixtures.js'; +import { readExportedDeclarationChain } from './exported-declarations.js'; + +describe('readExportedDeclarationChain', () => { + it('follows aliased re-export chains', async () => { + const root = await createTempFixture(); + await writeFixtureFiles(root, { + 'src/api/index.ts': ` +export { createAPIClient as myAPIClient } from './barrel'; +`, + 'src/api/barrel.ts': ` +export { createAPIClient } from './createAPIClient'; +`, + 'src/api/createAPIClient.ts': ` +export function createAPIClient() { + return null; +} +`, + }); + + const result = await readExportedDeclarationChain( + path.join(root, 'src/api/index.ts'), + 'myAPIClient', + createFixtureModuleAccess(root) + ); + + expect(result?.sourceFile).toBe( + path.join(root, 'src/api/createAPIClient.ts') + ); + expect(result?.sourceLoadId).toBe( + path.join(root, 'src/api/createAPIClient.ts') + ); + expect(t.isFunctionDeclaration(result?.init)).toBe(true); + }); +}); + +async function createTempFixture() { + return fs.mkdtemp(path.join(os.tmpdir(), 'qraft-exported-declarations-')); +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/exported-declarations.ts b/packages/tree-shaking-plugin/src/lib/transform/exported-declarations.ts new file mode 100644 index 000000000..cd03a7628 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/exported-declarations.ts @@ -0,0 +1,188 @@ +import type { QraftModuleAccess } from '../resolvers/common.js'; +import { parse } from '@babel/parser'; +import * as t from '@babel/types'; +import { findExportReexport } from './ast-utils.js'; +import { normalizeResolvedId } from './path-rendering.js'; + +export type ExportedDeclarationResolution = { + sourceFile: string; + sourceLoadId: string; + ast: t.File; + init: t.Node; + importBindings: Map; +}; + +export async function readExportedDeclarationChain( + startFile: string, + exportName: string, + moduleAccess: QraftModuleAccess, + seen = new Set() +): Promise { + const sourceFile = normalizeResolvedId(startFile); + if (seen.has(sourceFile)) return null; + seen.add(sourceFile); + + const source = await moduleAccess.load(startFile); + if (source === null) { + return null; + } + + const ast = parse(source, { + sourceType: 'module', + plugins: ['typescript'], + }); + const declarations = readTopLevelDeclarations(ast); + const exported = findExportedDeclaration(ast, declarations, exportName); + if (exported) { + return { + sourceFile, + sourceLoadId: startFile, + ast, + init: exported, + importBindings: await readTopLevelImportBindings( + ast, + sourceFile, + moduleAccess.resolve + ), + }; + } + + const reexport = findExportReexport(ast, exportName); + if (!reexport) return null; + + const resolved = await moduleAccess.resolve(reexport.source, sourceFile); + if (!resolved) return null; + const resolvedId = normalizeResolvedId(resolved); + if (resolvedId === sourceFile) return null; + + return readExportedDeclarationChain( + resolved, + reexport.localName, + moduleAccess, + seen + ); +} + +export async function matchesConfiguredBinding( + localName: string, + exportName: string, + expectedResolvedIds: ReadonlySet, + importerId: string, + imports: Map +) { + const imported = imports.get(localName); + if (imported) { + return ( + imported.imported === exportName && + Boolean( + imported.resolvedId && expectedResolvedIds.has(imported.resolvedId) + ) + ); + } + + if (localName !== exportName) return false; + const importerResolvedId = normalizeResolvedId(importerId); + return expectedResolvedIds.has(importerResolvedId); +} + +async function readTopLevelImportBindings( + ast: t.File, + importerId: string, + resolveModule: QraftModuleAccess['resolve'] +) { + const imports = new Map< + string, + { imported: string; resolvedId: string | null } + >(); + + for (const node of ast.program.body) { + if (!t.isImportDeclaration(node)) continue; + const resolved = await resolveModule(node.source.value, importerId); + const resolvedId = resolved ? normalizeResolvedId(resolved) : null; + + for (const specifier of node.specifiers) { + if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) { + const imported = t.isIdentifier(specifier.imported) + ? specifier.imported.name + : specifier.imported.value; + imports.set(specifier.local.name, { + imported, + resolvedId, + }); + } + if (t.isImportDefaultSpecifier(specifier)) { + imports.set(specifier.local.name, { + imported: 'default', + resolvedId, + }); + } + } + } + + return imports; +} + +function readTopLevelDeclarations(ast: t.File) { + const declarations = new Map(); + + for (const statement of ast.program.body) { + const declaration = t.isExportNamedDeclaration(statement) + ? statement.declaration + : statement; + if (t.isFunctionDeclaration(declaration) && declaration.id) { + declarations.set(declaration.id.name, declaration); + continue; + } + if (!t.isVariableDeclaration(declaration)) continue; + for (const item of declaration.declarations) { + if (!t.isIdentifier(item.id)) continue; + declarations.set( + item.id.name, + t.isExpression(item.init) ? item.init : null + ); + } + } + + return declarations; +} + +function findExportedDeclaration( + ast: t.File, + declarations: Map, + exportName: string +): t.Node | null { + for (const statement of ast.program.body) { + if (exportName === 'default' && t.isExportDefaultDeclaration(statement)) { + if (t.isIdentifier(statement.declaration)) { + return declarations.get(statement.declaration.name) ?? null; + } + if (t.isExpression(statement.declaration)) return statement.declaration; + } + + if (!t.isExportNamedDeclaration(statement)) continue; + if (t.isFunctionDeclaration(statement.declaration)) { + if (statement.declaration.id?.name === exportName) { + return statement.declaration; + } + } + if (t.isVariableDeclaration(statement.declaration)) { + for (const declaration of statement.declaration.declarations) { + if (!t.isIdentifier(declaration.id)) continue; + if (declaration.id.name !== exportName) continue; + return t.isExpression(declaration.init) ? declaration.init : null; + } + } + + for (const specifier of statement.specifiers) { + if (!t.isExportSpecifier(specifier)) continue; + const exportedName = t.isIdentifier(specifier.exported) + ? specifier.exported.name + : specifier.exported.value; + if (exportedName !== exportName) continue; + if (!t.isIdentifier(specifier.local)) continue; + return declarations.get(specifier.local.name) ?? null; + } + } + + return null; +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/generated-info-key.ts b/packages/tree-shaking-plugin/src/lib/transform/generated-info-key.ts new file mode 100644 index 000000000..0c3765ad0 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/generated-info-key.ts @@ -0,0 +1,6 @@ +export function getGeneratedInfoKey( + createImportPath: string, + entrypointKey: string +) { + return `${createImportPath}::${entrypointKey}`; +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts b/packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts new file mode 100644 index 000000000..f76d80835 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/generated-metadata.test.ts @@ -0,0 +1,775 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { + contextApiIndexTsBody, + createFixtureModuleAccess, + createPrecreatedFixtureFiles, + getContextFixtureFiles, + PRECREATED_API_INDEX_TS, + writeFixtureFiles, +} from '../../__tests__/core/fixtures.js'; +import { normalizeEntrypoints } from './entrypoints.js'; +import { + createGeneratedMetadataCache, + inspectGeneratedEntrypoints, +} from './generated-metadata.js'; +import { createTransformState } from './state.js'; + +describe('inspectGeneratedEntrypoints', () => { + it('reads generated factory metadata with static services ownership', async () => { + const root = await createTempFixture(); + await writeFixtureFiles( + root, + getContextFixtureFiles('APIClientContext', './APIClientContext', true) + ); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + reactContext: { exportName: 'APIClientContext' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(entrypoints[0]).toMatchObject({ + reactContext: { + moduleSpecifier: './api', + }, + }); + expect(result.reasons).toEqual([]); + expect(metadata?.entrypoint).toEqual(entrypoints[0]); + expect(metadata).toMatchObject({ + entrypoint: entrypoints[0], + factoryFile: path.join(root, 'src/api/index.ts'), + }); + expect(metadata).not.toHaveProperty('reactContext'); + }); + + it('uses the conventional generated services directory instead of inferring it from factory imports', async () => { + const root = await createTempFixture(); + await writeFixtureFiles(root, { + ...getContextFixtureFiles('APIClientContext', './APIClientContext', true), + 'src/api/index.ts': ` +import { qraftReactAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; +import { APIClientContext } from './APIClientContext'; +import { services } from './private-runtime/client-services/index'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(callbacks = defaultCallbacks) { + return qraftReactAPIClient(services, callbacks, APIClientContext); +} +`, + }); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + reactContext: { exportName: 'APIClientContext' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(result.reasons).toEqual([]); + expect(metadata?.entrypoint.services.directory).toBe('./services'); + }); + + it('uses configured services directory for generated service metadata', async () => { + const root = await createTempFixture(); + await writeFixtureFiles(root, { + ...getContextFixtureFiles('APIClientContext', './APIClientContext', true), + 'src/api/services/index.ts': ` +export const services = {} as const; +`, + }); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + services: { + directory: './generated-services', + }, + reactContext: { exportName: 'APIClientContext' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(result.reasons).toEqual([]); + expect(metadata?.entrypoint.services.directory).toBe( + './generated-services' + ); + }); + + it('returns unresolved reason when generated source is unavailable', async () => { + const importerId = '/virtual/src/App.tsx'; + const resolvedFactoryId = '/virtual/src/api/index.ts?client#factory'; + const load = vi.fn(async () => null); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: { + resolve: async () => resolvedFactoryId, + load, + }, + }); + + expect(result.metadataByEntrypointKey.get(entrypoints[0].key)).toBeNull(); + expect(load).toHaveBeenCalledWith(resolvedFactoryId); + expect(result.reasons).toEqual([ + { + layer: 'generated-metadata', + code: 'entrypoint-source-unavailable', + message: 'Generated entrypoint source is unavailable.', + entrypointKey: entrypoints[0].key, + }, + ]); + }); + + it('loads generated factory metadata through exact query and hash ids', async () => { + const importerId = '/virtual/src/App.tsx'; + const factoryId = '/virtual/src/api/index.ts?client#factory'; + const load = vi.fn(async (id: string) => { + if (id === factoryId) { + return contextApiIndexTsBody('APIClientContext'); + } + return null; + }); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + reactContext: { exportName: 'APIClientContext' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: { + resolve: async (specifier) => { + if (specifier === './api') return factoryId; + return null; + }, + load, + }, + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(result.reasons).toEqual([]); + expect(load).toHaveBeenCalledWith(factoryId); + expect(load).toHaveBeenCalledTimes(1); + expect(metadata).toMatchObject({ + factoryFile: '/virtual/src/api/index.ts', + factoryLoadId: factoryId, + }); + }); + + it('loads re-export chains through exact ids while matching cycles canonically', async () => { + const importerId = '/virtual/src/App.tsx'; + const indexId = '/virtual/src/api/index.ts?entry#client'; + const barrelId = '/virtual/src/api/barrel.ts?barrel#client'; + const factoryId = '/virtual/src/api/createAPIClient.ts?factory#client'; + const load = vi.fn(async (id: string) => { + if (id === indexId) return `export { createAPIClient } from './barrel';`; + if (id === barrelId) { + return `export { createAPIClient } from './createAPIClient';`; + } + if (id === factoryId) return contextApiIndexTsBody('APIClientContext'); + return null; + }); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + reactContext: { exportName: 'APIClientContext' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: { + resolve: async (specifier, importer) => { + if (specifier === './api') return indexId; + if ( + specifier === './barrel' && + importer === '/virtual/src/api/index.ts' + ) { + return barrelId; + } + if ( + specifier === './createAPIClient' && + importer === '/virtual/src/api/barrel.ts' + ) { + return factoryId; + } + if ( + specifier === './services/index' && + importer === '/virtual/src/api/createAPIClient.ts' + ) { + throw new Error('services index should not be resolved'); + } + return null; + }, + load, + }, + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(result.reasons).toEqual([]); + expect(load.mock.calls.map(([id]) => id)).toEqual([ + indexId, + barrelId, + factoryId, + ]); + expect(metadata).toMatchObject({ + factoryFile: '/virtual/src/api/createAPIClient.ts', + factoryLoadId: factoryId, + }); + }); + + it('assumes conventional services metadata for qraft factories without static services imports', async () => { + const root = await createTempFixture(); + await writeFixtureFiles(root, { + 'src/api/index.ts': ` +import { qraftReactAPIClient } from '@openapi-qraft/react'; +import { useQuery } from '@openapi-qraft/react/callbacks/index'; +import { APIClientContext } from './APIClientContext'; + +const defaultCallbacks = { useQuery } as const; + +export function createAPIClient(services, callbacks = defaultCallbacks) { + return qraftReactAPIClient(services, callbacks, APIClientContext); +} +`, + 'src/api/APIClientContext.ts': ` +export const APIClientContext = {}; +`, + }); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + reactContext: { exportName: 'APIClientContext' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(result.reasons).toEqual([]); + expect(metadata).toMatchObject({ + entrypoint: entrypoints[0], + factoryFile: path.join(root, 'src/api/index.ts'), + }); + expect(metadata).not.toHaveProperty('reactContext'); + }); + + it('returns missing services reason for non-qraft files that only mention qraft helpers', async () => { + const root = await createTempFixture(); + await writeFixtureFiles(root, { + 'src/api/index.ts': ` +const helperName = 'qraftAPIClient'; + +// qraftReactAPIClient appears in generated factories, but this is not one. +export function createAPIClient() { + return {}; +} +`, + }); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + expect(result.metadataByEntrypointKey.get(entrypoints[0].key)).toBeNull(); + expect(result.reasons).toEqual([ + { + layer: 'generated-metadata', + code: 'generated-services-import-missing', + message: 'Generated entrypoint does not import static services.', + entrypointKey: entrypoints[0].key, + }, + ]); + }); + + it('returns missing services reason for qraft helper names imported from other modules', async () => { + const root = await createTempFixture(); + await writeFixtureFiles(root, { + 'src/api/index.ts': ` +import { qraftAPIClient } from 'other-library'; + +export function createAPIClient(services, callbacks) { + return qraftAPIClient(services, callbacks); +} +`, + }); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + expect(result.metadataByEntrypointKey.get(entrypoints[0].key)).toBeNull(); + expect(result.reasons).toEqual([ + { + layer: 'generated-metadata', + code: 'generated-services-import-missing', + message: 'Generated entrypoint does not import static services.', + entrypointKey: entrypoints[0].key, + }, + ]); + }); + + it('reads generated factory metadata through a re-export chain', async () => { + const root = await createTempFixture(); + await writeFixtureFiles(root, { + ...getContextFixtureFiles('APIClientContext', './APIClientContext', true), + 'src/api/index.ts': ` +export { createAPIClient } from './barrel'; +`, + 'src/api/barrel.ts': ` +export { createAPIClient } from './createAPIClient'; +`, + 'src/api/createAPIClient.ts': ` +import { APIClientContext } from './APIClientContext'; +${contextApiIndexTsBody('APIClientContext')} +`, + }); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + reactContext: { exportName: 'APIClientContext' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(result.reasons).toEqual([]); + expect(metadata).toMatchObject({ + factoryFile: path.join(root, 'src/api/createAPIClient.ts'), + }); + expect(metadata).not.toHaveProperty('reactContext'); + }); + + it('reads generated factory metadata through an aliased re-export chain', async () => { + const root = await createTempFixture(); + await writeFixtureFiles(root, { + ...getContextFixtureFiles('APIClientContext', './APIClientContext', true), + 'src/api/index.ts': ` +export { createAPIClient as myAPIClient } from './barrel'; +`, + 'src/api/barrel.ts': ` +export { createAPIClient } from './createAPIClient'; +`, + 'src/api/createAPIClient.ts': ` +import { APIClientContext } from './APIClientContext'; +${contextApiIndexTsBody('APIClientContext')} +`, + }); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'myAPIClient', moduleSpecifier: './api' }, + reactContext: { exportName: 'APIClientContext' }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(result.reasons).toEqual([]); + expect(metadata).toMatchObject({ + factoryFile: path.join(root, 'src/api/createAPIClient.ts'), + }); + expect(metadata).not.toHaveProperty('reactContext'); + }); + + it('validates precreated clients against configured factory', async () => { + const root = await createTempFixture(); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'precreatedClient', + client: { exportName: 'APIClient', moduleSpecifier: './client' }, + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(result.reasons).toEqual([]); + expect(metadata).toMatchObject({ + entrypoint: entrypoints[0], + factoryFile: path.join(root, 'src/api/index.ts'), + }); + expect(metadata).not.toHaveProperty('optionsFactory'); + expect(metadata).not.toHaveProperty('reactContext'); + }); + + it('validates precreated clients that import the configured factory barrel', async () => { + const root = await createTempFixture(); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles( + ` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + { + 'src/api/index.ts': ` +export { createAPIClient } from './createAPIClient'; +`, + 'src/api/createAPIClient.ts': PRECREATED_API_INDEX_TS, + } + ) + ); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'precreatedClient', + client: { exportName: 'APIClient', moduleSpecifier: './client' }, + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + const metadata = result.metadataByEntrypointKey.get(entrypoints[0].key); + + expect(result.reasons).toEqual([]); + expect(metadata).toMatchObject({ + factoryFile: path.join(root, 'src/api/createAPIClient.ts'), + }); + expect(metadata).not.toHaveProperty('reactContext'); + }); + + it('returns mismatch reason when a precreated client uses another factory', async () => { + const root = await createTempFixture(); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles( + ` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`, + { + 'src/other-api.ts': PRECREATED_API_INDEX_TS, + } + ) + ); + const importerId = path.join(root, 'src/App.tsx'); + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'precreatedClient', + client: { exportName: 'APIClient', moduleSpecifier: './client' }, + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './other-api', + }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + }); + + const result = await inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess: createFixtureModuleAccess(root), + }); + + expect(result.metadataByEntrypointKey.get(entrypoints[0].key)).toBeNull(); + expect(result.reasons).toEqual([ + { + layer: 'generated-metadata', + code: 'precreated-client-factory-mismatch', + message: 'Precreated client export does not match configured factory.', + entrypointKey: entrypoints[0].key, + }, + ]); + }); + + it('seeds legacy planner metadata without reloading inspected factories', async () => { + const root = await createTempFixture(); + await writeFixtureFiles( + root, + getContextFixtureFiles('APIClientContext', './APIClientContext', true) + ); + const importerId = path.join(root, 'src/App.tsx'); + const factoryFile = path.join(root, 'src/api/index.ts'); + const fixtureModuleAccess = createFixtureModuleAccess(root); + let factoryLoadCount = 0; + + const state = await createTransformState( + ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +`, + importerId, + { + entrypoints: [ + { + kind: 'clientFactory', + factory: { + exportName: 'createAPIClient', + moduleSpecifier: './api', + }, + reactContext: { + exportName: 'APIClientContext', + }, + }, + ], + }, + { + resolve: fixtureModuleAccess.resolve, + load: async (id) => { + if (id === factoryFile) factoryLoadCount += 1; + return fixtureModuleAccess.load(id); + }, + } + ); + + expect(state.namedUsages).toHaveLength(1); + expect(factoryLoadCount).toBe(1); + }); + + it('reuses generated factory inspection across importers', async () => { + const root = await createTempFixture(); + await writeFixtureFiles( + root, + getContextFixtureFiles('APIClientContext', './APIClientContext', true) + ); + const factoryFile = path.join(root, 'src/api/index.ts'); + const fixtureModuleAccess = createFixtureModuleAccess(root); + const cache = createGeneratedMetadataCache(); + let factoryLoadCount = 0; + const moduleAccess = { + resolve: fixtureModuleAccess.resolve, + load: async (id: string) => { + if (id === factoryFile) factoryLoadCount += 1; + return fixtureModuleAccess.load(id); + }, + }; + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + reactContext: { exportName: 'APIClientContext' }, + }, + ], + }); + + await inspectGeneratedEntrypoints({ + importerId: path.join(root, 'src/App.tsx'), + entrypoints, + moduleAccess, + cache, + }); + await inspectGeneratedEntrypoints({ + importerId: path.join(root, 'src/Other.tsx'), + entrypoints, + moduleAccess, + cache, + }); + + expect(factoryLoadCount).toBe(1); + }); + + it('reuses precreated client validation and factory inspection across importers', async () => { + const root = await createTempFixture(); + await writeFixtureFiles( + root, + createPrecreatedFixtureFiles(` +import { createAPIClient } from './api'; +import { createAPIClientOptions } from './client-options'; + +export const APIClient = createAPIClient(createAPIClientOptions()); +`) + ); + const clientFile = path.join(root, 'src/client.ts'); + const factoryFile = path.join(root, 'src/api/index.ts'); + const fixtureModuleAccess = createFixtureModuleAccess(root); + const cache = createGeneratedMetadataCache(); + let clientLoadCount = 0; + let factoryLoadCount = 0; + const moduleAccess = { + resolve: fixtureModuleAccess.resolve, + load: async (id: string) => { + if (id === clientFile) clientLoadCount += 1; + if (id === factoryFile) factoryLoadCount += 1; + return fixtureModuleAccess.load(id); + }, + }; + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'precreatedClient', + client: { exportName: 'APIClient', moduleSpecifier: './client' }, + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + optionsFactory: { + exportName: 'createAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + }); + + await inspectGeneratedEntrypoints({ + importerId: path.join(root, 'src/App.tsx'), + entrypoints, + moduleAccess, + cache, + }); + await inspectGeneratedEntrypoints({ + importerId: path.join(root, 'src/Other.tsx'), + entrypoints, + moduleAccess, + cache, + }); + + expect(clientLoadCount).toBe(1); + expect(factoryLoadCount).toBe(1); + }); +}); + +function createTempFixture() { + return fs.mkdtemp(path.join(os.tmpdir(), 'qraft-generated-metadata-')); +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts b/packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts new file mode 100644 index 000000000..2522c589e --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/generated-metadata.ts @@ -0,0 +1,480 @@ +import type { QraftModuleAccess } from '../resolvers/common.js'; +import type { + ClientEntrypoint, + DiagnosticReason, + GeneratedClientMetadata, + GeneratedFactoryEntrypoint, + GeneratedMetadataResult, + PrecreatedClientEntrypoint, +} from './types.js'; +import { parse } from '@babel/parser'; +import * as traverseModule from '@babel/traverse'; +import * as t from '@babel/types'; +import { resolveDefaultExport } from '../interop/resolve-default-export.js'; +import { + getQraftModuleAccessTraceSince, + getQraftModuleAccessTraceSnapshot, +} from '../resolvers/common.js'; +import { findExportReexport } from './ast-utils.js'; +import { + matchesConfiguredBinding, + readExportedDeclarationChain, +} from './exported-declarations.js'; +import { normalizeResolvedId } from './path-rendering.js'; + +const traverse = + resolveDefaultExport<(typeof import('@babel/traverse'))['default']>( + traverseModule + ); + +const QRAFT_REACT_RUNTIME_MODULE = '@openapi-qraft/react'; + +type InspectGeneratedEntrypointsInput = { + importerId: string; + entrypoints: ClientEntrypoint[]; + moduleAccess: QraftModuleAccess; + cache?: GeneratedMetadataCache; +}; + +type MetadataInspection = + | { metadata: GeneratedClientMetadata } + | { reason: DiagnosticReason }; + +type FactoryInspectionOutcome = + | { kind: 'valid'; factoryFile: string; factoryLoadId: string } + | { + kind: 'missingFactoryRuntime'; + factoryFile: string; + factoryLoadId: string; + } + | { kind: 'unresolvedSource' }; + +type PrecreatedClientValidationOutcome = + | { kind: 'valid' } + | { kind: 'factoryMismatch' }; + +export type GeneratedMetadataCache = { + factoryInspectionByKey: Map; + precreatedClientValidationByKey: Map< + string, + PrecreatedClientValidationOutcome + >; + clear(): void; +}; + +export function createGeneratedMetadataCache(): GeneratedMetadataCache { + const factoryInspectionByKey = new Map(); + const precreatedClientValidationByKey = new Map< + string, + PrecreatedClientValidationOutcome + >(); + + return { + factoryInspectionByKey, + precreatedClientValidationByKey, + clear() { + factoryInspectionByKey.clear(); + precreatedClientValidationByKey.clear(); + }, + }; +} + +export async function inspectGeneratedEntrypoints({ + importerId, + entrypoints, + moduleAccess, + cache = createGeneratedMetadataCache(), +}: InspectGeneratedEntrypointsInput): Promise { + const metadataByEntrypointKey = new Map< + string, + GeneratedClientMetadata | null + >(); + const reasons: DiagnosticReason[] = []; + + for (const entrypoint of entrypoints) { + const result = await inspectEntrypoint( + importerId, + entrypoint, + moduleAccess, + cache + ); + + if ('metadata' in result) { + metadataByEntrypointKey.set(entrypoint.key, result.metadata); + } else { + metadataByEntrypointKey.set(entrypoint.key, null); + reasons.push(result.reason); + } + } + + return { metadataByEntrypointKey, reasons }; +} + +async function inspectEntrypoint( + importerId: string, + entrypoint: ClientEntrypoint, + moduleAccess: QraftModuleAccess, + cache: GeneratedMetadataCache +) { + const traceSnapshot = getQraftModuleAccessTraceSnapshot(moduleAccess); + + try { + return entrypoint.kind === 'generatedFactory' + ? await inspectGeneratedFactoryEntrypoint( + importerId, + entrypoint, + moduleAccess, + traceSnapshot, + cache + ) + : await inspectPrecreatedClientEntrypoint( + importerId, + entrypoint, + moduleAccess, + traceSnapshot, + cache + ); + } catch { + return unresolvedSource(entrypoint.key, moduleAccess, traceSnapshot); + } +} + +async function inspectGeneratedFactoryEntrypoint( + importerId: string, + entrypoint: GeneratedFactoryEntrypoint, + moduleAccess: QraftModuleAccess, + traceSnapshot: number, + cache: GeneratedMetadataCache +): Promise { + const resolved = await moduleAccess.resolve( + entrypoint.factory.moduleSpecifier, + importerId + ); + if (!resolved) { + return unresolvedSource(entrypoint.key, moduleAccess, traceSnapshot); + } + + const outcome = await inspectFactoryFileCached({ + cache, + factoryFile: normalizeResolvedId(resolved), + factoryLoadId: resolved, + factoryExportName: entrypoint.factory.exportName, + moduleAccess, + }); + return factoryOutcomeToInspection( + entrypoint, + outcome, + moduleAccess, + traceSnapshot + ); +} + +async function inspectPrecreatedClientEntrypoint( + importerId: string, + entrypoint: PrecreatedClientEntrypoint, + moduleAccess: QraftModuleAccess, + traceSnapshot: number, + cache: GeneratedMetadataCache +): Promise { + const [resolvedClient, resolvedFactory] = await Promise.all([ + moduleAccess.resolve(entrypoint.client.moduleSpecifier, importerId), + moduleAccess.resolve(entrypoint.factory.moduleSpecifier, importerId), + ]); + + if (!resolvedClient || !resolvedFactory) { + return unresolvedSource(entrypoint.key, moduleAccess, traceSnapshot); + } + + const factoryModuleFile = normalizeResolvedId(resolvedFactory); + const factoryOutcome = await inspectFactoryFileCached({ + cache, + factoryFile: factoryModuleFile, + factoryLoadId: resolvedFactory, + factoryExportName: entrypoint.factory.exportName, + moduleAccess, + }); + const expectedFactoryResolvedIds = new Set([factoryModuleFile]); + if (factoryOutcome.kind !== 'unresolvedSource') { + expectedFactoryResolvedIds.add(factoryOutcome.factoryFile); + } + + const clientOutcome = await validatePrecreatedClientCached({ + cache, + moduleAccess, + entrypoint, + clientLoadId: resolvedClient, + expectedFactoryResolvedIds, + }); + if (clientOutcome.kind === 'factoryMismatch') { + return precreatedClientFactoryMismatch(entrypoint.key); + } + + return factoryOutcomeToInspection( + entrypoint, + factoryOutcome, + moduleAccess, + traceSnapshot + ); +} + +async function inspectFactoryFileCached({ + cache, + factoryFile, + factoryLoadId, + factoryExportName, + moduleAccess, +}: { + cache: GeneratedMetadataCache; + factoryFile: string; + factoryLoadId: string; + factoryExportName: string; + moduleAccess: QraftModuleAccess; +}): Promise { + const key = JSON.stringify([factoryLoadId, factoryFile, factoryExportName]); + const cached = cache.factoryInspectionByKey.get(key); + if (cached) return cached; + + // Module loaders can re-enter this transform while an inspection is in + // flight, so cache only settled outcomes instead of sharing pending promises. + const outcome = await inspectFactoryFile({ + factoryFile, + factoryLoadId, + factoryExportName, + moduleAccess, + }); + if (outcome.kind !== 'unresolvedSource') { + cache.factoryInspectionByKey.set(key, outcome); + } + + return outcome; +} + +async function validatePrecreatedClientCached({ + cache, + entrypoint, + clientLoadId, + expectedFactoryResolvedIds, + moduleAccess, +}: { + cache: GeneratedMetadataCache; + entrypoint: PrecreatedClientEntrypoint; + clientLoadId: string; + expectedFactoryResolvedIds: Set; + moduleAccess: QraftModuleAccess; +}): Promise { + const key = JSON.stringify([ + clientLoadId, + entrypoint.client.exportName, + entrypoint.factory.exportName, + [...expectedFactoryResolvedIds].sort(), + ]); + const cached = cache.precreatedClientValidationByKey.get(key); + if (cached) return cached; + + // Keep this cache settled-only for the same loader re-entrancy reason as + // factory inspection caching above. + const valid = await validatePrecreatedClient( + entrypoint, + clientLoadId, + expectedFactoryResolvedIds, + moduleAccess + ); + const outcome = valid + ? ({ kind: 'valid' } satisfies PrecreatedClientValidationOutcome) + : ({ + kind: 'factoryMismatch', + } satisfies PrecreatedClientValidationOutcome); + cache.precreatedClientValidationByKey.set(key, outcome); + + return outcome; +} + +async function inspectFactoryFile({ + factoryFile, + factoryLoadId, + factoryExportName, + moduleAccess, + seenFactoryFiles = new Set(), +}: { + factoryFile: string; + factoryLoadId: string; + factoryExportName: string; + moduleAccess: QraftModuleAccess; + seenFactoryFiles?: Set; +}): Promise { + if (seenFactoryFiles.has(factoryFile)) { + return { kind: 'missingFactoryRuntime', factoryFile, factoryLoadId }; + } + seenFactoryFiles.add(factoryFile); + + const source = await moduleAccess.load(factoryLoadId); + if (source === null) { + return { kind: 'unresolvedSource' }; + } + + const ast = parse(source, { + sourceType: 'module', + plugins: ['typescript'], + }); + + const factoryImports = readGeneratedFactoryImports(ast); + + if (!factoryImports.hasQraftClientCall) { + const reexport = findExportReexport(ast, factoryExportName); + if (reexport) { + const resolved = await moduleAccess.resolve(reexport.source, factoryFile); + if (!resolved) { + return { kind: 'unresolvedSource' }; + } + + const resolvedId = normalizeResolvedId(resolved); + if (resolvedId === factoryFile) { + return { kind: 'missingFactoryRuntime', factoryFile, factoryLoadId }; + } + + return inspectFactoryFile({ + factoryFile: resolvedId, + factoryLoadId: resolved, + factoryExportName: reexport.localName, + moduleAccess, + seenFactoryFiles, + }); + } + + return { kind: 'missingFactoryRuntime', factoryFile, factoryLoadId }; + } + + return { + kind: 'valid', + factoryFile, + factoryLoadId, + }; +} + +function factoryOutcomeToInspection( + entrypoint: ClientEntrypoint, + outcome: FactoryInspectionOutcome, + moduleAccess: QraftModuleAccess, + traceSnapshot: number +): MetadataInspection { + if (outcome.kind === 'unresolvedSource') { + return unresolvedSource(entrypoint.key, moduleAccess, traceSnapshot); + } + if (outcome.kind === 'missingFactoryRuntime') { + return missingServicesImport(entrypoint.key); + } + + return { + metadata: { + entrypoint, + factoryFile: outcome.factoryFile, + factoryLoadId: outcome.factoryLoadId, + }, + }; +} + +function readGeneratedFactoryImports(ast: t.File) { + let hasQraftClientCall = false; + const qraftClientLocalNames = new Set(); + + traverse(ast, { + ImportDeclaration(importPath) { + const sourcePath = importPath.node.source.value; + + for (const specifier of importPath.node.specifiers) { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported) && + t.isIdentifier(specifier.local) + ) { + if ( + sourcePath === QRAFT_REACT_RUNTIME_MODULE && + (specifier.imported.name === 'qraftAPIClient' || + specifier.imported.name === 'qraftReactAPIClient') + ) { + qraftClientLocalNames.add(specifier.local.name); + } + } + } + }, + CallExpression(callPath) { + if (!t.isIdentifier(callPath.node.callee)) return; + if (!qraftClientLocalNames.has(callPath.node.callee.name)) return; + hasQraftClientCall = true; + }, + }); + + return { + hasQraftClientCall, + }; +} + +async function validatePrecreatedClient( + entrypoint: PrecreatedClientEntrypoint, + clientLoadId: string, + factoryResolvedIds: Set, + moduleAccess: QraftModuleAccess +) { + const resolvedExport = await readExportedDeclarationChain( + clientLoadId, + entrypoint.client.exportName, + moduleAccess + ); + if (!resolvedExport) return false; + const { init, importBindings, sourceFile } = resolvedExport; + if (!t.isCallExpression(init)) return false; + if (!t.isIdentifier(init.callee)) return false; + + return matchesConfiguredBinding( + init.callee.name, + entrypoint.factory.exportName, + factoryResolvedIds, + sourceFile, + importBindings + ); +} + +function unresolvedSource( + entrypointKey: string, + moduleAccess: QraftModuleAccess, + traceSnapshot: number +): MetadataInspection { + const moduleAccessTrace = getQraftModuleAccessTraceSince( + moduleAccess, + traceSnapshot + ); + + return { + reason: { + layer: 'generated-metadata', + code: 'entrypoint-source-unavailable', + message: 'Generated entrypoint source is unavailable.', + entrypointKey, + ...(moduleAccessTrace.length > 0 ? { moduleAccessTrace } : {}), + }, + }; +} + +function missingServicesImport(entrypointKey: string): MetadataInspection { + return { + reason: { + layer: 'generated-metadata', + code: 'generated-services-import-missing', + message: 'Generated entrypoint does not import static services.', + entrypointKey, + }, + }; +} + +function precreatedClientFactoryMismatch( + entrypointKey: string +): MetadataInspection { + return { + reason: { + layer: 'generated-metadata', + code: 'precreated-client-factory-mismatch', + message: 'Precreated client export does not match configured factory.', + entrypointKey, + }, + }; +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/mutate.ts b/packages/tree-shaking-plugin/src/lib/transform/mutate.ts new file mode 100644 index 000000000..93ffda155 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/mutate.ts @@ -0,0 +1,1041 @@ +import type { NodePath } from '@babel/traverse'; +import type { + ClientBinding, + CreateImportEntry, + GeneratedClientInfo, + InlineImportRequest, + OperationUsage, + RuntimeLocalNames, + SchemaUsage, + TransformState, +} from './types.js'; +import * as traverseModule from '@babel/traverse'; +import * as t from '@babel/types'; +import { resolveDefaultExport } from '../interop/resolve-default-export.js'; +import { + getStaticMemberPath, + getStaticMemberRoot, + getUsageScopeKey, + isExpression, +} from './ast-utils.js'; +import { + callbackNeedsOptions, + callbackNeedsReactRuntime, +} from './callbacks.js'; +import { getGeneratedInfoKey } from './generated-info-key.js'; + +const traverse = + resolveDefaultExport<(typeof import('@babel/traverse'))['default']>( + traverseModule + ); + +type RuntimeHelperKind = 'api' | 'react'; + +function selectRuntimeHelper( + callbackNames: readonly { callbackName: string }[] +): RuntimeHelperKind { + return callbackNames.some((callback) => + callbackNeedsReactRuntime(callback.callbackName) + ) + ? 'react' + : 'api'; +} + +function selectOptimizedClientRuntimeHelper( + usage: OperationUsage, + callbacks: Array<{ callbackName: string }> +): RuntimeHelperKind { + if (usage.client.runtimeInput.kind !== 'context') return 'api'; + return selectRuntimeHelper(callbacks); +} + +/** + * Apply the collected transform mutations by rewriting call sites, inserting + * imports, emitting optimized clients, and removing declarations that became + * dead after the rewrite. The state owns the parsed Babel AST, and this + * function mutates that AST in place. + * + * @example + * ```ts + * const source = ` + * import { createAPIClient } from './api'; + * + * const api = createAPIClient(); + * + * export function App() { + * api.pets.getPets.useQuery(); + * } + * `; + * + * const state = await createTransformState(source, id, options); + * + * applyTransformMutations(state); + * + * // `state.ast` now contains the rewritten named client call and imports. + * ``` + * + * @example + * ```ts + * const source = ` + * import { client } from './client'; + * + * export function App() { + * client.pets.getPets.useQuery(); + * } + * `; + * + * const state = await createTransformState(source, id, options); + * + * applyTransformMutations(state); + * + * // `state.ast` now contains the rewritten precreated client call. + * ``` + */ +export function applyTransformMutations(state: TransformState): void { + const { runtimeLocalNames } = state; + const usages = [...state.namedUsages]; + const inlineCallbackUsages = state.inlineUsages.filter( + (usage) => usage.kind !== 'schema' + ); + rewriteNamedClientCalls(state.ast, state.clients, state.namedUsages); + rewriteInlineClientCalls( + state.ast, + state.createImports, + runtimeLocalNames, + inlineCallbackUsages + ); + rewriteSchemaAccesses( + state.ast, + state.createImports, + state.clients, + state.schemaUsages + ); + const generatedDeclarations = insertOptimizedClients( + state.ast, + usages, + state.generatedInfoByImport, + { + api: runtimeLocalNames.api, + react: runtimeLocalNames.react, + } + ); + insertImports( + state.ast, + usages, + inlineCallbackUsages, + state.schemaUsages, + state.generatedInfoByImport, + generatedDeclarations, + { + api: runtimeLocalNames.api, + react: runtimeLocalNames.react, + } + ); + removeFullyTransformedClients( + state.ast, + state.clients, + state.transformedReferenceKeys + ); + removeEmptyCreateImports(state.ast, state.configuredFactoryNames); +} + +function rewriteNamedClientCalls( + ast: t.File, + clients: ClientBinding[], + usages: OperationUsage[] +) { + const usageByKey = new Map( + usages.map((usage) => [ + [ + usage.client.clientSourceKey, + usage.client.name, + usage.serviceName, + usage.operationName, + usage.callbackName, + usage.scopeKey, + ].join(':'), + usage, + ]) + ); + + traverse(ast, { + CallExpression(callPath) { + const match = matchClientCall(callPath, clients); + if (!match) return; + + const usage = usageByKey.get( + [ + match.client.clientSourceKey, + match.client.name, + match.serviceName, + match.operationName, + match.callbackName, + getUsageScopeKey(callPath), + ].join(':') + ); + if (!usage) return; + + if (match.callbackName === 'operationInvokeFn') { + callPath.node.callee = t.identifier(usage.localClientName); + return; + } + + const callee = callPath.node.callee as + | t.MemberExpression + | t.OptionalMemberExpression; + callee.object = t.identifier(usage.localClientName); + }, + }); +} + +function rewriteInlineClientCalls( + ast: t.File, + createImports: Map, + runtimeLocalNames: RuntimeLocalNames, + inlineUsages: InlineImportRequest[] +) { + const inlineUsagesByMatchKey = new Map( + inlineUsages.map((usage) => [getInlineUsageMatchKey(usage), usage]) + ); + + traverse(ast, { + CallExpression(callPath) { + const match = matchInlineClientCall(callPath.node.callee, createImports); + if (!match) return; + + const usage = inlineUsagesByMatchKey.get(getInlineUsageMatchKey(match)); + if (!usage) return; + + const args: t.Expression[] = [ + t.identifier(usage.operationImport.localName), + t.objectExpression([ + t.objectProperty( + t.identifier(match.callbackName), + t.identifier(usage.callbackLocalName), + false, + true + ), + ]), + ]; + + if (match.optionsExpression) { + args.push(match.optionsExpression); + } + + const newClientCall = t.callExpression( + t.identifier(runtimeLocalNames.api), + args + ); + + if (match.callbackName === 'operationInvokeFn') { + callPath.node.callee = newClientCall; + } else { + const callee = callPath.node.callee as + | t.MemberExpression + | t.OptionalMemberExpression; + callee.object = newClientCall; + } + }, + }); +} + +function getInlineUsageMatchKey({ + createImportPath, + serviceName, + operationName, + callbackName, +}: Pick< + InlineImportRequest, + 'createImportPath' | 'serviceName' | 'operationName' | 'callbackName' +>) { + return [createImportPath, serviceName, operationName, callbackName].join(':'); +} + +function rewriteSchemaAccesses( + ast: t.File, + createImports: Map, + clients: ClientBinding[], + schemaUsages: SchemaUsage[] +) { + const schemaUsageByKey = new Map( + schemaUsages.map((usage) => [ + [ + usage.sourceKey, + usage.serviceName, + usage.operationName, + usage.scopeKey, + ].join(':'), + usage, + ]) + ); + + traverse(ast, { + MemberExpression(memberPath) { + rewriteSchemaAccess(memberPath); + }, + OptionalMemberExpression(memberPath) { + rewriteSchemaAccess(memberPath); + }, + }); + + function rewriteSchemaAccess( + memberPath: NodePath + ) { + const match = matchSchemaAccess(memberPath, createImports, clients); + if (!match) return; + + const usage = schemaUsageByKey.get( + [ + match.sourceKey, + match.serviceName, + match.operationName, + getUsageScopeKey(memberPath), + ].join(':') + ); + if (!usage) return; + + memberPath.node.object = t.identifier(usage.operationImport.localName); + } +} + +function insertImports( + ast: t.File, + usages: OperationUsage[], + inlineImports: InlineImportRequest[], + schemaUsages: SchemaUsage[], + generatedInfoByImport: Map, + generatedDeclarations: t.VariableDeclaration[], + runtimeLocalNames: RuntimeLocalNames +) { + const body = ast.program.body; + const imported = getExistingImports(ast); + const declarations: t.ImportDeclaration[] = []; + const callbackInlineImports = inlineImports.filter( + (inline) => inline.kind !== 'schema' + ); + const hasScopeSplitContextUsage = hasScopeSplitUsage(usages); + const callbacksByClientScopeKey = new Map< + string, + Array<{ callbackName: string }> + >(); + for (const usage of usages) { + if (usage.client.runtimeInput.kind === 'optionsFactoryCall') continue; + const usageKey = getRuntimeHelperUsageKey(usage); + const callbacks = callbacksByClientScopeKey.get(usageKey) ?? []; + callbacks.push({ callbackName: usage.callbackName }); + callbacksByClientScopeKey.set(usageKey, callbacks); + } + const runtimeHelperKindsByClientScopeKey = new Map< + string, + RuntimeHelperKind + >(); + for (const [usageKey, callbackNames] of callbacksByClientScopeKey) { + const usage = usages.find( + (candidate) => getRuntimeHelperUsageKey(candidate) === usageKey + ); + if (!usage) continue; + runtimeHelperKindsByClientScopeKey.set( + usageKey, + selectOptimizedClientRuntimeHelper(usage, callbackNames) + ); + } + let needsApiRuntimeImport = + usages.some( + (usage) => usage.client.runtimeInput.kind === 'optionsFactoryCall' + ) || hasScopeSplitContextUsage; + let needsReactRuntimeImport = false; + for (const kind of runtimeHelperKindsByClientScopeKey.values()) { + if (kind === 'api') { + needsApiRuntimeImport = true; + } else { + needsReactRuntimeImport = true; + } + } + if (callbackInlineImports.length > 0) { + needsApiRuntimeImport = true; + } + + if (needsApiRuntimeImport) { + addNamedImportDeclaration( + declarations, + imported, + '@openapi-qraft/react', + 'qraftAPIClient', + runtimeLocalNames.api + ); + } + + if (needsReactRuntimeImport) { + addNamedImportDeclaration( + declarations, + imported, + '@openapi-qraft/react', + 'qraftReactAPIClient', + runtimeLocalNames.react + ); + } + + for (const usage of usages) { + const generatedInfo = + usage.client.runtimeInput.kind === 'context' + ? generatedInfoByImport.get( + getGeneratedInfoKey( + usage.client.createImportPath, + usage.client.entrypointKey + ) + ) + : null; + const contextImportPath = generatedInfo?.contextImportPath ?? null; + const contextName = generatedInfo?.contextName ?? null; + const shouldImportContext = + usage.client.runtimeInput.kind === 'context' && + callbackNeedsOptions(usage.callbackName) && + contextName !== null && + contextImportPath !== null && + !hasImportLocalName(ast, contextName); + + if (shouldImportContext && usage.callbackName === 'operationInvokeFn') { + addNamedImportDeclaration( + declarations, + imported, + contextImportPath, + contextName + ); + } + + addNamedImportDeclaration( + declarations, + imported, + `@openapi-qraft/react/callbacks/${usage.callbackName}`, + usage.callbackName, + usage.callbackLocalName + ); + addNamedImportDeclaration( + declarations, + imported, + usage.operationImport.importPath, + usage.operationImport.operationName, + usage.operationImport.localName + ); + + if (shouldImportContext && usage.callbackName !== 'operationInvokeFn') { + addNamedImportDeclaration( + declarations, + imported, + contextImportPath, + contextName + ); + } + + if (usage.client.runtimeInput.kind === 'optionsFactoryCall') { + addNamedImportDeclaration( + declarations, + imported, + usage.client.runtimeInput.target.moduleSpecifier, + usage.client.runtimeInput.target.exportName + ); + } + } + + for (const inline of callbackInlineImports) { + addNamedImportDeclaration( + declarations, + imported, + `@openapi-qraft/react/callbacks/${inline.callbackName}`, + inline.callbackName, + inline.callbackLocalName + ); + addNamedImportDeclaration( + declarations, + imported, + inline.operationImport.importPath, + inline.operationImport.operationName, + inline.operationImport.localName + ); + } + + for (const schema of schemaUsages) { + addNamedImportDeclaration( + declarations, + imported, + schema.operationImport.importPath, + schema.operationImport.operationName, + schema.operationImport.localName + ); + } + + const lastImportIndex = findLastImportIndex(body); + const firstGeneratedDeclarationIndex = findFirstGeneratedDeclarationIndex( + body, + generatedDeclarations + ); + const insertIndex = + firstGeneratedDeclarationIndex === -1 + ? lastImportIndex + 1 + : Math.min(lastImportIndex + 1, firstGeneratedDeclarationIndex); + body.splice(insertIndex, 0, ...declarations); +} + +function addNamedImportDeclaration( + declarations: t.ImportDeclaration[], + imported: Set, + source: string, + importedName: string, + localName = importedName +) { + const key = `${source}:${importedName}:${localName}`; + if (imported.has(key)) return; + imported.add(key); + declarations.push( + t.importDeclaration( + [t.importSpecifier(t.identifier(localName), t.identifier(importedName))], + t.stringLiteral(source) + ) + ); +} + +function getRuntimeHelperUsageKey(usage: OperationUsage) { + return `${usage.localClientName}:${usage.scopeKey}`; +} + +function getExistingImports(ast: t.File) { + const imported = new Set(); + for (const node of ast.program.body) { + if (!t.isImportDeclaration(node)) continue; + for (const specifier of node.specifiers) { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported) + ) { + if (t.isIdentifier(specifier.local)) { + imported.add( + `${node.source.value}:${specifier.imported.name}:${specifier.local.name}` + ); + } + } + } + } + return imported; +} + +function hasImportLocalName(ast: t.File, name: string) { + return ast.program.body.some( + (node) => + t.isImportDeclaration(node) && + node.specifiers.some( + (specifier) => + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.local) && + specifier.local.name === name + ) + ); +} + +function insertOptimizedClients( + ast: t.File, + usages: OperationUsage[], + generatedInfoByImport: Map, + runtimeLocalNames: RuntimeLocalNames +): t.VariableDeclaration[] { + const contextUsages = usages.filter( + (usage) => usage.client.mode.type === 'context' + ); + const explicitOptionsUsages = usages.filter( + (usage) => usage.client.mode.type === 'options' + ); + const precreatedUsages = usages.filter( + (usage) => usage.client.mode.type === 'precreated' + ); + + const precreatedDeclarations = createOptimizedClientDeclarations( + precreatedUsages, + precreatedUsages, + runtimeLocalNames + ); + + const insertedDeclarations: t.VariableDeclaration[] = []; + const contextUsagesByClient = new Map(); + for (const usage of contextUsages) { + const clientUsages = contextUsagesByClient.get(usage.client) ?? []; + clientUsages.push(usage); + contextUsagesByClient.set(usage.client, clientUsages); + } + + const topLevelContextDeclarations: t.VariableDeclaration[] = []; + for (const [client, clientUsages] of contextUsagesByClient) { + const scopeBuckets = groupContextUsagesByScope(clientUsages); + const declarations = scopeBuckets.flatMap((bucket) => + createOptimizedClientDeclarations( + bucket.usages, + bucket.usages, + runtimeLocalNames + ) + ); + const statementPath = client.localInitPath?.parentPath; + if (statementPath?.isVariableDeclaration()) { + if (statementPath.parentPath?.isProgram()) { + topLevelContextDeclarations.push(...dedupeDeclarations(declarations)); + } else { + statementPath.insertAfter(dedupeDeclarations(declarations)); + } + } + } + + const topLevelDeclarations = dedupeDeclarations([ + ...topLevelContextDeclarations, + ...precreatedDeclarations, + ]); + + const usagesByClient = new Map< + ClientBinding, + Map + >(); + for (const usage of explicitOptionsUsages) { + const scopeUsagesByClient = usagesByClient.get(usage.client) ?? new Map(); + const scopeUsages = scopeUsagesByClient.get(usage.scopeKey) ?? []; + scopeUsages.push(usage); + scopeUsagesByClient.set(usage.scopeKey, scopeUsages); + usagesByClient.set(usage.client, scopeUsagesByClient); + } + + for (const [client, scopeUsagesByClient] of usagesByClient) { + for (const clientUsages of scopeUsagesByClient.values()) { + const declarations = createOptimizedClientDeclarations( + clientUsages, + clientUsages, + runtimeLocalNames + ); + const statementPath = client.localInitPath?.parentPath; + if (statementPath?.isVariableDeclaration()) { + const optimizedDeclarations = dedupeDeclarations(declarations); + statementPath.insertAfter(optimizedDeclarations); + insertedDeclarations.push(...optimizedDeclarations); + } + } + } + + const body = ast.program.body; + const lastImportIndex = findLastImportIndex(body); + body.splice(lastImportIndex + 1, 0, ...topLevelDeclarations); + insertedDeclarations.push(...topLevelDeclarations); + + return insertedDeclarations; +} + +function hasScopeSplitUsage(usages: OperationUsage[]) { + const scopeKeysByOperation = new Map>(); + + for (const usage of usages) { + if (usage.client.mode.type === 'precreated') continue; + const key = [ + usage.client.clientSourceKey, + usage.client.name, + usage.serviceName, + usage.operationName, + ].join(':'); + const scopeKeys = scopeKeysByOperation.get(key) ?? new Set(); + scopeKeys.add(usage.scopeKey); + scopeKeysByOperation.set(key, scopeKeys); + } + + return [...scopeKeysByOperation.values()].some( + (scopeKeys) => scopeKeys.size > 1 + ); +} + +type ScopeUsageBucket = { + scopeKey: string; + usages: OperationUsage[]; +}; + +function groupUsagesByScope(usages: OperationUsage[]): ScopeUsageBucket[] { + const buckets = new Map(); + + for (const usage of usages) { + const next = buckets.get(usage.scopeKey) ?? []; + next.push(usage); + buckets.set(usage.scopeKey, next); + } + + return [...buckets.entries()].map(([scopeKey, scopeUsages]) => ({ + scopeKey, + usages: scopeUsages, + })); +} + +function groupContextUsagesByScope( + usages: OperationUsage[] +): ScopeUsageBucket[] { + return groupUsagesByScope(usages); +} + +function createOptimizedClientDeclarations( + declarationsUsages: OperationUsage[], + callbackUsages: OperationUsage[], + runtimeLocalNames: RuntimeLocalNames +) { + return declarationsUsages.map((usage) => { + const callbacks = callbackUsages + .filter((item) => item.localClientName === usage.localClientName) + .map((item) => ({ + callbackName: item.callbackName, + callbackLocalName: item.callbackLocalName, + })) + .filter( + (item, index, all) => + all.findIndex( + (candidate) => candidate.callbackName === item.callbackName + ) === index + ); + + return createOptimizedClientDeclaration( + usage, + callbacks, + runtimeLocalNames + ); + }); +} + +function createOptimizedClientDeclaration( + usage: OperationUsage, + callbacks: Array<{ callbackName: string; callbackLocalName: string }>, + runtimeLocalNames: RuntimeLocalNames +) { + const args: t.Expression[] = [ + t.identifier(usage.operationImport.localName), + t.objectExpression( + callbacks.map((callback) => + t.objectProperty( + t.identifier(callback.callbackName), + t.identifier(callback.callbackLocalName), + false, + true + ) + ) + ), + ]; + + const runtimeHelperKind = selectOptimizedClientRuntimeHelper( + usage, + callbacks + ); + const needsOptions = callbacks.some((callback) => + callbackNeedsOptions(callback.callbackName) + ); + + if (usage.client.runtimeInput.kind === 'context') { + if (needsOptions) { + args.push(t.identifier(usage.client.runtimeInput.context.exportName)); + } + } else if (usage.client.runtimeInput.kind === 'optionsExpression') { + args.push(t.cloneNode(usage.client.runtimeInput.expression, true)); + } else if (usage.client.runtimeInput.kind === 'optionsFactoryCall') { + args.push( + t.callExpression( + t.identifier(usage.client.runtimeInput.target.exportName), + [] + ) + ); + } + + const runtimeImportLocalName = + usage.client.runtimeInput.kind === 'optionsFactoryCall' || + runtimeHelperKind === 'api' + ? runtimeLocalNames.api + : runtimeLocalNames.react; + + return t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(usage.localClientName), + t.callExpression(t.identifier(runtimeImportLocalName), args) + ), + ]); +} + +function dedupeDeclarations(declarations: t.VariableDeclaration[]) { + return declarations.filter((declaration, index, all) => { + const name = (declaration.declarations[0].id as t.Identifier).name; + return ( + all.findIndex( + (item) => (item.declarations[0].id as t.Identifier).name === name + ) === index + ); + }); +} + +function removeFullyTransformedClients( + ast: t.File, + clients: ClientBinding[], + transformedReferenceKeys: Set +) { + for (const client of clients) { + if (!transformedReferenceKeys.has(client.name)) continue; + if (hasIdentifierReference(ast, client.name, client.bindingNode)) continue; + + if (client.mode.type === 'precreated') { + removeImportSpecifier(ast, client.bindingNode); + continue; + } + + const declarationPath = client.localInitPath?.parentPath; + if (!declarationPath?.isVariableDeclaration()) continue; + if (declarationPath.node.declarations.length === 1) { + declarationPath.remove(); + } else if (client.localInitPath) { + client.localInitPath.remove(); + } + } +} + +function removeImportSpecifier(ast: t.File, localNode: t.Node) { + traverse(ast, { + ImportDeclaration(importPath) { + const remainingSpecifiers = importPath.node.specifiers.filter( + (specifier) => specifier.local !== localNode + ); + if (remainingSpecifiers.length === importPath.node.specifiers.length) { + return; + } + if (remainingSpecifiers.length === 0) { + importPath.remove(); + } else { + importPath.node.specifiers = remainingSpecifiers; + } + importPath.stop(); + }, + }); +} + +function hasIdentifierReference( + ast: t.File, + name: string, + declarationId: t.Node +) { + let found = false; + + traverse(ast, { + Identifier(identifierPath) { + if (found) return; + if ( + identifierPath.node !== declarationId && + identifierPath.node.name === name && + identifierPath.isReferencedIdentifier() + ) { + found = true; + } + }, + }); + + return found; +} + +function removeEmptyCreateImports(ast: t.File, factoryNames: Set) { + traverse(ast, { + ImportDeclaration(importPath) { + const remainingSpecifiers = importPath.node.specifiers.filter( + (specifier) => { + if ( + !t.isImportSpecifier(specifier) || + !t.isIdentifier(specifier.local) || + !t.isIdentifier(specifier.imported) || + !factoryNames.has(specifier.imported.name) + ) { + return true; + } + return hasIdentifierReference( + ast, + specifier.local.name, + specifier.local + ); + } + ); + if (remainingSpecifiers.length === 0) { + importPath.remove(); + } else { + importPath.node.specifiers = remainingSpecifiers; + } + }, + }); +} + +function matchClientCall( + callPath: NodePath, + clients: ClientBinding[] +): { + client: ClientBinding; + serviceName: string; + operationName: string; + callbackName: string; +} | null { + const callee = callPath.node.callee; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) { + return null; + } + + const path = getStaticMemberPath(callee); + if (!path) return null; + + const [clientName, serviceName, operationName, callbackName] = + path.length === 3 + ? [path[0], path[1], path[2], 'operationInvokeFn'] + : path.length === 4 + ? path + : []; + + if (!clientName || !serviceName || !operationName || !callbackName) + return null; + const binding = callPath.scope.getBinding(clientName); + const client = clients.find((item) => { + if (item.name !== clientName) return false; + return binding?.identifier === item.bindingNode; + }); + if (!client) return null; + + return { client, serviceName, operationName, callbackName }; +} + +function matchSchemaAccess( + memberPath: NodePath, + createImports: Map, + clients: ClientBinding[] +): { + sourceKey: string; + serviceName: string; + operationName: string; +} | null { + const path = getStaticMemberPath(memberPath.node); + if (!path) return null; + + if (path.length === 4) { + const [clientName, serviceName, operationName, propertyName] = path; + if (propertyName !== 'schema') return null; + + const binding = memberPath.scope.getBinding(clientName); + const client = clients.find((item) => { + if (item.name !== clientName) return false; + return binding?.identifier === item.bindingNode; + }); + if (!client) return null; + + return { + sourceKey: `${client.clientSourceKey}:${client.name}`, + serviceName, + operationName, + }; + } + + if (path.length !== 3) return null; + const [serviceName, operationName, propertyName] = path; + if (propertyName !== 'schema') return null; + + const root = getStaticMemberRoot(memberPath.node); + if (!t.isCallExpression(root)) return null; + if (!t.isIdentifier(root.callee)) return null; + + const createImport = createImports.get(root.callee.name); + if (!createImport) return null; + if (root.arguments.length > 1) return null; + if (root.arguments.length === 1 && !isExpression(root.arguments[0])) { + return null; + } + + return { + sourceKey: createImport.factoryFile, + serviceName, + operationName, + }; +} + +function matchInlineClientCall( + callee: t.Expression | t.V8IntrinsicIdentifier, + createImports: Map +): { + createImportPath: string; + entrypointKey: ClientBinding['entrypointKey']; + optionsExpression: t.Expression | null; + serviceName: string; + operationName: string; + callbackName: string; +} | null { + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) { + return null; + } + + const path = getStaticMemberPath(callee); + if (!path) return null; + + const [serviceName, operationName, callbackName] = + path.length === 2 + ? [path[0], path[1], 'operationInvokeFn'] + : path.length === 3 + ? path + : []; + if (!serviceName || !operationName || !callbackName) return null; + + const root = getStaticMemberRoot(callee); + if (!t.isCallExpression(root)) return null; + if (!t.isIdentifier(root.callee)) return null; + + const createImport = createImports.get(root.callee.name); + if (!createImport) return null; + + if (root.arguments.length === 0) { + if (callbackNeedsOptions(callbackName)) return null; + return { + createImportPath: createImport.factoryFile, + entrypointKey: createImport.entrypointKey, + optionsExpression: null, + serviceName, + operationName, + callbackName, + }; + } + + if (root.arguments.length !== 1) return null; + if (!isExpression(root.arguments[0])) return null; + + return { + createImportPath: createImport.factoryFile, + entrypointKey: createImport.entrypointKey, + optionsExpression: t.cloneNode(root.arguments[0], true), + serviceName, + operationName, + callbackName, + }; +} + +function findLastImportIndex(body: t.Statement[]) { + for (let index = body.length - 1; index >= 0; index -= 1) { + if (t.isImportDeclaration(body[index])) return index; + } + return -1; +} + +function findFirstGeneratedDeclarationIndex( + body: t.Statement[], + generatedDeclarations: t.VariableDeclaration[] +) { + const generatedNames = new Set( + generatedDeclarations.flatMap((declaration) => { + const declaratorId = declaration.declarations[0]?.id; + return t.isIdentifier(declaratorId) ? [declaratorId.name] : []; + }) + ); + + for (let index = 0; index < body.length; index += 1) { + const statement = body[index]; + if ( + !t.isVariableDeclaration(statement) || + statement.declarations.length === 0 + ) { + continue; + } + + const declaratorId = statement.declarations[0].id; + if (t.isIdentifier(declaratorId) && generatedNames.has(declaratorId.name)) { + return index; + } + } + + return -1; +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/path-rendering.test.ts b/packages/tree-shaking-plugin/src/lib/transform/path-rendering.test.ts new file mode 100644 index 000000000..079f50b64 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/path-rendering.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + composeImportPath, + composeResolvedSourceImportPath, + composeServiceOperationImportPath, + normalizeResolvedId, + resolvePrecreatedOptionsImportPath, + resolveRelativeImportPath, + stripIndexSourceExtension, + stripQueryAndHash, + stripSourceExtension, +} from './path-rendering.js'; + +describe('path rendering helpers', () => { + it('drops source extensions and trailing index segments from relative imports', () => { + expect( + composeResolvedSourceImportPath( + '/repo/src/App.tsx', + '/repo/src/api/services/PetsService.ts' + ) + ).toBe('./api/services/PetsService'); + + expect( + composeResolvedSourceImportPath( + '/repo/src/App.tsx', + '/repo/src/api/services/index.ts' + ) + ).toBe('./api/services'); + }); + + it('keeps bare specifiers unchanged when resolving precreated options imports', () => { + expect( + resolvePrecreatedOptionsImportPath( + '/repo/src/App.tsx', + 'react-query', + '/repo/node_modules/react-query/index.js' + ) + ).toBe('react-query'); + }); + + it('renders path-like precreated options imports relative to the importer', () => { + expect( + resolvePrecreatedOptionsImportPath( + '/repo/src/App.tsx', + './client-options', + '/repo/src/client-options/index.ts' + ) + ).toBe('./client-options'); + }); + + it('normalizes resolved ids by removing query and hash suffixes', () => { + expect(normalizeResolvedId('/repo/src/api.ts?query=1#hash')).toBe( + '/repo/src/api.ts' + ); + expect(stripQueryAndHash('/repo/src/api.ts?query=1#hash')).toBe( + '/repo/src/api.ts' + ); + }); + + it('preserves path joining helpers for relative imports', () => { + expect( + composeImportPath('/repo/src/App.tsx', '/repo/src/api/index.ts') + ).toBe('./api/index.ts'); + expect( + resolveRelativeImportPath( + '/repo/src/App.tsx', + '/repo/src/api/index.ts', + './services/PetsService.ts' + ) + ).toBe('./api/services/PetsService.ts'); + }); + + it('strips source extensions and trailing index suffixes independently', () => { + expect(stripSourceExtension('./services/PetsService.ts')).toBe( + './services/PetsService' + ); + expect(stripIndexSourceExtension('./services/index')).toBe('./services'); + }); + + it('composes public service operation import specifiers from generated metadata', () => { + expect( + composeServiceOperationImportPath( + '@api/my-api', + './services', + './PetsService.ts' + ) + ).toBe('@api/my-api/services/PetsService'); + + expect( + composeServiceOperationImportPath( + '@api/my-api/public', + './services', + './PetsService' + ) + ).toBe('@api/my-api/public/services/PetsService'); + + expect( + composeServiceOperationImportPath('./api', './services', './PetsService') + ).toBe('./api/services/PetsService'); + + expect( + composeServiceOperationImportPath( + '@api/my-api', + './services', + './PetsService/index.ts' + ) + ).toBe('@api/my-api/services/PetsService'); + }); +}); diff --git a/packages/tree-shaking-plugin/src/lib/transform/path-rendering.ts b/packages/tree-shaking-plugin/src/lib/transform/path-rendering.ts new file mode 100644 index 000000000..7568b4a09 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/path-rendering.ts @@ -0,0 +1,91 @@ +import { + dirname, + isAbsolute, + normalize, + relative, + resolve, + sep, +} from 'node:path'; + +export function resolveRelativeImportPath( + importerId: string, + baseFile: string, + importPath: string +) { + return importPath.startsWith('.') + ? composeImportPath(importerId, resolve(dirname(baseFile), importPath)) + : importPath; +} + +export function composeImportPath(importerId: string, targetFile: string) { + const relativePath = relative(dirname(importerId), targetFile); + const normalized = relativePath.split(sep).join('/'); + return normalized.startsWith('.') ? normalized : `./${normalized}`; +} + +export function resolvePrecreatedOptionsImportPath( + importerId: string, + configuredModule: string, + resolvedFile: string | null +) { + if (!isPathLikeSpecifier(configuredModule)) return configuredModule; + if (!resolvedFile) return configuredModule; + const emittedPath = composeResolvedSourceImportPath(importerId, resolvedFile); + return emittedPath === configuredModule ? configuredModule : emittedPath; +} + +export function normalizeResolvedId(resolvedId: string) { + const withoutQuery = stripQueryAndHash(resolvedId); + return normalize(withoutQuery); +} + +export function stripQueryAndHash(filePath: string) { + const queryIndex = filePath.search(/[?#]/); + return queryIndex >= 0 ? filePath.slice(0, queryIndex) : filePath; +} + +export function composeResolvedSourceImportPath( + importerId: string, + targetFile: string +) { + const composed = composeImportPath(importerId, targetFile); + return stripIndexSourceExtension(stripSourceExtension(composed)); +} + +export function composeServiceOperationImportPath( + moduleSpecifierBase: string, + servicesDir: string, + serviceImportPath: string +) { + return joinImportPathSegments( + moduleSpecifierBase, + normalizeImportSubpathSegment(servicesDir), + normalizeImportSubpathSegment( + stripIndexSourceExtension(stripSourceExtension(serviceImportPath)) + ) + ); +} + +export function stripSourceExtension(importPath: string) { + return importPath.replace(/\.(?:[cm]?[jt]sx?)$/, ''); +} + +export function stripIndexSourceExtension(importPath: string) { + return importPath.replace(/\/index$/, ''); +} + +function isPathLikeSpecifier(specifier: string) { + return specifier.startsWith('.') || isAbsolute(specifier); +} + +function joinImportPathSegments(...segments: string[]) { + const [firstSegment, ...remainingSegments] = segments; + return [ + firstSegment.replace(/\/+$/, ''), + ...remainingSegments.map(normalizeImportSubpathSegment), + ].join('/'); +} + +function normalizeImportSubpathSegment(segment: string) { + return segment.replace(/^\.?\//, '').replace(/\/+$/, ''); +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/source-gate.test.ts b/packages/tree-shaking-plugin/src/lib/transform/source-gate.test.ts new file mode 100644 index 000000000..b9c6a4e48 --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/source-gate.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeEntrypoints } from './entrypoints.js'; +import { shouldInspectSource } from './source-gate.js'; + +describe('shouldInspectSource', () => { + const entrypoints = normalizeEntrypoints({ + entrypoints: [ + { + kind: 'clientFactory', + factory: { exportName: 'createAPIClient', moduleSpecifier: './api' }, + }, + { + kind: 'precreatedClient', + client: { exportName: 'nodeAPIClient', moduleSpecifier: './client' }, + factory: { + exportName: 'createNodeAPIClient', + moduleSpecifier: './api', + }, + optionsFactory: { + exportName: 'createNodeAPIClientOptions', + moduleSpecifier: './client-options', + }, + }, + ], + }); + + it('skips when no entrypoints are configured', () => { + expect( + shouldInspectSource({ + code: ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +`, + id: '/virtual/src/App.tsx', + entrypoints: [], + }) + ).toBe(false); + }); + + it('skips non-source ids', () => { + expect( + shouldInspectSource({ + code: `createAPIClient().pets.getPets.useQuery()`, + id: '/virtual/src/styles.css', + entrypoints, + include: [/\.[cm]?[jt]sx?$/], + }) + ).toBe(false); + }); + + it('does not apply default source id filters by itself', () => { + expect( + shouldInspectSource({ + code: `createAPIClient().pets.getPets.useQuery()`, + id: '/virtual/src/styles.css', + entrypoints, + }) + ).toBe(true); + }); + + it('uses configured exclude filters for node_modules ids', () => { + expect( + shouldInspectSource({ + code: `createAPIClient().pets.getPets.useQuery()`, + id: '/virtual/node_modules/pkg/index.ts', + entrypoints, + exclude: /node_modules/, + }) + ).toBe(false); + + expect( + shouldInspectSource({ + code: `createAPIClient().pets.getPets.useQuery()`, + id: '/virtual/node_modules/pkg/index.ts', + entrypoints, + }) + ).toBe(true); + }); + + it('requires a configured entrypoint signal', () => { + expect( + shouldInspectSource({ + code: ` +const pets = { + getPets: { + useQuery() {}, + }, +}; + +pets.getPets.useQuery(); +`, + id: '/virtual/src/App.tsx', + entrypoints, + }) + ).toBe(false); + + expect( + shouldInspectSource({ + code: ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +`, + id: '/virtual/src/App.tsx', + entrypoints, + }) + ).toBe(true); + }); + + it('inspects direct operation invocation when an entrypoint signal is present', () => { + expect( + shouldInspectSource({ + code: ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets(); +`, + id: '/virtual/src/App.tsx', + entrypoints, + }) + ).toBe(true); + }); + + it('honors include and exclude filters', () => { + expect( + shouldInspectSource({ + code: ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +`, + id: '/virtual/src/App.tsx', + entrypoints, + include: '/server/', + }) + ).toBe(false); + + expect( + shouldInspectSource({ + code: ` +import { createAPIClient } from './api'; + +const api = createAPIClient(); + +api.pets.getPets.useQuery(); +`, + id: '/virtual/src/App.tsx', + entrypoints, + exclude: ['/virtual/src/', /\.tsx$/], + }) + ).toBe(false); + + expect( + shouldInspectSource({ + code: ` +import { nodeAPIClient } from './client'; + +nodeAPIClient.pets.getPets.fetchQuery(); +`, + id: '/virtual/src/App.ts', + entrypoints, + include: [/src\/App/, /\.ts$/], + exclude: '/dist/', + }) + ).toBe(true); + }); +}); diff --git a/packages/tree-shaking-plugin/src/lib/transform/source-gate.ts b/packages/tree-shaking-plugin/src/lib/transform/source-gate.ts new file mode 100644 index 000000000..11253a8dc --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/source-gate.ts @@ -0,0 +1,56 @@ +import type { ClientEntrypoint, FilterPattern } from './types.js'; + +type ShouldInspectSourceInput = SourceFilterOptions & { + code: string; + id: string; + entrypoints: ClientEntrypoint[]; +}; + +export type SourceFilterOptions = { + include?: FilterPattern; + exclude?: FilterPattern; +}; + +export function shouldInspectSource({ + code, + id, + entrypoints, + include, + exclude, +}: ShouldInspectSourceInput): boolean { + if (entrypoints.length === 0) return false; + if (matchesPattern(id, exclude)) return false; + if (include && !matchesPattern(id, include)) return false; + + return hasEntrypointSignal(code, entrypoints); +} + +function hasEntrypointSignal( + code: string, + entrypoints: ClientEntrypoint[] +): boolean { + return entrypoints.some((entrypoint) => { + const signals = + entrypoint.kind === 'generatedFactory' + ? [entrypoint.factory.exportName, entrypoint.factory.moduleSpecifier] + : [ + entrypoint.client.exportName, + entrypoint.client.moduleSpecifier, + entrypoint.factory.exportName, + entrypoint.factory.moduleSpecifier, + ]; + + return signals.some((signal) => signal.length > 0 && code.includes(signal)); + }); +} +function matchesPattern( + id: string, + pattern: FilterPattern | undefined +): boolean { + if (!pattern) return false; + if (Array.isArray(pattern)) { + return pattern.some((item) => matchesPattern(id, item)); + } + if (typeof pattern === 'string') return id.includes(pattern); + return pattern.test(id); +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/state.ts b/packages/tree-shaking-plugin/src/lib/transform/state.ts new file mode 100644 index 000000000..2043773fd --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/state.ts @@ -0,0 +1,1778 @@ +import type { NodePath, Scope } from '@babel/traverse'; +import type { QraftModuleAccess } from '../resolvers/common.js'; +import type { DiagnosticReporter } from './diagnostics.js'; +import type { GeneratedMetadataCache } from './generated-metadata.js'; +import type { + ClientBinding, + ClientEntrypoint, + CreateImportEntry, + DiagnosticReason, + GeneratedClientInfo, + GeneratedClientMetadata, + GeneratedFactoryEntrypoint, + GeneratedInfoRequest, + InlineImportRequest, + OperationImportInfo, + OperationUsage, + PrecreatedClientEntrypoint, + QraftTreeShakeOptions, + RuntimeLocalNames, + SchemaUsage, + TransformState, +} from './types.js'; +import { parse } from '@babel/parser'; +import * as traverseModule from '@babel/traverse'; +import * as t from '@babel/types'; +import { resolveDefaultExport } from '../interop/resolve-default-export.js'; +import { createTraceableQraftModuleAccess } from '../resolvers/common.js'; +import { + getStaticMemberPath, + getStaticMemberRoot, + getUsageScopeKey, + isExpression, +} from './ast-utils.js'; +import { + callbackNeedsRuntimeContext, + isSupportedCallbackName, +} from './callbacks.js'; +import { createDiagnosticReporter } from './diagnostics.js'; +import { normalizeEntrypoints } from './entrypoints.js'; +import { + matchesConfiguredBinding, + readExportedDeclarationChain, +} from './exported-declarations.js'; +import { getGeneratedInfoKey } from './generated-info-key.js'; +import { inspectGeneratedEntrypoints } from './generated-metadata.js'; +import { + composeServiceOperationImportPath, + normalizeResolvedId, + resolvePrecreatedOptionsImportPath, +} from './path-rendering.js'; + +const traverse = + resolveDefaultExport<(typeof import('@babel/traverse'))['default']>( + traverseModule + ); + +type EntrypointUseSignal = { + key: string; + bindingNode: t.Node; +}; + +/** + * Parse the source, resolve the configured clients, and collect everything the + * mutation phase needs without changing the AST. + * + * The returned state separates the discovered work into concrete buckets: + * - `clients`: bindings for discovered client variables + * - `namedUsages`: matched client method calls that already have a local client + * - `inlineUsages`: inline `createAPIClient(...)` call sites that need rewrite + * - `schemaUsages`: `.schema` accesses that rewrite directly to operations + * + * The state also carries the bookkeeping needed by the mutator to insert + * imports, generate optimized clients, and clean up dead declarations. + * + * @example + * ```ts + * const source = ` + * import { createAPIClient } from './api'; + * + * const api = createAPIClient(); + * + * export function App() { + * api.pets.getPets.useQuery(); + * } + * `; + * + * const state = await createTransformState(source, id, options); + * + * state.clients[0] + * // { + * // name: 'api', + * // mode: { type: 'context' }, + * // ... + * // } + * + * state.namedUsages[0] + * // { + * // client: { name: 'api' }, + * // serviceName: 'pets', + * // operationName: 'getPets', + * // callbackName: 'useQuery', + * // ... + * // } + * ``` + * + * @example + * ```ts + * const source = ` + * import { client } from './client'; + * + * export function App() { + * client.pets.getPets.useQuery(); + * } + * `; + * + * const state = await createTransformState(source, id, options); + * + * state.clients[0] + * // { + * // name: 'client', + * // mode: { type: 'precreated' }, + * // ... + * // } + * + * state.namedUsages[0] + * // { + * // client: { name: 'client' }, + * // serviceName: 'pets', + * // operationName: 'getPets', + * // callbackName: 'useQuery', + * // ... + * // } + * ``` + */ +export async function createTransformState( + code: string, + id: string, + options: QraftTreeShakeOptions, + moduleAccess: QraftModuleAccess, + generatedMetadataCache?: GeneratedMetadataCache +): Promise { + const traceableModuleAccess = createTraceableQraftModuleAccess(moduleAccess); + const resolveModule = traceableModuleAccess.resolve; + const entrypoints = normalizeEntrypoints(options); + const generatedFactoryEntrypoints = entrypoints.filter( + (entrypoint) => entrypoint.kind === 'generatedFactory' + ); + const precreatedEntrypoints = entrypoints.filter( + (entrypoint) => entrypoint.kind === 'precreatedClient' + ); + const diagnostics = createDiagnosticReporter(options); + const generatedMetadata = await inspectGeneratedEntrypoints({ + importerId: id, + entrypoints, + moduleAccess: traceableModuleAccess, + cache: generatedMetadataCache, + }); + const configuredFactoryNames = new Set( + generatedFactoryEntrypoints.map( + (entrypoint) => entrypoint.factory.exportName + ) + ); + + const ast = parse(code, { + sourceType: 'module', + plugins: ['jsx', 'typescript'], + }); + const fileBindingNames = getAllBindingNames(ast); + const programScope = getProgramScope(ast); + if (!programScope) { + return emptyTransformState(ast); + } + const activeProgramScope = programScope; + + const factoryResolvedIds = new Map< + GeneratedFactoryEntrypoint, + string | null + >(); + for (const entrypoint of generatedFactoryEntrypoints) { + const resolved = await resolveFactoryModule( + entrypoint.factory.moduleSpecifier, + id, + resolveModule + ); + factoryResolvedIds.set( + entrypoint, + resolved ? normalizeResolvedId(resolved) : null + ); + } + const precreatedClientResolvedIds = new Map< + PrecreatedClientEntrypoint, + string | null + >(); + for (const precreated of precreatedEntrypoints) { + precreatedClientResolvedIds.set( + precreated, + await resolveFactoryModule( + precreated.client.moduleSpecifier, + id, + resolveModule + ) + ); + } + + const createImports = new Map(); + const factoryImportSignals = new Map(); + const precreatedImportSignals = new Map(); + const generatedInfoByImport = new Map(); + seedGeneratedInfoByImport( + generatedInfoByImport, + generatedMetadata.metadataByEntrypointKey, + id, + entrypoints, + factoryResolvedIds + ); + + for (const node of ast.program.body) { + if (!t.isImportDeclaration(node)) continue; + const source = node.source.value; + let resolvedAbs: string | null | undefined; + let resolvedId: string | null | undefined; + + for (const specifier of node.specifiers) { + if ( + !t.isImportSpecifier(specifier) || + !t.isIdentifier(specifier.imported) || + !t.isIdentifier(specifier.local) + ) { + continue; + } + const importedName = specifier.imported.name; + const matchingEntrypoints = generatedFactoryEntrypoints.filter( + (entrypoint) => entrypoint.factory.exportName === importedName + ); + if (matchingEntrypoints.length === 0) continue; + + if (resolvedAbs === undefined) { + resolvedAbs = (await resolveModule(source, id)) ?? null; + resolvedId = resolvedAbs ? normalizeResolvedId(resolvedAbs) : null; + } + const matchedBySource = matchingEntrypoints.find((entrypoint) => + entrypointModuleMatchesImportSource( + entrypoint.factory.moduleSpecifier, + source, + factoryResolvedIds.get(entrypoint) ?? null, + resolvedId ?? null + ) + ); + let matched: GeneratedFactoryEntrypoint | null = matchedBySource ?? null; + if (!matched && resolvedAbs) { + matched = await matchGeneratedFactoryEntrypointByImport({ + importedName, + importLoadId: resolvedAbs, + importResolvedId: resolvedId ?? normalizeResolvedId(resolvedAbs), + candidates: matchingEntrypoints, + metadataByEntrypointKey: generatedMetadata.metadataByEntrypointKey, + factoryResolvedIds, + moduleAccess: traceableModuleAccess, + }); + } + if (!matched) continue; + + factoryImportSignals.set(specifier.local.name, { + key: matched.key, + bindingNode: specifier.local, + }); + + if (resolvedAbs) { + const createImportPath = resolvedId ?? normalizeResolvedId(resolvedAbs); + const generatedInfo = generatedInfoByEntrypoint( + generatedMetadata.metadataByEntrypointKey, + matched.key, + id + ); + createImports.set(specifier.local.name, { + sourceSpecifier: source, + factoryFile: createImportPath, + factoryLoadId: resolvedAbs, + entrypointKey: matched.key, + entrypoint: matched, + }); + generatedInfoByImport.set( + getGeneratedInfoKey(createImportPath, matched.key), + generatedInfo + ); + } + } + + for (const specifier of node.specifiers) { + if ( + !t.isImportSpecifier(specifier) && + !t.isImportDefaultSpecifier(specifier) + ) { + continue; + } + if (!t.isIdentifier(specifier.local)) { + continue; + } + const importedName = t.isImportDefaultSpecifier(specifier) + ? 'default' + : t.isIdentifier(specifier.imported) + ? specifier.imported.name + : specifier.imported.value; + const importResolvedId = + resolvedId === undefined + ? normalizeOptionalResolvedId(await resolveModule(source, id)) + : resolvedId; + + for (const precreated of precreatedEntrypoints) { + if (precreated.client.exportName !== importedName) continue; + if ( + !entrypointModuleMatchesImportSource( + precreated.client.moduleSpecifier, + source, + precreatedClientResolvedIds.get(precreated) ?? null, + importResolvedId + ) + ) + continue; + + precreatedImportSignals.set(specifier.local.name, { + key: precreated.key, + bindingNode: specifier.local, + }); + } + } + } + + const usedEntrypointKeys = collectUsedEntrypointKeys( + ast, + factoryImportSignals, + precreatedImportSignals + ); + + const clients: ClientBinding[] = []; + clients.push( + ...(await findPrecreatedClients( + ast, + id, + precreatedEntrypoints, + generatedMetadata.metadataByEntrypointKey, + traceableModuleAccess, + activeProgramScope + )) + ); + const operationImports = new Map(); + const importLocalNames = new Map(); + const reservedImportLocalNames = new Set(); + + const reactRuntimeImportLocalName = getOrCreateProgramImportLocalName( + activeProgramScope, + importLocalNames, + reservedImportLocalNames, + '@openapi-qraft/react:qraftReactAPIClient', + 'qraftReactAPIClient', + fileBindingNames + ); + const apiRuntimeImportLocalName = getOrCreateProgramImportLocalName( + activeProgramScope, + importLocalNames, + reservedImportLocalNames, + '@openapi-qraft/react:qraftAPIClient', + 'qraftAPIClient', + fileBindingNames + ); + const runtimeLocalNames = { + api: apiRuntimeImportLocalName, + react: reactRuntimeImportLocalName, + } satisfies RuntimeLocalNames; + + traverse(ast, { + VariableDeclarator(variablePath) { + if ( + variablePath.parentPath.parentPath?.isExportNamedDeclaration() || + variablePath.parentPath.parentPath?.isExportDefaultDeclaration() + ) { + return; + } + + if (!t.isIdentifier(variablePath.node.id)) return; + if (!t.isCallExpression(variablePath.node.init)) return; + if (!t.isIdentifier(variablePath.node.init.callee)) return; + + const createImport = createImports.get( + variablePath.node.init.callee.name + ); + if (!createImport) return; + const createImportPath = createImport.factoryFile; + + const args = variablePath.node.init.arguments; + if (args.length === 0) { + const mode = { type: 'context' } as const; + const generatedInfo = generatedInfoByImport.get( + getGeneratedInfoKey(createImportPath, createImport.entrypoint.key) + ); + const runtimeInput = + generatedInfo?.contextName && generatedInfo.contextImportPath + ? { + kind: 'context' as const, + context: { + exportName: generatedInfo.contextName, + moduleSpecifier: generatedInfo.contextImportPath, + }, + } + : { kind: 'none' as const }; + clients.push({ + name: variablePath.node.id.name, + clientSourceKey: getClientSourceKey( + createImportPath, + createImport.entrypoint.key, + mode + ), + createImportPath, + createImportLoadId: createImport.factoryLoadId, + entrypointKey: createImport.entrypoint.key, + entrypoint: createImport.entrypoint, + bindingNode: variablePath.node.id, + declarationScope: variablePath.parentPath.scope, + runtimeInput, + localInitPath: variablePath, + mode, + }); + return; + } + + if (args.length === 1 && isExpression(args[0])) { + const runtimeInput = { + kind: 'optionsExpression' as const, + expression: t.cloneNode(args[0], true), + }; + const mode = { + type: 'options', + optionsExpression: t.cloneNode(args[0], true), + } as const; + clients.push({ + name: variablePath.node.id.name, + clientSourceKey: getClientSourceKey( + createImportPath, + createImport.entrypoint.key, + mode + ), + createImportPath, + createImportLoadId: createImport.factoryLoadId, + entrypointKey: createImport.entrypoint.key, + entrypoint: createImport.entrypoint, + bindingNode: variablePath.node.id, + declarationScope: variablePath.parentPath.scope, + runtimeInput, + localInitPath: variablePath, + mode, + }); + } + }, + }); + + const usageMap = new Map(); + const inlineImports: InlineImportRequest[] = []; + const schemaUsageMap = new Map(); + const transformedReferenceKeys = new Set(); + const generatedInfoRequests = new Map(); + const localClientNamesByOperation = new Map(); + + for (const client of clients) { + const key = getGeneratedInfoKey( + client.createImportPath, + client.entrypoint.key + ); + if (!generatedInfoRequests.has(key)) { + generatedInfoRequests.set(key, { + createImportPath: client.createImportPath, + createImportLoadId: client.createImportLoadId, + entrypoint: client.entrypoint, + }); + } + } + + traverse(ast, { + CallExpression(callPath) { + const inlineMatch = matchInlineClientCall( + callPath.node.callee, + createImports + ); + if (inlineMatch) { + const key = getGeneratedInfoKey( + inlineMatch.createImportPath, + inlineMatch.entrypoint.key + ); + if (!generatedInfoRequests.has(key)) { + generatedInfoRequests.set(key, { + createImportPath: inlineMatch.createImportPath, + createImportLoadId: inlineMatch.createImportLoadId, + entrypoint: inlineMatch.entrypoint, + }); + } + if (!generatedInfoByImport.has(key)) { + generatedInfoByImport.set(key, null); + } + } + + const match = matchClientCall(callPath, clients); + if (!match) return; + + const generatedInfo = generatedInfoByImport.get( + getGeneratedInfoKey( + match.client.createImportPath, + match.client.entrypoint.key + ) + ); + if (!generatedInfo) + return skipOrdinaryTransformCandidate( + diagnostics, + 'generated-client-unresolved', + 'Generated client was not resolved.' + ); + if ( + match.client.mode.type === 'context' && + match.client.runtimeInput.kind !== 'context' && + callbackNeedsRuntimeContext(match.callbackName) + ) { + return skipUnresolvedTransformCandidate( + diagnostics, + 'context-client-unresolved', + 'Context client was not detected.' + ); + } + + const operationImport = resolveOperationImport( + generatedInfo, + match.serviceName, + match.operationName, + activeProgramScope, + fileBindingNames, + reservedImportLocalNames, + operationImports + ); + if (!operationImport) + return skipUnresolvedTransformCandidate( + diagnostics, + 'operation-import-unresolved', + 'Operation import was not resolved.' + ); + + const callbackLocalName = getOrCreateProgramImportLocalName( + activeProgramScope, + importLocalNames, + reservedImportLocalNames, + `@openapi-qraft/react/callbacks/${match.callbackName}`, + match.callbackName, + fileBindingNames + ); + + const scopeKey = getUsageScopeKey(callPath); + + const operationKey = [ + match.client.clientSourceKey, + match.client.name, + match.serviceName, + match.operationName, + scopeKey, + ].join(':'); + const localClientName = + localClientNamesByOperation.get(operationKey) ?? + (match.client.mode.type === 'precreated' + ? createProgramUniqueName( + activeProgramScope, + composeLocalClientName( + match.client.name, + match.serviceName, + match.operationName + ), + fileBindingNames, + reservedImportLocalNames + ) + : createScopedUniqueName( + match.client.declarationScope, + composeLocalClientName( + match.client.name, + match.serviceName, + match.operationName + ) + )); + localClientNamesByOperation.set(operationKey, localClientName); + + const key = [ + match.client.clientSourceKey, + match.client.name, + match.serviceName, + match.operationName, + match.callbackName, + scopeKey, + ].join(':'); + + const usage = usageMap.get(key) ?? { + client: match.client, + serviceName: match.serviceName, + operationName: match.operationName, + callbackName: match.callbackName, + callbackLocalName, + localClientName, + operationImport, + scopeKey, + }; + usageMap.set(key, usage); + + transformedReferenceKeys.add(match.client.name); + }, + MemberExpression(memberPath) { + registerInlineSchemaRequest(memberPath); + }, + OptionalMemberExpression(memberPath) { + registerInlineSchemaRequest(memberPath); + }, + }); + + assignScopeLocalClientNames( + [...usageMap.values()], + activeProgramScope, + fileBindingNames, + reservedImportLocalNames, + localClientNamesByOperation + ); + + traverse(ast, { + CallExpression(callPath) { + const match = matchInlineClientCall(callPath.node.callee, createImports); + if (!match) return; + + const generatedInfo = generatedInfoByImport.get( + getGeneratedInfoKey(match.createImportPath, match.entrypoint.key) + ); + if (!generatedInfo) + return skipOrdinaryTransformCandidate( + diagnostics, + 'generated-inline-client-unresolved', + 'Generated inline client was not resolved.' + ); + + const operationImport = resolveOperationImport( + generatedInfo, + match.serviceName, + match.operationName, + activeProgramScope, + fileBindingNames, + reservedImportLocalNames, + operationImports + ); + if (!operationImport) + return skipUnresolvedTransformCandidate( + diagnostics, + 'inline-operation-import-unresolved', + 'Inline operation import was not resolved.' + ); + + const callbackLocalName = getOrCreateProgramImportLocalName( + activeProgramScope, + importLocalNames, + reservedImportLocalNames, + `@openapi-qraft/react/callbacks/${match.callbackName}`, + match.callbackName, + fileBindingNames + ); + + inlineImports.push({ + createImportPath: match.createImportPath, + serviceName: match.serviceName, + operationName: match.operationName, + callbackName: match.callbackName, + callbackLocalName, + operationImport, + }); + }, + MemberExpression(memberPath) { + collectSchemaUsage(memberPath); + }, + OptionalMemberExpression(memberPath) { + collectSchemaUsage(memberPath); + }, + }); + + function registerInlineSchemaRequest( + memberPath: NodePath + ) { + const match = matchSchemaAccess(memberPath, createImports, clients); + if (!match || match.kind !== 'inline') return; + + const key = getGeneratedInfoKey( + match.createImportPath, + match.entrypoint.key + ); + if (!generatedInfoRequests.has(key)) { + generatedInfoRequests.set(key, { + createImportPath: match.createImportPath, + createImportLoadId: match.createImportLoadId, + entrypoint: match.entrypoint, + }); + } + if (!generatedInfoByImport.has(key)) { + generatedInfoByImport.set(key, null); + } + } + + function collectSchemaUsage( + memberPath: NodePath + ) { + const match = matchSchemaAccess(memberPath, createImports, clients); + if (!match) return; + + const generatedInfo = + match.kind === 'named' + ? generatedInfoByImport.get( + getGeneratedInfoKey( + match.client.createImportPath, + match.client.entrypoint.key + ) + ) + : generatedInfoByImport.get( + getGeneratedInfoKey(match.createImportPath, match.entrypoint.key) + ); + if (!generatedInfo) + return skipOrdinaryTransformCandidate( + diagnostics, + 'generated-client-unresolved', + 'Generated client was not resolved.' + ); + + const operationImport = resolveOperationImport( + generatedInfo, + match.serviceName, + match.operationName, + activeProgramScope, + fileBindingNames, + reservedImportLocalNames, + operationImports + ); + if (!operationImport) + return skipUnresolvedTransformCandidate( + diagnostics, + 'operation-import-unresolved', + 'Operation import was not resolved.' + ); + + const scopeKey = getUsageScopeKey(memberPath); + const sourceKey = + match.kind === 'named' + ? `${match.client.clientSourceKey}:${match.client.name}` + : match.createImportPath; + const key = [ + sourceKey, + match.serviceName, + match.operationName, + scopeKey, + ].join(':'); + + if (!schemaUsageMap.has(key)) { + schemaUsageMap.set(key, { + client: match.kind === 'named' ? match.client : null, + sourceKey, + serviceName: match.serviceName, + operationName: match.operationName, + operationImport, + scopeKey, + }); + } + + if (match.kind === 'named') { + transformedReferenceKeys.add(match.client.name); + } + } + + if ( + schemaUsageMap.size > 0 && + usageMap.size === 0 && + inlineImports.length === 0 + ) { + const firstSchemaUsage = schemaUsageMap.values().next().value; + if (firstSchemaUsage) { + inlineImports.push({ + createImportPath: firstSchemaUsage.sourceKey, + serviceName: firstSchemaUsage.serviceName, + operationName: firstSchemaUsage.operationName, + callbackName: 'schema', + callbackLocalName: 'schema', + operationImport: firstSchemaUsage.operationImport, + kind: 'schema', + }); + } + } + + reportUsedUnresolvedEntrypoints( + diagnostics, + generatedMetadata.reasons, + usedEntrypointKeys + ); + + return { + ast, + clients, + namedUsages: [...usageMap.values()], + inlineUsages: inlineImports, + schemaUsages: [...schemaUsageMap.values()], + generatedInfoByImport, + generatedInfoRequests, + transformedReferenceKeys, + localClientNamesByOperation, + runtimeLocalNames, + createImports, + configuredFactoryNames, + }; +} + +function collectUsedEntrypointKeys( + ast: t.File, + factoryImportSignals: Map, + precreatedImportSignals: Map +) { + const usedEntrypointKeys = new Set(); + const localClientSignals = new Map(); + + traverse(ast, { + VariableDeclarator(variablePath) { + if ( + variablePath.parentPath.parentPath?.isExportNamedDeclaration() || + variablePath.parentPath.parentPath?.isExportDefaultDeclaration() + ) { + return; + } + if (!t.isIdentifier(variablePath.node.id)) return; + if (!t.isCallExpression(variablePath.node.init)) return; + if (!t.isIdentifier(variablePath.node.init.callee)) return; + if (!isSupportedFactoryCallArity(variablePath.node.init)) return; + + const factorySignal = factoryImportSignals.get( + variablePath.node.init.callee.name + ); + if ( + !factorySignal || + !bindingMatches( + variablePath, + variablePath.node.init.callee.name, + factorySignal + ) + ) { + return; + } + + localClientSignals.set(variablePath.node.id, { + key: factorySignal.key, + bindingNode: variablePath.node.id, + }); + }, + }); + + traverse(ast, { + MemberExpression(memberPath) { + collectMemberEntrypointUse(memberPath); + }, + }); + + return usedEntrypointKeys; + + function collectMemberEntrypointUse( + memberPath: NodePath + ) { + const path = getStaticMemberPath(memberPath.node); + if (!path) return; + + const root = getStaticMemberRoot(memberPath.node); + if (t.isCallExpression(root) && t.isIdentifier(root.callee)) { + if (!isInlineTransformCandidateMemberUse(memberPath, path)) return; + if (!isSupportedFactoryCallArity(root)) return; + + const factorySignal = factoryImportSignals.get(root.callee.name); + if ( + factorySignal && + bindingMatches(memberPath, root.callee.name, factorySignal) && + path.length >= 2 + ) { + usedEntrypointKeys.add(factorySignal.key); + } + return; + } + + const clientName = path[0]; + if (!clientName || path.length < 3) return; + if (!isNamedTransformCandidateMemberUse(memberPath, path)) return; + + const clientBinding = memberPath.scope.getBinding(clientName)?.identifier; + const clientSignal = + (clientBinding ? localClientSignals.get(clientBinding) : undefined) ?? + precreatedImportSignals.get(clientName); + if ( + !clientSignal || + !bindingMatches(memberPath, clientName, clientSignal) + ) { + return; + } + + usedEntrypointKeys.add(clientSignal.key); + } +} + +function isInlineTransformCandidateMemberUse( + memberPath: NodePath, + path: string[] +) { + if (path.length === 3 && path[2] === 'schema') return true; + + if (!isCallCallee(memberPath)) return false; + const callbackName = + path.length === 2 + ? 'operationInvokeFn' + : path.length === 3 + ? path[2] + : null; + + return Boolean(callbackName && isSupportedCallbackName(callbackName)); +} + +function isSupportedFactoryCallArity(call: t.CallExpression) { + if (call.arguments.length === 0) return true; + return call.arguments.length === 1 && isExpression(call.arguments[0]); +} + +function isNamedTransformCandidateMemberUse( + memberPath: NodePath, + path: string[] +) { + if (path.length === 4 && path[3] === 'schema') return true; + + if (!isCallCallee(memberPath)) return false; + const callbackName = + path.length === 3 + ? 'operationInvokeFn' + : path.length === 4 + ? path[3] + : null; + + return Boolean(callbackName && isSupportedCallbackName(callbackName)); +} + +function isCallCallee( + memberPath: NodePath +) { + return ( + memberPath.parentPath.isCallExpression() && + memberPath.parentPath.node.callee === memberPath.node + ); +} + +function bindingMatches( + path: NodePath, + name: string, + signal: EntrypointUseSignal +) { + return path.scope.getBinding(name)?.identifier === signal.bindingNode; +} + +function reportUsedUnresolvedEntrypoints( + diagnostics: DiagnosticReporter, + reasons: DiagnosticReason[], + usedEntrypointKeys: Set +) { + for (const reason of reasons) { + if (!reason.entrypointKey) continue; + if (!usedEntrypointKeys.has(reason.entrypointKey)) continue; + diagnostics.unresolved(reason); + } +} + +function skipUnresolvedTransformCandidate( + diagnostics: DiagnosticReporter, + code: string, + message: string +) { + return diagnostics.unresolved({ + layer: 'usage-collection', + code, + message, + }); +} + +function skipOrdinaryTransformCandidate( + diagnostics: DiagnosticReporter, + code: string, + message: string +) { + return diagnostics.ordinarySkip({ + layer: 'usage-collection', + code, + message, + }); +} + +function entrypointModuleMatchesImportSource( + moduleSpecifier: string, + importSource: string, + configuredResolvedId: string | null, + importResolvedId: string | null +) { + if (configuredResolvedId && importResolvedId) { + return configuredResolvedId === importResolvedId; + } + + return moduleSpecifier === importSource; +} + +async function matchGeneratedFactoryEntrypointByImport({ + importedName, + importLoadId, + importResolvedId, + candidates, + metadataByEntrypointKey, + factoryResolvedIds, + moduleAccess, +}: { + importedName: string; + importLoadId: string; + importResolvedId: string; + candidates: GeneratedFactoryEntrypoint[]; + metadataByEntrypointKey: Map; + factoryResolvedIds: Map; + moduleAccess: QraftModuleAccess; +}) { + const resolvedExport = await readExportedDeclarationChain( + importLoadId, + importedName, + moduleAccess + ); + if (!resolvedExport) return null; + + return ( + candidates.find((candidate) => { + const metadata = metadataByEntrypointKey.get(candidate.key) ?? null; + if (metadata?.factoryFile === resolvedExport.sourceFile) { + return true; + } + + const configuredResolvedId = factoryResolvedIds.get(candidate) ?? null; + return configuredResolvedId === importResolvedId; + }) ?? null + ); +} + +function generatedInfoByEntrypoint( + metadataByEntrypointKey: Map, + entrypointKey: string, + importerId: string +) { + const metadata = metadataByEntrypointKey.get(entrypointKey) ?? null; + return metadata + ? toGeneratedClientInfo(metadata, metadata.entrypoint, importerId) + : null; +} + +async function findPrecreatedClients( + ast: t.File, + importerId: string, + configs: PrecreatedClientEntrypoint[], + metadataByEntrypointKey: Map, + moduleAccess: QraftModuleAccess, + programScope: Scope +): Promise { + if (configs.length === 0) return []; + const resolveModule = moduleAccess.resolve; + + const resolvedConfigs = await Promise.all( + configs.map(async (config) => { + const clientLoadId = + (await resolveModule(config.client.moduleSpecifier, importerId)) ?? + null; + const clientFile = clientLoadId + ? normalizeResolvedId(clientLoadId) + : null; + const factoryModuleLoadId = + (await resolveModule(config.factory.moduleSpecifier, importerId)) ?? + null; + const factoryModuleFile = factoryModuleLoadId + ? normalizeResolvedId(factoryModuleLoadId) + : null; + const factoryExport = factoryModuleLoadId + ? await readExportedDeclarationChain( + factoryModuleLoadId, + config.factory.exportName, + moduleAccess + ) + : null; + const factoryFile = factoryExport?.sourceFile ?? factoryModuleFile; + const factoryLoadId = + factoryExport?.sourceLoadId ?? factoryModuleLoadId ?? factoryFile; + const optionsModule = config.optionsFactory.moduleSpecifier; + const optionsFile = await resolveFactoryModule( + optionsModule, + importerId, + resolveModule + ); + const optionsImportPath = resolvePrecreatedOptionsImportPath( + importerId, + optionsModule, + optionsFile + ); + + return { + config, + clientFile, + clientLoadId, + clientResolvedId: clientFile, + factoryFile, + factoryLoadId, + factoryResolvedId: factoryFile + ? normalizeResolvedId(factoryFile) + : null, + metadata: findPrecreatedMetadata(config, metadataByEntrypointKey), + optionsImportPath, + }; + }) + ); + + const clients: ClientBinding[] = []; + const validated = new Map< + PrecreatedClientEntrypoint, + PrecreatedClientEntrypoint | null + >(); + + for (const node of ast.program.body) { + if (!t.isImportDeclaration(node)) continue; + + const resolvedImport = await resolveModule(node.source.value, importerId); + const resolvedImportId = resolvedImport + ? normalizeResolvedId(resolvedImport) + : null; + if (!resolvedImportId) continue; + + for (const specifier of node.specifiers) { + const match = resolvedConfigs.find((item) => { + if (item.clientResolvedId !== resolvedImportId) return false; + if ( + item.config.client.exportName === 'default' && + t.isImportDefaultSpecifier(specifier) + ) { + return true; + } + return ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported) && + t.isIdentifier(specifier.local) && + specifier.imported.name === item.config.client.exportName + ); + }); + const factoryFile = match?.metadata?.factoryFile ?? match?.factoryFile; + const factoryLoadId = + match?.metadata?.factoryLoadId ?? match?.factoryLoadId; + if (!match?.clientFile || !factoryFile) continue; + if ( + !t.isImportDefaultSpecifier(specifier) && + !t.isImportSpecifier(specifier) + ) { + continue; + } + if (!t.isIdentifier(specifier.local)) continue; + + let validatedConfig = validated.get(match.config) ?? null; + if (!validated.has(match.config)) { + if (match.metadata?.entrypoint.kind === 'precreatedClient') { + validatedConfig = match.metadata.entrypoint; + } else if (match.factoryResolvedId) { + validatedConfig = await validatePrecreatedClientConfig( + match.config, + match.clientLoadId ?? match.clientFile, + match.factoryResolvedId, + moduleAccess + ); + } else { + validatedConfig = null; + } + validated.set(match.config, validatedConfig); + } + if (!validatedConfig) continue; + + const mode = { + type: 'precreated', + optionsImportPath: match.optionsImportPath, + optionsExportName: match.config.optionsFactory.exportName, + } as const; + const runtimeInput = { + kind: 'optionsFactoryCall' as const, + target: { + exportName: match.config.optionsFactory.exportName, + moduleSpecifier: match.optionsImportPath, + }, + }; + + clients.push({ + name: specifier.local.name, + clientSourceKey: getClientSourceKey( + factoryFile, + match.config.key, + mode + ), + createImportPath: factoryFile, + createImportLoadId: factoryLoadId ?? factoryFile, + entrypointKey: validatedConfig.key, + entrypoint: validatedConfig, + bindingNode: specifier.local, + declarationScope: programScope, + runtimeInput, + mode, + }); + } + } + + return clients; +} + +function findPrecreatedMetadata( + config: PrecreatedClientEntrypoint, + metadataByEntrypointKey: Map +) { + return metadataByEntrypointKey.get(config.key) ?? null; +} + +async function validatePrecreatedClientConfig( + config: PrecreatedClientEntrypoint, + clientLoadId: string, + factoryResolvedId: string, + moduleAccess: QraftModuleAccess +): Promise { + const skip = (_reason: string) => null; + + const resolvedExport = await readExportedDeclarationChain( + clientLoadId, + config.client.exportName, + moduleAccess + ); + if (!resolvedExport) return skip('precreated client export was not found'); + const { init, importBindings, sourceFile } = resolvedExport; + if (!t.isCallExpression(init)) { + return skip('precreated client export is not a factory call'); + } + if (!t.isIdentifier(init.callee)) { + return skip('precreated client factory is not an identifier'); + } + + if ( + !(await matchesConfiguredBinding( + init.callee.name, + config.factory.exportName, + new Set([factoryResolvedId]), + sourceFile, + importBindings + )) + ) { + return skip('precreated client factory did not match configuration'); + } + + return config; +} + +function matchClientCall( + callPath: NodePath, + clients: ClientBinding[] +): { + client: ClientBinding; + serviceName: string; + operationName: string; + callbackName: string; +} | null { + const callee = callPath.node.callee; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) { + return null; + } + + const path = getStaticMemberPath(callee); + if (!path) return null; + + const [clientName, serviceName, operationName, callbackName] = + path.length === 3 + ? [path[0], path[1], path[2], 'operationInvokeFn'] + : path.length === 4 + ? path + : []; + + if (!clientName || !serviceName || !operationName || !callbackName) + return null; + if (!isSupportedCallbackName(callbackName)) return null; + + const binding = callPath.scope.getBinding(clientName); + const client = clients.find((item) => { + if (item.name !== clientName) return false; + return binding?.identifier === item.bindingNode; + }); + if (!client) return null; + + return { client, serviceName, operationName, callbackName }; +} + +function matchSchemaAccess( + memberPath: NodePath, + createImports: Map, + clients: ClientBinding[] +): + | { + kind: 'named'; + client: ClientBinding; + serviceName: string; + operationName: string; + } + | { + kind: 'inline'; + createImportPath: string; + createImportLoadId: string; + entrypoint: GeneratedFactoryEntrypoint; + serviceName: string; + operationName: string; + } + | null { + const { node } = memberPath; + const path = getStaticMemberPath(node); + if (!path) return null; + + if (path.length === 4) { + const [clientName, serviceName, operationName, propertyName] = path; + if (propertyName !== 'schema') return null; + + const binding = memberPath.scope.getBinding(clientName); + const client = clients.find((item) => { + if (item.name !== clientName) return false; + return binding?.identifier === item.bindingNode; + }); + if (!client) return null; + + return { kind: 'named', client, serviceName, operationName }; + } + + if (path.length !== 3) return null; + const [serviceName, operationName, propertyName] = path; + if (propertyName !== 'schema') return null; + + const root = getStaticMemberRoot(node); + if (!t.isCallExpression(root)) return null; + if (!t.isIdentifier(root.callee)) return null; + + const createImport = createImports.get(root.callee.name); + if (!createImport) return null; + if (root.arguments.length > 1) return null; + if (root.arguments.length === 1 && !isExpression(root.arguments[0])) { + return null; + } + + return { + kind: 'inline', + createImportPath: createImport.factoryFile, + createImportLoadId: createImport.factoryLoadId, + entrypoint: createImport.entrypoint, + serviceName, + operationName, + }; +} + +function matchInlineClientCall( + callee: t.Expression | t.V8IntrinsicIdentifier, + createImports: Map +): { + createImportPath: string; + createImportLoadId: string; + entrypoint: GeneratedFactoryEntrypoint; + optionsExpression: t.Expression | null; + serviceName: string; + operationName: string; + callbackName: string; +} | null { + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) { + return null; + } + + const path = getStaticMemberPath(callee); + if (!path) return null; + + const [serviceName, operationName, callbackName] = + path.length === 2 + ? [path[0], path[1], 'operationInvokeFn'] + : path.length === 3 + ? path + : []; + if (!serviceName || !operationName || !callbackName) return null; + if (!isSupportedCallbackName(callbackName)) return null; + + const root = getStaticMemberRoot(callee); + if (!t.isCallExpression(root)) return null; + if (!t.isIdentifier(root.callee)) return null; + + const createImport = createImports.get(root.callee.name); + if (!createImport) return null; + + if (root.arguments.length === 0) { + if (callbackNeedsRuntimeContext(callbackName)) return null; + return { + createImportPath: createImport.factoryFile, + createImportLoadId: createImport.factoryLoadId, + entrypoint: createImport.entrypoint, + optionsExpression: null, + serviceName, + operationName, + callbackName, + }; + } + + if (root.arguments.length !== 1) return null; + if (!isExpression(root.arguments[0])) return null; + + return { + createImportPath: createImport.factoryFile, + createImportLoadId: createImport.factoryLoadId, + entrypoint: createImport.entrypoint, + optionsExpression: t.cloneNode(root.arguments[0], true), + serviceName, + operationName, + callbackName, + }; +} + +function resolveOperationImport( + generatedInfo: GeneratedClientInfo, + serviceName: string, + operationName: string, + programScope: Scope, + fileBindingNames: Set, + reservedImportLocalNames: Set, + operationImports: Map +): OperationImportInfo | null { + const key = [ + generatedInfo.clientFile, + generatedInfo.servicesModuleSpecifierBase, + serviceName, + operationName, + ].join(':'); + const cached = operationImports.get(key); + if (cached) return cached; + + const serviceImportPath = `./${serviceNameToFileBase(serviceName)}`; + const resolved = { + importPath: composeServiceOperationImportPath( + generatedInfo.servicesModuleSpecifierBase, + generatedInfo.servicesDir, + serviceImportPath + ), + operationName, + localName: createProgramUniqueName( + programScope, + operationName, + fileBindingNames, + reservedImportLocalNames + ), + }; + reservedImportLocalNames.add(resolved.localName); + operationImports.set(key, resolved); + return resolved; +} + +function seedGeneratedInfoByImport( + generatedInfoByImport: Map, + metadataByEntrypointKey: Map, + importerId: string, + entrypoints: ClientEntrypoint[], + factoryResolvedIds: Map +) { + for (const entrypoint of entrypoints) { + const metadata = metadataByEntrypointKey.get(entrypoint.key) ?? null; + const generatedInfo = metadata + ? toGeneratedClientInfo(metadata, entrypoint, importerId) + : null; + const sourceIds = new Set(); + + if (entrypoint.kind === 'generatedFactory') { + const configuredResolvedId = factoryResolvedIds.get(entrypoint) ?? null; + if (configuredResolvedId) { + sourceIds.add(configuredResolvedId); + } + } + if (metadata) { + sourceIds.add(metadata.factoryFile); + } + + for (const sourceId of sourceIds) { + generatedInfoByImport.set( + getGeneratedInfoKey(sourceId, entrypoint.key), + generatedInfo + ); + } + } +} + +function toGeneratedClientInfo( + metadata: GeneratedClientMetadata, + entrypoint: ClientEntrypoint, + importerId: string +): GeneratedClientInfo { + return { + importerId, + clientFile: metadata.factoryFile, + servicesModuleSpecifierBase: + metadata.entrypoint.services.moduleSpecifierBase, + servicesDir: metadata.entrypoint.services.directory, + contextImportPath: resolveMetadataContextImportPath(metadata, entrypoint), + contextName: + entrypoint.kind === 'generatedFactory' + ? (entrypoint.reactContext?.exportName ?? null) + : null, + }; +} + +function resolveMetadataContextImportPath( + metadata: GeneratedClientMetadata, + entrypoint: ClientEntrypoint +) { + if (entrypoint.kind !== 'generatedFactory') return null; + if (metadata.entrypoint.kind !== 'generatedFactory') return null; + return metadata.entrypoint.reactContext?.moduleSpecifier ?? null; +} + +function serviceNameToFileBase(serviceName: string) { + return `${serviceName[0]?.toUpperCase() ?? ''}${serviceName.slice(1)}Service`; +} + +function composeLocalClientName( + clientName: string, + serviceName: string, + operationName: string +) { + return `${clientName}_${serviceName}_${operationName}`; +} + +function getAllBindingNames(ast: t.File) { + const names = new Set(); + + traverse(ast, { + Scopable(path) { + for (const name of Object.keys(path.scope.bindings)) { + names.add(name); + } + }, + }); + + return names; +} + +function createScopedUniqueName(scope: Scope, baseName: string) { + if (!scope.hasBinding(baseName) && !scope.hasGlobal(baseName)) { + return baseName; + } + + return scope.generateUidIdentifier(baseName).name; +} + +function getProgramScope(ast: t.File) { + let programScope: Scope | null = null; + + traverse(ast, { + Program(path) { + programScope = path.scope; + path.stop(); + }, + }); + + return programScope; +} + +function getOrCreateProgramImportLocalName( + programScope: Scope, + importLocalNames: Map, + reservedImportLocalNames: Set, + key: string, + preferredLocalName: string, + fileBindingNames: Set +) { + const existing = importLocalNames.get(key); + if (existing) return existing; + + const localName = createProgramUniqueName( + programScope, + preferredLocalName, + fileBindingNames, + reservedImportLocalNames + ); + + importLocalNames.set(key, localName); + reservedImportLocalNames.add(localName); + return localName; +} + +function createProgramUniqueName( + programScope: Scope, + baseName: string, + fileBindingNames: Set, + reservedImportLocalNames: Set +) { + if ( + !fileBindingNames.has(baseName) && + !reservedImportLocalNames.has(baseName) && + !programScope.hasBinding(baseName) && + !programScope.hasGlobal(baseName) + ) { + return baseName; + } + + if ( + (fileBindingNames.has(baseName) || + reservedImportLocalNames.has(baseName)) && + !programScope.hasBinding(baseName) && + !programScope.hasGlobal(baseName) + ) { + programScope.addGlobal(t.identifier(baseName)); + } + + let candidate = programScope.generateUidIdentifier(baseName).name; + while (reservedImportLocalNames.has(candidate)) { + candidate = programScope.generateUidIdentifier(baseName).name; + } + return candidate; +} + +function getClientSourceKey( + createImportPath: string, + entrypointKey: string, + mode: ClientBinding['mode'] +) { + const generatedInfoKey = getGeneratedInfoKey(createImportPath, entrypointKey); + + if (mode.type === 'precreated') { + return [ + 'precreated', + generatedInfoKey, + mode.optionsImportPath, + mode.optionsExportName, + ].join('::'); + } + + return [mode.type, generatedInfoKey].join('::'); +} + +async function resolveFactoryModule( + specifier: string, + importerId: string, + resolveModule: QraftModuleAccess['resolve'] +): Promise { + const resolved = await resolveModule(specifier, importerId); + return normalizeOptionalResolvedId(resolved); +} + +function normalizeOptionalResolvedId(resolved: string | null | undefined) { + return resolved ? normalizeResolvedId(resolved) : null; +} + +function emptyTransformState(ast: t.File): TransformState { + return { + ast, + clients: [], + namedUsages: [], + inlineUsages: [], + schemaUsages: [], + generatedInfoByImport: new Map(), + generatedInfoRequests: new Map(), + transformedReferenceKeys: new Set(), + localClientNamesByOperation: new Map(), + runtimeLocalNames: { + api: 'qraftAPIClient', + react: 'qraftReactAPIClient', + }, + createImports: new Map(), + configuredFactoryNames: new Set(), + }; +} + +function assignScopeLocalClientNames( + usages: OperationUsage[], + programScope: Scope, + fileBindingNames: Set, + reservedImportLocalNames: Set, + localClientNamesByOperation: Map +) { + const contextUsages = usages.filter( + (usage) => usage.client.mode.type === 'context' + ); + const usagesByOperation = new Map>(); + + for (const usage of contextUsages) { + const operationKey = [ + usage.client.clientSourceKey, + usage.client.name, + usage.serviceName, + usage.operationName, + ].join(':'); + const scopeUsagesByOperation = + usagesByOperation.get(operationKey) ?? new Map(); + const scopeUsages = scopeUsagesByOperation.get(usage.scopeKey) ?? []; + scopeUsages.push(usage); + scopeUsagesByOperation.set(usage.scopeKey, scopeUsages); + usagesByOperation.set(operationKey, scopeUsagesByOperation); + } + + for (const scopeUsagesByOperation of usagesByOperation.values()) { + if (scopeUsagesByOperation.size <= 1) continue; + + const scopeEntries = [...scopeUsagesByOperation.entries()].map( + ([scopeKey, scopeUsages]) => ({ + scopeKey, + scopeUsages, + scopeRange: parseScopeKey(scopeKey), + }) + ); + + const rootEntries = scopeEntries.filter( + (entry) => + !scopeEntries.some( + (candidate) => + candidate.scopeKey !== entry.scopeKey && + scopeContains(candidate.scopeRange, entry.scopeRange) + ) + ); + + if (rootEntries.length <= 1) continue; + + for (const entry of scopeEntries) { + if (rootEntries.includes(entry)) continue; + const rootParent = rootEntries.find((root) => + scopeContains(root.scopeRange, entry.scopeRange) + ); + if (rootParent) { + rootParent.scopeUsages.push(...entry.scopeUsages); + } + } + + for (const scopeEntry of rootEntries) { + const usage = scopeEntry.scopeUsages[0]; + if (!usage) continue; + + const localClientName = createProgramUniqueName( + programScope, + composeLocalClientName( + usage.client.name, + usage.serviceName, + usage.operationName + ), + fileBindingNames, + reservedImportLocalNames + ); + reservedImportLocalNames.add(localClientName); + + for (const scopeUsage of scopeEntry.scopeUsages) { + scopeUsage.localClientName = localClientName; + localClientNamesByOperation.set( + [ + scopeUsage.client.clientSourceKey, + scopeUsage.client.name, + scopeUsage.serviceName, + scopeUsage.operationName, + scopeUsage.scopeKey, + ].join(':'), + localClientName + ); + } + } + } +} + +function parseScopeKey(scopeKey: string) { + const [, startText = '-1', endText = '-1'] = scopeKey.split(':', 3); + return { + start: Number(startText), + end: Number(endText), + }; +} + +function scopeContains( + outer: { start: number; end: number }, + inner: { start: number; end: number } +) { + return outer.start < inner.start && outer.end > inner.end; +} diff --git a/packages/tree-shaking-plugin/src/lib/transform/types.ts b/packages/tree-shaking-plugin/src/lib/transform/types.ts new file mode 100644 index 000000000..cc98d629e --- /dev/null +++ b/packages/tree-shaking-plugin/src/lib/transform/types.ts @@ -0,0 +1,229 @@ +import type { Scope } from '@babel/traverse'; +import type * as t from '@babel/types'; +import type { + QraftModuleAccessOptions, + QraftModuleAccessTraceEntry, + QraftResolver, +} from '../resolvers/common.js'; + +export type FilterPattern = string | RegExp | Array; + +export type ModuleExportTarget = { + exportName: string; + moduleSpecifier: string; +}; + +export type ReactContextTarget = { + exportName: string; + moduleSpecifier?: string; +}; + +export type ServicesTarget = { + moduleSpecifierBase?: string; + directory?: string; +}; + +export type ServicesConfig = { + moduleSpecifierBase: string; + directory: string; +}; + +export type QraftClientFactoryEntrypointConfig = { + kind: 'clientFactory'; + factory: ModuleExportTarget; + services?: ServicesTarget; + reactContext?: ReactContextTarget; +}; + +export type QraftPrecreatedClientEntrypointConfig = { + kind: 'precreatedClient'; + client: ModuleExportTarget; + factory: ModuleExportTarget; + optionsFactory: ModuleExportTarget; + services?: ServicesTarget; +}; + +export type QraftEntrypointConfig = + | QraftClientFactoryEntrypointConfig + | QraftPrecreatedClientEntrypointConfig; + +export type DiagnosticsLevel = 'error' | 'warn' | 'off'; + +export type ImportTarget = { + exportName: string; + moduleSpecifier: string; +}; + +export type ReactContextConfig = { + exportName: string; + moduleSpecifier: string; +}; + +export type RuntimeInput = + | { kind: 'none' } + | { kind: 'context'; context: ReactContextConfig } + | { kind: 'optionsExpression'; expression: t.Expression } + | { kind: 'optionsFactoryCall'; target: ImportTarget }; + +export type GeneratedFactoryEntrypoint = { + kind: 'generatedFactory'; + key: string; + factory: ImportTarget; + services: ServicesConfig; + reactContext: ReactContextConfig | null; +}; + +export type PrecreatedClientEntrypoint = { + kind: 'precreatedClient'; + key: string; + client: ImportTarget; + factory: ImportTarget; + optionsFactory: ImportTarget; + services: ServicesConfig; +}; + +export type ClientEntrypoint = + | GeneratedFactoryEntrypoint + | PrecreatedClientEntrypoint; + +export type DiagnosticLayer = + | 'gate' + | 'entrypoint' + | 'generated-metadata' + | 'usage-collection'; + +export type DiagnosticReason = { + layer: DiagnosticLayer; + code: string; + message: string; + entrypointKey?: string; + moduleAccessTrace?: QraftModuleAccessTraceEntry[]; +}; + +export type QraftTreeShakeOptions = { + entrypoints?: QraftEntrypointConfig[]; + resolve?: QraftResolver; + moduleAccess?: QraftModuleAccessOptions; + include?: FilterPattern; + exclude?: FilterPattern; + diagnostics?: DiagnosticsLevel; +}; + +export type GeneratedClientInfo = { + importerId: string; + clientFile: string; + servicesModuleSpecifierBase: string; + servicesDir: string; + contextImportPath: string | null; + contextName: string | null; +}; + +export type GeneratedClientMetadata = { + entrypoint: ClientEntrypoint; + factoryFile: string; + factoryLoadId: string; +}; + +export type GeneratedMetadataResult = { + metadataByEntrypointKey: Map; + reasons: DiagnosticReason[]; +}; + +export type OperationImportInfo = { + importPath: string; + operationName: string; + localName: string; +}; + +type EntrypointKeyCompatibility = Record< + 'entrypointKey', + ClientEntrypoint['key'] +>; + +type GeneratedFactoryEntrypointKeyCompatibility = Record< + 'entrypointKey', + GeneratedFactoryEntrypoint['key'] +>; + +export type ClientBinding = EntrypointKeyCompatibility & { + name: string; + clientSourceKey: string; + createImportPath: string; + createImportLoadId: string; + entrypoint: ClientEntrypoint; + bindingNode: t.Node; + declarationScope: Scope; + runtimeInput: RuntimeInput; + localInitPath?: import('@babel/traverse').NodePath; + mode: + | { type: 'context' } + | { type: 'options'; optionsExpression: t.Expression } + | { + type: 'precreated'; + optionsImportPath: string; + optionsExportName: string; + }; +}; + +export type OperationUsage = { + client: ClientBinding; + serviceName: string; + operationName: string; + callbackName: string; + callbackLocalName: string; + localClientName: string; + operationImport: OperationImportInfo; + scopeKey: string; +}; + +export type InlineImportRequest = { + createImportPath: string; + serviceName: string; + operationName: string; + callbackName: string; + callbackLocalName: string; + operationImport: OperationImportInfo; + kind?: 'callback' | 'schema'; +}; + +export type SchemaUsage = { + client: ClientBinding | null; + sourceKey: string; + serviceName: string; + operationName: string; + operationImport: OperationImportInfo; + scopeKey: string; +}; + +export type GeneratedInfoRequest = { + createImportPath: string; + createImportLoadId: string; + entrypoint: ClientEntrypoint; +}; + +export type CreateImportEntry = GeneratedFactoryEntrypointKeyCompatibility & { + sourceSpecifier: string; + factoryFile: string; + factoryLoadId: string; + entrypoint: GeneratedFactoryEntrypoint; +}; + +export type RuntimeLocalNames = { + api: string; + react: string; +}; + +export type TransformState = { + ast: t.File; + clients: ClientBinding[]; + namedUsages: OperationUsage[]; + inlineUsages: InlineImportRequest[]; + schemaUsages: SchemaUsage[]; + generatedInfoByImport: Map; + generatedInfoRequests: Map; + transformedReferenceKeys: Set; + localClientNamesByOperation: Map; + runtimeLocalNames: RuntimeLocalNames; + createImports: Map; + configuredFactoryNames: Set; +}; diff --git a/packages/tree-shaking-plugin/src/rollup.ts b/packages/tree-shaking-plugin/src/rollup.ts new file mode 100644 index 000000000..c573f7eb0 --- /dev/null +++ b/packages/tree-shaking-plugin/src/rollup.ts @@ -0,0 +1,12 @@ +import { + createBuildStartHooks, + createQraftTreeShakePlugin, +} from './lib/plugin/create-qraft-tree-shake-plugin.js'; +import { type BundlerResolveContext } from './lib/resolvers/common.js'; +import { createRollupLikeModuleAccess } from './lib/resolvers/rollup-like.js'; + +export const qraftTreeShakeRollup = + createQraftTreeShakePlugin( + createRollupLikeModuleAccess, + createBuildStartHooks + ).rollup; diff --git a/packages/tree-shaking-plugin/src/rspack.ts b/packages/tree-shaking-plugin/src/rspack.ts new file mode 100644 index 000000000..406592ea9 --- /dev/null +++ b/packages/tree-shaking-plugin/src/rspack.ts @@ -0,0 +1,12 @@ +import { + createBuildStartHooks, + createQraftTreeShakePlugin, +} from './lib/plugin/create-qraft-tree-shake-plugin.js'; +import { type BundlerResolveContext } from './lib/resolvers/common.js'; +import { createRspackModuleAccess } from './lib/resolvers/rspack.js'; + +export const qraftTreeShakeRspack = + createQraftTreeShakePlugin( + createRspackModuleAccess, + createBuildStartHooks + ).rspack; diff --git a/packages/tree-shaking-plugin/src/vite.test.ts b/packages/tree-shaking-plugin/src/vite.test.ts new file mode 100644 index 000000000..236b9755a --- /dev/null +++ b/packages/tree-shaking-plugin/src/vite.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { qraftTreeShakeVite } from './vite.js'; + +describe('qraftTreeShakeVite', () => { + it('clears generated metadata cache on Vite hot updates', () => { + const plugin = qraftTreeShakeVite({}); + + expect(plugin).toHaveProperty('handleHotUpdate'); + }); +}); diff --git a/packages/tree-shaking-plugin/src/vite.ts b/packages/tree-shaking-plugin/src/vite.ts new file mode 100644 index 000000000..cf2e62b5b --- /dev/null +++ b/packages/tree-shaking-plugin/src/vite.ts @@ -0,0 +1,17 @@ +import { + createBuildStartHooks, + createQraftTreeShakePlugin, +} from './lib/plugin/create-qraft-tree-shake-plugin.js'; +import { type BundlerResolveContext } from './lib/resolvers/common.js'; +import { createRollupLikeModuleAccess } from './lib/resolvers/rollup-like.js'; + +export const qraftTreeShakeVite = + createQraftTreeShakePlugin( + createRollupLikeModuleAccess, + (context) => ({ + ...createBuildStartHooks(context), + vite: { + handleHotUpdate: context.clearGeneratedMetadataCache, + }, + }) + ).vite; diff --git a/packages/tree-shaking-plugin/src/webpack.ts b/packages/tree-shaking-plugin/src/webpack.ts new file mode 100644 index 000000000..c58e12f6b --- /dev/null +++ b/packages/tree-shaking-plugin/src/webpack.ts @@ -0,0 +1,23 @@ +import { + createQraftTreeShakePlugin, + QRAFT_TREE_SHAKE_PLUGIN_NAME, +} from './lib/plugin/create-qraft-tree-shake-plugin.js'; +import { type BundlerResolveContext } from './lib/resolvers/common.js'; +import { createWebpackLikeModuleAccess } from './lib/resolvers/webpack-like.js'; + +export const qraftTreeShakeWebpack = + createQraftTreeShakePlugin( + createWebpackLikeModuleAccess, + ({ clearGeneratedMetadataCache }) => ({ + webpack(compiler) { + compiler.hooks.beforeRun.tap( + QRAFT_TREE_SHAKE_PLUGIN_NAME, + clearGeneratedMetadataCache + ); + compiler.hooks.watchRun.tap( + QRAFT_TREE_SHAKE_PLUGIN_NAME, + clearGeneratedMetadataCache + ); + }, + }) + ).webpack; diff --git a/packages/tree-shaking-plugin/tsconfig.build.json b/packages/tree-shaking-plugin/tsconfig.build.json new file mode 100644 index 000000000..5a5dd789b --- /dev/null +++ b/packages/tree-shaking-plugin/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist/types" + }, + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/tree-shaking-plugin/tsconfig.json b/packages/tree-shaking-plugin/tsconfig.json new file mode 100644 index 000000000..a5a210916 --- /dev/null +++ b/packages/tree-shaking-plugin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node", "vitest"] + }, + "include": ["src/**/*.ts", "*.config.mjs"] +} diff --git a/playground/src/App.tsx b/playground/src/App.tsx index 06db378e0..33190fdb1 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -184,7 +184,7 @@ function PetUpdateForm({ onUpdate: () => void; onReset: () => void; }) { - const qraftContext = usePlaygroundAPIClientContext(); + const qraftOptions = usePlaygroundAPIClientContext(); const petParameters: typeof qraft.pet.getPetById.types.parameters = { path: { petId }, @@ -198,7 +198,7 @@ function PetUpdateForm({ const { isPending, mutate } = qraft.pet.updatePet.useMutation(undefined, { async onMutate(variables) { - const miniQraft = createPlaygroundAPIClient(qraftContext); + const miniQraft = createPlaygroundAPIClient(qraftOptions); await miniQraft.pet.getPetById.cancelQueries({ parameters: petParameters, }); @@ -214,14 +214,14 @@ function PetUpdateForm({ }, async onError(_error, _variables, context) { if (context?.prevPet) { - createPlaygroundAPIClient(qraftContext).pet.getPetById.setQueryData( + createPlaygroundAPIClient(qraftOptions).pet.getPetById.setQueryData( petParameters, context.prevPet ); } }, async onSuccess(updatedPet) { - const miniQraft = createPlaygroundAPIClient(qraftContext); + const miniQraft = createPlaygroundAPIClient(qraftOptions); miniQraft.pet.getPetById.setQueryData(petParameters, updatedPet); await miniQraft.pet.findPetsByStatus.invalidateQueries(); onUpdate(); @@ -295,12 +295,12 @@ function PetCreateForm({ onCreate: (pet: components['schemas']['Pet']) => void; onReset: () => void; }) { - const qraftContext = usePlaygroundAPIClientContext(); + const qraftOptions = usePlaygroundAPIClientContext(); const { isPending, mutate, error } = qraft.pet.addPet.useMutation(undefined, { async onSuccess(createdPet) { await createPlaygroundAPIClient( - qraftContext + qraftOptions ).pet.findPetsByStatus.invalidateQueries(); if (!createdPet) throw new Error('createdPet not found in addPet.onSuccess'); diff --git a/playground/vite.config.ts b/playground/vite.config.ts index d34d54d42..f36f06c7a 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -1,8 +1,18 @@ +import { qraftTreeShakeVite } from '@openapi-qraft/tree-shaking-plugin/vite'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [ + qraftTreeShakeVite({ + createAPIClientFn: [ + { + name: 'createPlaygroundAPIClient', + module: './api', + context: 'PlaygroundAPIClientContext', + }, + ], + }), react({ babel: { plugins: [ diff --git a/yarn.lock b/yarn.lock index bce08d2c8..b6b0e6283 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1729,7 +1729,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.21.3, @babel/types@npm:^7.26.0, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.21.3, @babel/types@npm:^7.26.0, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.4.4": version: 7.29.0 resolution: "@babel/types@npm:7.29.0" dependencies: @@ -4072,6 +4072,18 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^1.1.2": + version: 1.1.4 + resolution: "@napi-rs/wasm-runtime@npm:1.1.4" + dependencies: + "@tybys/wasm-util": "npm:^0.10.1" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/2e88e1955258949ccf2d18c79975821ad38071b465ef126a5e14110977b97868867b016c1ad046e963cccc42c0bd9db6c8ff5fd1ebb61b87bb3487f339041658 + languageName: node + linkType: hard + "@noble/hashes@npm:1.4.0": version: 1.4.0 resolution: "@noble/hashes@npm:1.4.0" @@ -4464,6 +4476,36 @@ __metadata: languageName: unknown linkType: soft +"@openapi-qraft/tree-shaking-plugin@workspace:packages/tree-shaking-plugin": + version: 0.0.0-use.local + resolution: "@openapi-qraft/tree-shaking-plugin@workspace:packages/tree-shaking-plugin" + dependencies: + "@babel/generator": "npm:^7.29.0" + "@babel/parser": "npm:^7.29.0" + "@babel/traverse": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + "@jridgewell/trace-mapping": "npm:^0.3.31" + "@openapi-qraft/eslint-config": "workspace:*" + "@openapi-qraft/rollup-config": "workspace:*" + "@qraft/test-utils": "workspace:*" + "@rspack/resolver": "npm:^0.4.0" + "@types/babel__generator": "npm:^7.27.0" + "@types/babel__traverse": "npm:^7.28.0" + "@types/node": "npm:^22.19.17" + eslint: "npm:^10.2.0" + rimraf: "npm:^6.1.3" + rollup: "npm:~4.60.1" + typescript: "npm:^5.9.3" + unplugin: "npm:^2.3.10" + vitest: "npm:^4.1.4" + peerDependencies: + "@rspack/resolver": ^0.4.0 + peerDependenciesMeta: + "@rspack/resolver": + optional: true + languageName: unknown + linkType: soft + "@openapi-qraft/ts-factory-code-generator@workspace:*, @openapi-qraft/ts-factory-code-generator@workspace:packages/ts-factory-code-generator": version: 0.0.0-use.local resolution: "@openapi-qraft/ts-factory-code-generator@workspace:packages/ts-factory-code-generator" @@ -5524,6 +5566,117 @@ __metadata: languageName: node linkType: hard +"@rspack/resolver-binding-darwin-arm64@npm:0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver-binding-darwin-arm64@npm:0.4.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rspack/resolver-binding-darwin-x64@npm:0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver-binding-darwin-x64@npm:0.4.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rspack/resolver-binding-linux-arm64-gnu@npm:0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver-binding-linux-arm64-gnu@npm:0.4.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rspack/resolver-binding-linux-arm64-musl@npm:0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver-binding-linux-arm64-musl@npm:0.4.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rspack/resolver-binding-linux-x64-gnu@npm:0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver-binding-linux-x64-gnu@npm:0.4.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rspack/resolver-binding-linux-x64-musl@npm:0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver-binding-linux-x64-musl@npm:0.4.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rspack/resolver-binding-wasm32-wasi@npm:0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver-binding-wasm32-wasi@npm:0.4.0" + dependencies: + "@napi-rs/wasm-runtime": "npm:^1.1.2" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@rspack/resolver-binding-win32-arm64-msvc@npm:0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver-binding-win32-arm64-msvc@npm:0.4.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rspack/resolver-binding-win32-ia32-msvc@npm:0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver-binding-win32-ia32-msvc@npm:0.4.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rspack/resolver-binding-win32-x64-msvc@npm:0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver-binding-win32-x64-msvc@npm:0.4.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rspack/resolver@npm:^0.4.0": + version: 0.4.0 + resolution: "@rspack/resolver@npm:0.4.0" + dependencies: + "@rspack/resolver-binding-darwin-arm64": "npm:0.4.0" + "@rspack/resolver-binding-darwin-x64": "npm:0.4.0" + "@rspack/resolver-binding-linux-arm64-gnu": "npm:0.4.0" + "@rspack/resolver-binding-linux-arm64-musl": "npm:0.4.0" + "@rspack/resolver-binding-linux-x64-gnu": "npm:0.4.0" + "@rspack/resolver-binding-linux-x64-musl": "npm:0.4.0" + "@rspack/resolver-binding-wasm32-wasi": "npm:0.4.0" + "@rspack/resolver-binding-win32-arm64-msvc": "npm:0.4.0" + "@rspack/resolver-binding-win32-ia32-msvc": "npm:0.4.0" + "@rspack/resolver-binding-win32-x64-msvc": "npm:0.4.0" + dependenciesMeta: + "@rspack/resolver-binding-darwin-arm64": + optional: true + "@rspack/resolver-binding-darwin-x64": + optional: true + "@rspack/resolver-binding-linux-arm64-gnu": + optional: true + "@rspack/resolver-binding-linux-arm64-musl": + optional: true + "@rspack/resolver-binding-linux-x64-gnu": + optional: true + "@rspack/resolver-binding-linux-x64-musl": + optional: true + "@rspack/resolver-binding-wasm32-wasi": + optional: true + "@rspack/resolver-binding-win32-arm64-msvc": + optional: true + "@rspack/resolver-binding-win32-ia32-msvc": + optional: true + "@rspack/resolver-binding-win32-x64-msvc": + optional: true + checksum: 10c0/cab2418717b6714bd47939ddcdc75fb56b4a76b0d5b8d84ada308a26635085b4bf602091b43a3e7f1691a16a26a15884050ffa2e38a6a079c4814066dab6f253 + languageName: node + linkType: hard + "@sideway/address@npm:^4.1.5": version: 4.1.5 resolution: "@sideway/address@npm:4.1.5" @@ -6267,6 +6420,24 @@ __metadata: languageName: node linkType: hard +"@types/babel__generator@npm:^7.27.0": + version: 7.27.0 + resolution: "@types/babel__generator@npm:7.27.0" + dependencies: + "@babel/types": "npm:^7.0.0" + checksum: 10c0/9f9e959a8792df208a9d048092fda7e1858bddc95c6314857a8211a99e20e6830bdeb572e3587ae8be5429e37f2a96fcf222a9f53ad232f5537764c9e13a2bbd + languageName: node + linkType: hard + +"@types/babel__traverse@npm:^7.28.0": + version: 7.28.0 + resolution: "@types/babel__traverse@npm:7.28.0" + dependencies: + "@babel/types": "npm:^7.28.2" + checksum: 10c0/b52d7d4e8fc6a9018fe7361c4062c1c190f5778cf2466817cb9ed19d69fbbb54f9a85ffedeb748ed8062d2cf7d4cc088ee739848f47c57740de1c48cbf0d0994 + languageName: node + linkType: hard + "@types/body-parser@npm:*": version: 1.19.6 resolution: "@types/body-parser@npm:1.19.6" @@ -22570,6 +22741,18 @@ __metadata: languageName: node linkType: hard +"unplugin@npm:^2.3.10": + version: 2.3.11 + resolution: "unplugin@npm:2.3.11" + dependencies: + "@jridgewell/remapping": "npm:^2.3.5" + acorn: "npm:^8.15.0" + picomatch: "npm:^4.0.3" + webpack-virtual-modules: "npm:^0.6.2" + checksum: 10c0/273c1eab0eca4470c7317428689295c31dbe8ab0b306504de9f03cd20c156debb4131bef24b27ac615862958c5dd950a3951d26c0723ea774652ab3624149cff + languageName: node + linkType: hard + "unrs-resolver@npm:^1.7.11, unrs-resolver@npm:^1.9.2": version: 1.11.1 resolution: "unrs-resolver@npm:1.11.1" @@ -23244,6 +23427,13 @@ __metadata: languageName: node linkType: hard +"webpack-virtual-modules@npm:^0.6.2": + version: 0.6.2 + resolution: "webpack-virtual-modules@npm:0.6.2" + checksum: 10c0/5ffbddf0e84bf1562ff86cf6fcf039c74edf09d78358a6904a09bbd4484e8bb6812dc385fe14330b715031892dcd8423f7a88278b57c9f5002c84c2860179add + languageName: node + linkType: hard + "webpack@npm:^5.104.1": version: 5.106.1 resolution: "webpack@npm:5.106.1"