From b8912082688030890fe5c307d19e00340c47a8ef Mon Sep 17 00:00:00 2001 From: benjamineckstein <13351939+benjamineckstein@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:55:27 +0200 Subject: [PATCH] feat(openapi-zod-ts): add --check-drift CI gate for generated output Adds a --check-drift flag that regenerates all output in memory, applies the same Prettier formatting as the write path, and compares against committed files on disk. Exits non-zero with per-file diagnostics when any file is stale, missing, or extra. Writes nothing to disk. When GITHUB_ACTIONS=true, emits ::error file=:: annotations per drifted file and appends a markdown table to GITHUB_STEP_SUMMARY. Exports a reusable ./drift-check subpath (compareOutput, reportDrift, DriftReport) so openapi-server, openapi-react-query, and openapi-msw can adopt the same gate in follow-up PRs. Claude-Session: https://claude.ai/code/session_01SZmVypA9rE5H3HTreJgyE2 --- docs/astro.config.mjs | 1 + .../content/docs/guides/drift-detection.mdx | 129 +++++ packages/openapi-zod-ts/package.json | 4 + .../src/__tests__/cli-args.test.ts | 56 +++ .../src/__tests__/drift-check.test.ts | 443 ++++++++++++++++++ .../src/__tests__/generator-schema.test.ts | 50 ++ packages/openapi-zod-ts/src/cli-args.ts | 17 + packages/openapi-zod-ts/src/cli.ts | 25 +- packages/openapi-zod-ts/src/drift-check.ts | 219 +++++++++ packages/openapi-zod-ts/src/generator.ts | 235 +++++++--- 10 files changed, 1108 insertions(+), 71 deletions(-) create mode 100644 docs/src/content/docs/guides/drift-detection.mdx create mode 100644 packages/openapi-zod-ts/src/__tests__/drift-check.test.ts create mode 100644 packages/openapi-zod-ts/src/drift-check.ts diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 9553a51d..fa248b24 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -114,6 +114,7 @@ export default defineConfig({ { label: 'React Query hooks', slug: 'openapi-react-query' }, { label: 'MSW mock handlers', slug: 'openapi-msw' }, { label: 'Form error mapping', slug: 'api-errors' }, + { label: 'Drift detection in CI', slug: 'guides/drift-detection' }, ], }, { diff --git a/docs/src/content/docs/guides/drift-detection.mdx b/docs/src/content/docs/guides/drift-detection.mdx new file mode 100644 index 00000000..af56e8ac --- /dev/null +++ b/docs/src/content/docs/guides/drift-detection.mdx @@ -0,0 +1,129 @@ +--- +title: Drift detection in CI +description: Use --check-drift to gate CI on stale, missing, or extra generated files and catch regeneration lapses before they reach production. +--- + +import { Steps, Code, Aside } from '@astrojs/starlight/components' + +## The problem + +Your OpenAPI spec evolves. A new endpoint is added, a response shape changes, a field is renamed. The generated output (`models.ts`, `client.ts`, and friends) must be regenerated and committed whenever the spec changes. Without a CI gate, a team member can update the spec, forget to regenerate, and push. The discrepancy is invisible until a type error or a runtime mismatch surfaces downstream. + +`--check-drift` solves this with a single command that fits in any CI pipeline. + +## One-command usage + +```sh +npx openapi-zod-ts --check-drift +``` + +The command: + +1. Loads your config file (`openapi-zod-ts.config.json` by default, or the path you pass with `--config`). +2. Runs the full generator pipeline in memory. No files are written. +3. Applies the same Prettier formatting the write path uses, so the comparison is exact. +4. Reads the committed files from your configured `output` directory. +5. Exits 0 when everything matches. Exits 1 and prints per-file diagnostics when anything is stale, missing, or extra. + +Pass `--config` when your config file is not in the current directory: + +```sh +npx openapi-zod-ts --config packages/api/openapi-zod-ts.config.json --check-drift +``` + +## What it checks + +| Status | Meaning | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **STALE** | The file exists on disk but its content differs from what the generator produces today. Usually caused by a spec change that was not followed by a regeneration commit. | +| **MISSING** | The generator would produce this file but it is not on disk. Could happen after a first-time setup or after a config change that adds a new output file. | +| **EXTRA** | A file is in the output directory that the generator does not produce. Often a stale artifact from a removed endpoint or a file that was manually added to the generated output directory (which is not recommended). | + +Example output when drift is detected: + +``` +Drift check failed: generated output does not match committed files. + + STALE models.ts (content differs from what the generator produces today) + MISSING client-config.ts (expected by the generator but not found on disk) + EXTRA old-models.ts (on disk but not produced by the generator; delete it or regenerate) + +Fix: openapi-zod-ts +``` + +When the check passes: + +``` +Drift check passed: all generated files are up to date. +``` + +## GitHub Actions integration + +Add a dedicated drift check step to your workflow. When `GITHUB_ACTIONS=true`, the command also emits `::error file=...::` workflow commands so GitHub renders inline annotations on the PR, and appends a summary table to the step summary panel. + +```yaml +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + drift-check: + name: Generated output drift check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm ci + + - name: Check generated output is up to date + run: npx openapi-zod-ts --check-drift +``` + +For a monorepo where each package has its own config: + +```yaml +- name: Check generated output (packages/api) + run: npx openapi-zod-ts --config packages/api/openapi-zod-ts.config.json --check-drift +``` + +## How this differs from --check + +The two drift flags guard against different problems: + +| Flag | What it checks | When it applies | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | +| `--check` | Schema drift: your `input_schema` Zod file vs. what a fresh bootstrap from the spec would produce. Ensures your hand-written Zod schemas stay aligned with the contract. | Only when `input_schema` is configured. | +| `--check-drift` | Output file drift: the committed generated files (`models.ts`, `client.ts`, etc.) vs. what the generator would produce today from the current spec and config. | Always, for every generator run. | + +A project can have `--check` pass (the Zod schema is aligned with the spec) and `--check-drift` fail (the TypeScript client is stale because regeneration was not committed). Both flags can be run together in the same CI step: + +```sh +npx openapi-zod-ts --check --check-drift +``` + +## What is NOT checked + +The user-owned `input_schema` file (your hand-written Zod schemas, typically `zod.ts`) is intentionally excluded from `--check-drift` scope. The generator bootstraps this file once and never overwrites it: it belongs to you. Use `--check` to verify that your schema file stays aligned with the OpenAPI contract. + +## Fixing drift + +When the command reports drift, run the generator to regenerate and commit the result: + +```sh +# Regenerate +npx openapi-zod-ts + +# Commit the updated generated files +git add src/api/ +git commit -m "chore: regenerate api client" +``` + +For more on the CLI options, see the [Types and fetch client reference](/openapi-zod-ts). diff --git a/packages/openapi-zod-ts/package.json b/packages/openapi-zod-ts/package.json index bb21da1d..10ce65dd 100644 --- a/packages/openapi-zod-ts/package.json +++ b/packages/openapi-zod-ts/package.json @@ -18,6 +18,10 @@ "./cli-core": { "import": "./dist/cli-core.js", "types": "./dist/cli-core.d.ts" + }, + "./drift-check": { + "import": "./dist/drift-check.js", + "types": "./dist/drift-check.d.ts" } }, "files": [ diff --git a/packages/openapi-zod-ts/src/__tests__/cli-args.test.ts b/packages/openapi-zod-ts/src/__tests__/cli-args.test.ts index 3cd7e739..991e9229 100644 --- a/packages/openapi-zod-ts/src/__tests__/cli-args.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/cli-args.test.ts @@ -36,6 +36,7 @@ describe('parseCliArgs', () => { cwd: dirname(resolve(fakeCwd, 'config.json')), watch: false, check: false, + checkDrift: false, resetSchema: false, }) }) @@ -48,6 +49,7 @@ describe('parseCliArgs', () => { cwd: '/abs/path', watch: false, check: false, + checkDrift: false, resetSchema: false, }) }) @@ -79,6 +81,7 @@ describe('parseCliArgs', () => { cwd: fakeCwd, watch: false, check: false, + checkDrift: false, resetSchema: false, }) }) @@ -285,6 +288,59 @@ describe('parseCliArgs', () => { }) }) + describe('--check-drift', () => { + it('sets checkDrift to true when --check-drift is given', () => { + const result = parseCliArgs([...baseArgv, '--check-drift'], fakeCwd) + expect(result.action).toBe('run') + if (result.action === 'run') { + expect(result.checkDrift).toBe(true) + } + }) + + it('sets checkDrift to false when --check-drift is not given', () => { + const result = parseCliArgs([...baseArgv], fakeCwd) + expect(result.action).toBe('run') + if (result.action === 'run') { + expect(result.checkDrift).toBe(false) + } + }) + + it('returns error action when --check-drift and --watch are combined', () => { + const result = parseCliArgs([...baseArgv, '--check-drift', '--watch'], fakeCwd) + expect(result.action).toBe('error') + }) + + it('error message for --check-drift --watch mentions both flags', () => { + const result = parseCliArgs([...baseArgv, '--check-drift', '--watch'], fakeCwd) + if (result.action === 'error') { + expect(result.message).toContain('--check-drift') + expect(result.message).toContain('--watch') + } + }) + + it('combines --check-drift with --input and --output', () => { + const result = parseCliArgs( + [...baseArgv, '--check-drift', '--input', 'spec.json', '--output', 'out/'], + fakeCwd + ) + expect(result.action).toBe('run') + if (result.action === 'run') { + expect(result.checkDrift).toBe(true) + expect(result.inputOverride).toBe(resolve(fakeCwd, 'spec.json')) + expect(result.outputOverride).toBe(resolve(fakeCwd, 'out/')) + } + }) + + it('combines --check-drift with --check (both are allowed independently)', () => { + const result = parseCliArgs([...baseArgv, '--check-drift', '--check'], fakeCwd) + expect(result.action).toBe('run') + if (result.action === 'run') { + expect(result.checkDrift).toBe(true) + expect(result.check).toBe(true) + } + }) + }) + describe('--reset-schema', () => { it('sets resetSchema to true when --reset-schema is given', () => { const result = parseCliArgs([...baseArgv, '--reset-schema'], fakeCwd) diff --git a/packages/openapi-zod-ts/src/__tests__/drift-check.test.ts b/packages/openapi-zod-ts/src/__tests__/drift-check.test.ts new file mode 100644 index 00000000..d63d9d73 --- /dev/null +++ b/packages/openapi-zod-ts/src/__tests__/drift-check.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { Dirent } from 'node:fs' + +// Mock node:fs/promises so we can control the file system in tests. +vi.mock('node:fs/promises', () => ({ + readdir: vi.fn(), + readFile: vi.fn(), +})) + +// Mock node:fs (appendFileSync used for GITHUB_STEP_SUMMARY). +vi.mock('node:fs', () => ({ + appendFileSync: vi.fn(), +})) + +import { compareOutput, reportDrift, type DriftReport } from '../drift-check.js' +import * as fsPromises from 'node:fs/promises' +import * as fs from 'node:fs' + +const mockReaddir = vi.mocked(fsPromises.readdir) +const mockReadFile = vi.mocked(fsPromises.readFile) +const mockAppendFileSync = vi.mocked(fs.appendFileSync) + +/** Build a minimal Dirent-like object for use in readdir mocks. */ +function fakeDirent(name: string): Dirent { + return { + name, + isFile: () => true, + isDirectory: () => false, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isSymbolicLink: () => false, + isFIFO: () => false, + isSocket: () => false, + parentPath: '', + path: '', + } as unknown as Dirent +} + +const OUTPUT_DIR = '/project/src/api' + +beforeEach(() => { + vi.resetAllMocks() + // Clear GITHUB_STEP_SUMMARY between tests. + delete process.env['GITHUB_STEP_SUMMARY'] + delete process.env['GITHUB_ACTIONS'] +}) + +afterEach(() => { + delete process.env['GITHUB_STEP_SUMMARY'] + delete process.env['GITHUB_ACTIONS'] +}) + +// --------------------------------------------------------------------------- +// compareOutput +// --------------------------------------------------------------------------- + +describe('compareOutput', () => { + it('returns a clean DriftReport when expected Map matches disk exactly', async () => { + const expected = new Map([ + ['models.ts', 'export type Foo = string;\n'], + ['client.ts', 'export function getAll() {}\n'], + ]) + + mockReaddir.mockResolvedValue([fakeDirent('models.ts'), fakeDirent('client.ts')]) + mockReadFile + .mockResolvedValueOnce('export type Foo = string;\n') + .mockResolvedValueOnce('export function getAll() {}\n') + + const report = await compareOutput(expected, OUTPUT_DIR) + + expect(report.clean).toBe(true) + expect(report.total).toBe(0) + expect(report.stale).toHaveLength(0) + expect(report.missing).toHaveLength(0) + expect(report.extra).toHaveLength(0) + }) + + it('reports stale for a file whose content differs on disk', async () => { + const expected = new Map([['models.ts', 'export type Foo = string;\n']]) + + mockReaddir.mockResolvedValue([fakeDirent('models.ts')]) + // Disk has old content. + mockReadFile.mockResolvedValue('export type Foo = number;\n') + + const report = await compareOutput(expected, OUTPUT_DIR) + + expect(report.clean).toBe(false) + expect(report.stale).toHaveLength(1) + expect(report.stale[0]).toEqual({ filename: 'models.ts', reason: 'stale' }) + expect(report.missing).toHaveLength(0) + expect(report.extra).toHaveLength(0) + expect(report.total).toBe(1) + }) + + it('reports missing for a file in expected but absent from disk', async () => { + const expected = new Map([ + ['models.ts', 'export type Foo = string;\n'], + ['client.ts', 'export function getAll() {}\n'], + ]) + + // Only models.ts is on disk. + mockReaddir.mockResolvedValue([fakeDirent('models.ts')]) + mockReadFile.mockResolvedValue('export type Foo = string;\n') + + const report = await compareOutput(expected, OUTPUT_DIR) + + expect(report.clean).toBe(false) + expect(report.missing).toHaveLength(1) + expect(report.missing[0]).toEqual({ filename: 'client.ts', reason: 'missing' }) + expect(report.stale).toHaveLength(0) + expect(report.extra).toHaveLength(0) + }) + + it('reports extra for a file on disk not in expected', async () => { + const expected = new Map([['models.ts', 'export type Foo = string;\n']]) + + // Disk has an extra stale artifact. + mockReaddir.mockResolvedValue([fakeDirent('models.ts'), fakeDirent('old-client.ts')]) + mockReadFile.mockResolvedValue('export type Foo = string;\n') + + const report = await compareOutput(expected, OUTPUT_DIR) + + expect(report.clean).toBe(false) + expect(report.extra).toHaveLength(1) + expect(report.extra[0]).toEqual({ filename: 'old-client.ts', reason: 'extra' }) + expect(report.stale).toHaveLength(0) + expect(report.missing).toHaveLength(0) + }) + + it('normalizes CRLF to LF before comparison so identical content does not false-positive', async () => { + // Expected has LF; disk has CRLF. Should match. + const expected = new Map([['models.ts', 'export type Foo = string;\n']]) + + mockReaddir.mockResolvedValue([fakeDirent('models.ts')]) + mockReadFile.mockResolvedValue('export type Foo = string;\r\n') + + const report = await compareOutput(expected, OUTPUT_DIR) + + expect(report.clean).toBe(true) + expect(report.stale).toHaveLength(0) + }) + + it('normalizes missing trailing newline before comparison so it does not false-positive', async () => { + // Expected has trailing newline; disk does not. Should match after normalization. + const expected = new Map([['models.ts', 'export type Foo = string;\n']]) + + mockReaddir.mockResolvedValue([fakeDirent('models.ts')]) + mockReadFile.mockResolvedValue('export type Foo = string;') + + const report = await compareOutput(expected, OUTPUT_DIR) + + expect(report.clean).toBe(true) + }) + + it('returns all-missing report when outputDir does not exist', async () => { + const expected = new Map([ + ['models.ts', 'export type Foo = string;\n'], + ['client.ts', 'export function getAll() {}\n'], + ]) + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + mockReaddir.mockRejectedValue(enoent) + + const report = await compareOutput(expected, OUTPUT_DIR) + + expect(report.clean).toBe(false) + expect(report.missing).toHaveLength(2) + expect(report.stale).toHaveLength(0) + expect(report.extra).toHaveLength(0) + expect(report.total).toBe(2) + }) + + it('handles multiple stale, missing, and extra files in one call', async () => { + const expected = new Map([ + ['models.ts', 'new content\n'], + ['client.ts', 'client content\n'], + ['index.ts', 'index content\n'], + ]) + + // Disk: models.ts (stale), client.ts (missing), bonus.ts (extra) + mockReaddir.mockResolvedValue([fakeDirent('models.ts'), fakeDirent('bonus.ts')]) + // Only one readFile call: for models.ts (client.ts is missing, bonus.ts is extra but not read). + mockReadFile.mockResolvedValue('old content\n') + + const report = await compareOutput(expected, OUTPUT_DIR) + + expect(report.stale).toHaveLength(1) + expect(report.stale[0]!.filename).toBe('models.ts') + expect(report.missing).toHaveLength(2) + const missingNames = report.missing.map((m) => m.filename) + expect(missingNames).toContain('client.ts') + expect(missingNames).toContain('index.ts') + expect(report.extra).toHaveLength(1) + expect(report.extra[0]!.filename).toBe('bonus.ts') + expect(report.total).toBe(4) + expect(report.clean).toBe(false) + }) + + it('re-throws non-ENOENT errors from readdir', async () => { + const expected = new Map([['models.ts', 'content\n']]) + const permErr = Object.assign(new Error('EACCES'), { code: 'EACCES' }) + mockReaddir.mockRejectedValue(permErr) + + await expect(compareOutput(expected, OUTPUT_DIR)).rejects.toThrow('EACCES') + }) + + it('throws a clear actionable error when outputDir is a file (ENOTDIR)', async () => { + const expected = new Map([['models.ts', 'content\n']]) + const enotdir = Object.assign(new Error('ENOTDIR'), { code: 'ENOTDIR' }) + mockReaddir.mockRejectedValue(enotdir) + + await expect(compareOutput(expected, OUTPUT_DIR)).rejects.toThrow( + `Output path '${OUTPUT_DIR}' is a file, not a directory` + ) + }) +}) + +// --------------------------------------------------------------------------- +// reportDrift +// --------------------------------------------------------------------------- + +const FIX_COMMAND = 'openapi-zod-ts' + +function cleanReport(): DriftReport { + return { stale: [], missing: [], extra: [], total: 0, clean: true } +} + +function staleReport(): DriftReport { + return { + stale: [{ filename: 'models.ts', reason: 'stale' }], + missing: [], + extra: [], + total: 1, + clean: false, + } +} + +function missingReport(): DriftReport { + return { + stale: [], + missing: [{ filename: 'client.ts', reason: 'missing' }], + extra: [], + total: 1, + clean: false, + } +} + +function extraReport(): DriftReport { + return { + stale: [], + missing: [], + extra: [{ filename: 'old-endpoint.ts', reason: 'extra' }], + total: 1, + clean: false, + } +} + +describe('reportDrift', () => { + it('returns exitCode 0 for a clean report', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const { exitCode } = reportDrift(cleanReport(), { github: false, fixCommand: FIX_COMMAND }) + expect(exitCode).toBe(0) + consoleSpy.mockRestore() + }) + + it('returns exitCode 1 for a report with stale files', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const { exitCode } = reportDrift(staleReport(), { github: false, fixCommand: FIX_COMMAND }) + expect(exitCode).toBe(1) + errSpy.mockRestore() + }) + + it('returns exitCode 1 for a report with missing files', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const { exitCode } = reportDrift(missingReport(), { github: false, fixCommand: FIX_COMMAND }) + expect(exitCode).toBe(1) + errSpy.mockRestore() + }) + + it('returns exitCode 1 for a report with extra files', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const { exitCode } = reportDrift(extraReport(), { github: false, fixCommand: FIX_COMMAND }) + expect(exitCode).toBe(1) + errSpy.mockRestore() + }) + + it('prints per-file diagnostic lines for each stale entry', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + reportDrift(staleReport(), { github: false, fixCommand: FIX_COMMAND }) + const output: string = errSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(output).toContain('STALE') + expect(output).toContain('models.ts') + errSpy.mockRestore() + }) + + it('prints per-file diagnostic lines for each missing entry', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + reportDrift(missingReport(), { github: false, fixCommand: FIX_COMMAND }) + const output: string = errSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(output).toContain('MISSING') + expect(output).toContain('client.ts') + errSpy.mockRestore() + }) + + it('prints per-file diagnostic lines for each extra entry', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + reportDrift(extraReport(), { github: false, fixCommand: FIX_COMMAND }) + const output: string = errSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(output).toContain('EXTRA') + expect(output).toContain('old-endpoint.ts') + errSpy.mockRestore() + }) + + it('prints the fix command in the diagnostic output', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + reportDrift(staleReport(), { github: false, fixCommand: 'openapi-zod-ts --config my.json' }) + const output: string = errSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(output).toContain('openapi-zod-ts --config my.json') + errSpy.mockRestore() + }) + + it('emits ::error file=:: annotation when github=true for stale files (no outputDir)', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + reportDrift(staleReport(), { github: true, fixCommand: FIX_COMMAND }) + + const logOutput = logSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(logOutput).toContain('::error file=models.ts::') + + logSpy.mockRestore() + errSpy.mockRestore() + }) + + it('emits ::error file=:: annotation when github=true for missing files (no outputDir)', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + reportDrift(missingReport(), { github: true, fixCommand: FIX_COMMAND }) + + const logOutput = logSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(logOutput).toContain('::error file=client.ts::') + + logSpy.mockRestore() + errSpy.mockRestore() + }) + + it('prefixes annotation file path with outputDir when provided', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + reportDrift(staleReport(), { + github: true, + fixCommand: FIX_COMMAND, + outputDir: 'src/api', + }) + + const logOutput = logSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(logOutput).toContain('::error file=src/api/models.ts::') + // Ensure the bare filename form is NOT used when outputDir is provided. + expect(logOutput).not.toContain('::error file=models.ts::') + + logSpy.mockRestore() + errSpy.mockRestore() + }) + + it('does NOT emit ::error annotations when github=false even if GITHUB_ACTIONS is set', () => { + process.env['GITHUB_ACTIONS'] = 'true' + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + reportDrift(staleReport(), { github: false, fixCommand: FIX_COMMAND }) + + const logOutput = logSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(logOutput).not.toContain('::error') + + logSpy.mockRestore() + errSpy.mockRestore() + }) + + it('writes GITHUB_STEP_SUMMARY markdown panel when github=true and env var is set', () => { + process.env['GITHUB_STEP_SUMMARY'] = '/tmp/step-summary.md' + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + reportDrift(staleReport(), { github: true, fixCommand: FIX_COMMAND }) + + expect(mockAppendFileSync).toHaveBeenCalledOnce() + const [path, content] = mockAppendFileSync.mock.calls[0]! + expect(path).toBe('/tmp/step-summary.md') + expect(String(content)).toContain('Drift check failed') + expect(String(content)).toContain('models.ts') + expect(String(content)).toContain(FIX_COMMAND) + + logSpy.mockRestore() + errSpy.mockRestore() + }) + + it('does not write GITHUB_STEP_SUMMARY when github=true but env var is not set', () => { + // GITHUB_STEP_SUMMARY is not set (cleared in beforeEach). + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + reportDrift(staleReport(), { github: true, fixCommand: FIX_COMMAND }) + + expect(mockAppendFileSync).not.toHaveBeenCalled() + + logSpy.mockRestore() + errSpy.mockRestore() + }) + + it('warns to stderr when GITHUB_STEP_SUMMARY write fails but does not change exit code', () => { + process.env['GITHUB_STEP_SUMMARY'] = '/unwritable/path.md' + mockAppendFileSync.mockImplementation(() => { + throw new Error('EACCES: permission denied') + }) + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const { exitCode } = reportDrift(staleReport(), { github: true, fixCommand: FIX_COMMAND }) + + expect(exitCode).toBe(1) + const warnOutput = warnSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(warnOutput).toContain('GITHUB_STEP_SUMMARY') + + logSpy.mockRestore() + errSpy.mockRestore() + warnSpy.mockRestore() + }) + + it('logs success message to console.log for a clean report', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + reportDrift(cleanReport(), { github: false, fixCommand: FIX_COMMAND }) + + const logOutput = logSpy.mock.calls.map((c) => String(c[0])).join('\n') + expect(logOutput).toContain('passed') + + logSpy.mockRestore() + }) +}) diff --git a/packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts b/packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts index 2d825691..24c4fb57 100644 --- a/packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts @@ -275,3 +275,53 @@ describe('schema-enhanced mode — cyclic schemas resolve to concrete types (#38 expect(schemas).not.toContain('RegionSchema as z.ZodType') }) }) + +// ── --check-drift with input_schema ─────────────────────────────────────────── + +describe('--check-drift with input_schema configured', () => { + it('passes (exits cleanly) when generated output matches disk after schema-enhanced run', async () => { + const { configPath, tmpDir: dir } = await makeConfig(taskApiFixture) + + // Run 1: bootstraps the schema file, writes PLAIN models/client (enhancement is next-run). + await generate(dir, configPath) + // Run 2: schema file exists, so writes schema-ENHANCED models.ts and client.ts. + await generate(dir, configPath) + + // Drift check: expectedMap uses enhanced versions (schema exists), disk has enhanced versions. + // This must NOT throw. + await expect(generate(dir, { configPath, checkDrift: true })).resolves.toBeUndefined() + }) + + it('fails (throws) when schema-enhanced models.ts is stale on disk', async () => { + const { configPath, tmpDir: dir, outDir } = await makeConfig(taskApiFixture) + + // Run 1 + 2 to get enhanced files on disk. + await generate(dir, configPath) + await generate(dir, configPath) + + // Corrupt models.ts on disk so it is stale relative to the schema-enhanced expected. + await writeFile(join(outDir, 'models.ts'), '// stale content\n', 'utf-8') + + // Drift check must detect the stale file. + await expect(generate(dir, { configPath, checkDrift: true })).rejects.toThrow( + 'Output drift detected' + ) + }) + + it('passes when checkDrift is used without input_schema (plain generation mode)', async () => { + // Create a config WITHOUT input_schema to confirm the base generatedFiles path works. + const dir = await mkdtemp(join(tmpdir(), 'openapi-zod-ts-drift-plain-')) + tmpDir = dir + const outDir = join(dir, 'generated') + const configPath = join(dir, 'openapi-zod-ts.config.json') + await writeFile( + configPath, + JSON.stringify({ input_openapi: taskApiFixture, output: outDir }), + 'utf-8' + ) + + // Generate first, then drift-check. + await generate(dir, configPath) + await expect(generate(dir, { configPath, checkDrift: true })).resolves.toBeUndefined() + }) +}) diff --git a/packages/openapi-zod-ts/src/cli-args.ts b/packages/openapi-zod-ts/src/cli-args.ts index ddca7164..ee8e381b 100644 --- a/packages/openapi-zod-ts/src/cli-args.ts +++ b/packages/openapi-zod-ts/src/cli-args.ts @@ -27,6 +27,12 @@ export type CliAction = * (read-only) and --watch (one-shot). */ resetSchema: boolean + /** + * When true, regenerate output in memory and compare it against committed files on + * disk. Exits non-zero when any file is stale, missing, or extra. Writes nothing. + * Incompatible with --watch. + */ + checkDrift: boolean } | { action: 'error'; message: string } @@ -53,6 +59,7 @@ export function parseCliArgs(argv: string[], cwd: string): CliAction { const watch = args.includes('--watch') const check = args.includes('--check') const resetSchema = args.includes('--reset-schema') + const checkDrift = args.includes('--check-drift') if (check && watch) { return { @@ -63,6 +70,15 @@ export function parseCliArgs(argv: string[], cwd: string): CliAction { } } + if (checkDrift && watch) { + return { + action: 'error', + message: + 'Error: --check-drift and --watch cannot be used together. ' + + '--check-drift is a read-only one-shot verification; it cannot watch for changes.', + } + } + if (resetSchema && check) { return { action: 'error', @@ -122,5 +138,6 @@ export function parseCliArgs(argv: string[], cwd: string): CliAction { watch, check, resetSchema, + checkDrift, } } diff --git a/packages/openapi-zod-ts/src/cli.ts b/packages/openapi-zod-ts/src/cli.ts index eed8ba25..fadebddd 100644 --- a/packages/openapi-zod-ts/src/cli.ts +++ b/packages/openapi-zod-ts/src/cli.ts @@ -29,6 +29,11 @@ if (parsed.action === 'help') { ' any schema drift. Use as a CI gate alongside fallow:audit.', ' Drift detection applies only when input_schema is configured.', ' Cannot be combined with --watch.', + ' --check-drift Regenerate all output files in memory and compare them against', + ' committed files on disk. Exits non-zero when any file is stale,', + ' missing, or extra. Writes nothing. Emits GitHub Actions inline', + ' annotations when GITHUB_ACTIONS=true. Cannot be combined with', + ' --watch. Can be combined with --check (runs both checks).', ' --reset-schema Re-bootstrap the input_schema file from the spec, overwriting it.', ' The remedy for drift reported by --check. Destructive: schema', ' customizations are replaced. Cannot be combined with --check or', @@ -53,10 +58,26 @@ if (parsed.action === 'error') { process.exit(1) } -const { cwd, configFile, inputOverride, outputOverride, watch: watchMode, check, resetSchema } = parsed +const { + cwd, + configFile, + inputOverride, + outputOverride, + watch: watchMode, + check, + resetSchema, + checkDrift, +} = parsed async function runGenerate(): Promise { - await generate(cwd, { configPath: configFile, inputOverride, outputOverride, check, resetSchema }) + await generate(cwd, { + configPath: configFile, + inputOverride, + outputOverride, + check, + resetSchema, + checkDrift, + }) } if (!watchMode) { diff --git a/packages/openapi-zod-ts/src/drift-check.ts b/packages/openapi-zod-ts/src/drift-check.ts new file mode 100644 index 00000000..f0accd03 --- /dev/null +++ b/packages/openapi-zod-ts/src/drift-check.ts @@ -0,0 +1,219 @@ +import { appendFileSync } from 'node:fs' +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +/** + * The result of comparing in-memory generator output against files on disk. + * + * - `stale`: file exists on disk but its content differs from what the generator produces today + * - `missing`: file expected by the generator but absent from disk + * - `extra`: file found on disk that the generator would not produce (stale artifact or manual addition) + */ +export interface DriftReport { + stale: Array<{ filename: string; reason: 'stale' }> + missing: Array<{ filename: string; reason: 'missing' }> + extra: Array<{ filename: string; reason: 'extra' }> + /** Total count of drifted files across all three categories. */ + total: number + /** True when no files are stale, missing, or extra. */ + clean: boolean +} + +/** + * Normalize file content for comparison: convert CRLF to LF and ensure a single + * trailing newline. This prevents false positives caused by line-ending differences + * between platforms or editors that strip/add trailing newlines. + */ +function normalizeContent(content: string): string { + const lf = content.replace(/\r\n/g, '\n') + return lf.endsWith('\n') ? lf : lf + '\n' +} + +/** Classify files from disk against the expected map, returning stale/missing/extra lists. */ +async function classifyFiles( + expected: Map, + diskFiles: string[], + outputDir: string +): Promise> { + const diskSet = new Set(diskFiles) + const expectedSet = new Set(expected.keys()) + + const stale: DriftReport['stale'] = [] + const missing: DriftReport['missing'] = [] + const extra: DriftReport['extra'] = [] + + for (const [filename, expectedContent] of expected) { + if (!diskSet.has(filename)) { + missing.push({ filename, reason: 'missing' }) + } else { + const diskContent = await readFile(join(outputDir, filename), 'utf-8') + if (normalizeContent(expectedContent) !== normalizeContent(diskContent)) { + stale.push({ filename, reason: 'stale' }) + } + } + } + + for (const filename of diskFiles) { + if (!expectedSet.has(filename)) { + extra.push({ filename, reason: 'extra' }) + } + } + + return { stale, missing, extra } +} + +/** + * Compare in-memory generator output against files on disk. + * + * @param expected Map of filename (basename only, e.g. "models.ts") to formatted file content. + * This must be the already-formatted output (prettier applied) to match the write path. + * @param outputDir Absolute path to the directory that holds the committed generated files. + * @returns A DriftReport describing any stale, missing, or extra files. + */ +export async function compareOutput( + expected: Map, + outputDir: string +): Promise { + let diskFiles: string[] + + try { + const entries = await readdir(outputDir, { withFileTypes: true }) + diskFiles = entries.filter((e) => e.isFile()).map((e) => e.name) + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException + if (nodeErr.code === 'ENOENT') { + // Output directory does not exist: every expected file is missing, zero extra. + const missing = Array.from(expected.keys()).map((filename) => ({ + filename, + reason: 'missing' as const, + })) + const total = missing.length + return { stale: [], missing, extra: [], total, clean: total === 0 } + } + if (nodeErr.code === 'ENOTDIR') { + throw new Error( + `Output path '${outputDir}' is a file, not a directory. Check the 'output' config option.` + ) + } + throw err + } + + const { stale, missing, extra } = await classifyFiles(expected, diskFiles, outputDir) + const total = stale.length + missing.length + extra.length + return { stale, missing, extra, total, clean: total === 0 } +} + +/** Build the human-readable diagnostic lines for a failed drift report. */ +function buildDiagnosticLines(report: DriftReport, fixCommand: string): string[] { + const lines: string[] = [ + 'Drift check failed: generated output does not match committed files.', + '', + ] + + for (const entry of report.stale) { + lines.push( + ` STALE ${entry.filename} (content differs from what the generator produces today)` + ) + } + for (const entry of report.missing) { + lines.push(` MISSING ${entry.filename} (expected by the generator but not found on disk)`) + } + for (const entry of report.extra) { + lines.push( + ` EXTRA ${entry.filename} (on disk but not produced by the generator; delete it or regenerate)` + ) + } + + lines.push('') + lines.push(`Fix: ${fixCommand}`) + return lines +} + +/** + * Emit `::error file=::` GitHub Actions workflow commands for stale and missing files. + * When outputDir is provided it is prepended to the filename so GitHub can pin the annotation + * to the file in the PR diff (e.g. `src/api/models.ts` instead of bare `models.ts`). + */ +function emitGithubAnnotations( + report: DriftReport, + fixCommand: string, + outputDir: string | undefined +): void { + const prefix = outputDir !== undefined && outputDir !== '' ? `${outputDir}/` : '' + for (const entry of report.stale) { + console.log( + `::error file=${prefix}${entry.filename}::Drift detected: ${entry.filename} is stale. Run '${fixCommand}' and commit the result.` + ) + } + for (const entry of report.missing) { + console.log( + `::error file=${prefix}${entry.filename}::Drift detected: ${entry.filename} is missing. Run '${fixCommand}' and commit the result.` + ) + } +} + +/** Append a markdown summary table to the GITHUB_STEP_SUMMARY file. */ +function writeStepSummary(report: DriftReport, fixCommand: string, summaryPath: string): void { + const summaryLines = ['', '## Drift check failed', '', '| Status | File |', '|--------|------|'] + + for (const entry of report.stale) { + summaryLines.push(`| STALE | \`${entry.filename}\` |`) + } + for (const entry of report.missing) { + summaryLines.push(`| MISSING | \`${entry.filename}\` |`) + } + for (const entry of report.extra) { + summaryLines.push(`| EXTRA | \`${entry.filename}\` |`) + } + summaryLines.push('') + summaryLines.push(`**Fix:** run \`${fixCommand}\` and commit the result.`) + summaryLines.push('') + + try { + appendFileSync(summaryPath, summaryLines.join('\n'), 'utf-8') + } catch (err) { + console.warn( + `Warning: could not write to GITHUB_STEP_SUMMARY (${(err as Error).message}). Diagnostics above are still complete.` + ) + } +} + +/** + * Print per-file diagnostics for a DriftReport and optionally emit GitHub Actions + * annotations and a step summary panel. + * + * When `opts.github` is true, this function emits `::error file=::` + * workflow commands for each stale or missing file so GitHub renders inline + * annotations on the PR. It also appends a markdown summary panel to the file + * at `process.env.GITHUB_STEP_SUMMARY` when that env var is set. + * + * @param report The DriftReport returned by compareOutput(). + * @param opts.github When true, emit GitHub Actions annotations. Set this only when + * process.env.GITHUB_ACTIONS === 'true'. + * @param opts.fixCommand The exact shell command consumers should run to fix drift. + * @param opts.outputDir The output directory path relative to the repo root. When + * provided, annotation file paths are prefixed so GitHub can + * pin them to the correct file in the PR diff. + * @returns An object with the appropriate process exit code: 0 when clean, 1 when drifted. + */ +export function reportDrift( + report: DriftReport, + opts: { github: boolean; fixCommand: string; outputDir?: string } +): { exitCode: number } { + if (report.clean) { + console.log('Drift check passed: all generated files are up to date.') + return { exitCode: 0 } + } + + console.error(buildDiagnosticLines(report, opts.fixCommand).join('\n')) + + if (opts.github) { + emitGithubAnnotations(report, opts.fixCommand, opts.outputDir) + const summaryPath = process.env['GITHUB_STEP_SUMMARY'] + if (summaryPath !== undefined && summaryPath !== '') { + writeStepSummary(report, opts.fixCommand, summaryPath) + } + } + + return { exitCode: 1 } +} diff --git a/packages/openapi-zod-ts/src/generator.ts b/packages/openapi-zod-ts/src/generator.ts index 114fb221..7af4195f 100644 --- a/packages/openapi-zod-ts/src/generator.ts +++ b/packages/openapi-zod-ts/src/generator.ts @@ -2,6 +2,7 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises' import { join, relative, resolve } from 'node:path' import { loadConfig, loadConfigs, type Config } from './config.js' import { runProjects } from './config-core.js' +import { compareOutput, reportDrift } from './drift-check.js' import { parseSpec } from './parser.js' import { generateTypes } from './plugins/types.js' import { generateClientConfig } from './plugins/client-config.js' @@ -31,6 +32,12 @@ export interface GenerateOptions { * is being regenerated. Destructive: customizations in the schema file are replaced. */ resetSchema?: boolean + /** + * When true, regenerate all output files in memory, compare them against committed + * files on disk, and exit non-zero if any file is stale, missing, or extra. Nothing + * is written to disk. Incompatible with watch mode. + */ + checkDrift?: boolean } async function formatTs(content: string, filePath: string): Promise { @@ -51,6 +58,103 @@ function applyOverrides(config: Config, opts: GenerateOptions): Config { return result } +/** Parameters for buildFinalOutputMap. */ +interface BuildFinalOutputMapParams { + generatedFiles: Array<{ filename: string; content: string }> + serverFile: { filename: string; content: string } | undefined + outputDir: string + config: Config + spec: Awaited> + writableVariantMap: ReturnType + driftPlan: SchemaDriftPlan | undefined + resetSchema: boolean + cwd: string +} + +/** + * Build a Map of filename -> formatted content representing everything that belongs in + * the output directory. This is the single authoritative source used by both the write + * path and the --check-drift path so the two can never diverge. + * + * Inclusion rules: + * - All base generated files (models.ts, client.ts, client-config.ts, index.ts). + * - The server file when config.server_client is true. + * - When input_schema is configured, the schema file already exists, and resetSchema is + * false, models.ts and client.ts are replaced with their schema-enhanced versions. + * + * The user-owned schema file (zod.ts / input_schema) is intentionally excluded: it is + * bootstrapped once on first run and never overwritten by subsequent regenerations, so + * it must not appear in the expected output set. + */ +async function buildFinalOutputMap( + params: BuildFinalOutputMapParams +): Promise> { + const { + generatedFiles, + serverFile, + outputDir, + config, + spec, + writableVariantMap, + driftPlan, + resetSchema, + cwd, + } = params + const map = new Map() + + for (const file of generatedFiles) { + const filePath = join(outputDir, file.filename) + map.set(file.filename, await formatTs(file.content, filePath)) + } + + if (serverFile !== undefined) { + const serverFilePath = join(outputDir, serverFile.filename) + map.set(serverFile.filename, await formatTs(serverFile.content, serverFilePath)) + } + + // When input_schema is configured, the schema file already exists, and we are not + // resetting: replace models.ts and client.ts with schema-enhanced versions. This is + // the exact condition that writeZodIntegration previously used to overwrite those + // files on the write path; encoding it here ensures the drift-check path uses the + // same enhanced content without any duplication. + if ( + config.input_schema !== undefined && + driftPlan !== undefined && + driftPlan.schemaExists && + !resetSchema + ) { + const schemaPath = resolve(cwd, config.input_schema) + const relPath = relative(outputDir, schemaPath) + const schemaImportPath = (relPath.startsWith('.') ? '' : './') + relPath.replace(/\.ts$/, '.js') + + const enhancedTypes = generateTypes( + spec, + { schemaNames: driftPlan.exportedSchemas, schemaImportPath }, + writableVariantMap + ) + const enhancedClient = generateClient( + spec, + { + schemaNames: driftPlan.exportedSchemas, + schemaImportPath, + errorBodyType: config.error_body_type, + errorBodyTypeImport: config.error_body_type_import, + }, + writableVariantMap + ) + map.set( + enhancedTypes.filename, + await formatTs(enhancedTypes.content, join(outputDir, enhancedTypes.filename)) + ) + map.set( + enhancedClient.filename, + await formatTs(enhancedClient.content, join(outputDir, enhancedClient.filename)) + ) + } + + return map +} + /** * Run generation for a single resolved config. Used internally by generate() * for both single-spec and each project in a multi-spec config. @@ -106,6 +210,7 @@ async function generateOne( const check = opts.check === true const resetSchema = opts.resetSchema === true + const checkDrift = opts.checkDrift === true // Drift detection is a read-only pass that runs BEFORE any writes. This keeps // generation atomic: when a drift gate fails, the output directory is left @@ -132,38 +237,67 @@ async function generateOne( return } + // Phase 3 (pre-write): build the optional server file so it can be included in the + // expected set for --check-drift. We generate it here (before any disk I/O) so that + // the drift check and the write path share exactly the same conditional logic. + const serverFile = config.server_client === true ? generateServer(spec) : undefined + + // Build the canonical output map: filename -> formatted content. This is the single + // source of truth for what belongs in the output directory. Both the write path and + // the drift-check path consume this map, ensuring they can never diverge. + // zod.ts (the user-owned schema file) is intentionally excluded: the generator + // never overwrites it after the first bootstrap, so it must not be in the expected set. + const outputMap = await buildFinalOutputMap({ + generatedFiles, + serverFile, + outputDir, + config, + spec, + writableVariantMap, + driftPlan, + resetSchema, + cwd, + }) + + // Output drift check: regenerate in memory, compare against committed files on disk. + // This runs BEFORE any mkdir/writeFile so the output directory is left untouched on + // failure. + if (checkDrift) { + const report = await compareOutput(outputMap, outputDir) + const isGithubActions = process.env['GITHUB_ACTIONS'] === 'true' + const configDesc = opts.configPath !== undefined ? `--config ${opts.configPath}` : '' + const fixCommand = ['openapi-zod-ts', configDesc].filter(Boolean).join(' ') + const relativeOutputDir = relative(process.cwd(), outputDir) + const { exitCode } = reportDrift(report, { + github: isGithubActions, + fixCommand, + outputDir: relativeOutputDir, + }) + if (exitCode !== 0) { + throw new Error( + `${prefix}Output drift detected: generated files do not match what is committed. ` + + `Run '${fixCommand}' and commit the result.` + ) + } + return + } + console.log(`${prefix}Writing output to: ${outputDir}`) await mkdir(outputDir, { recursive: true }) - for (const file of generatedFiles) { - const filePath = join(outputDir, file.filename) - await writeFile(filePath, await formatTs(file.content, filePath), 'utf-8') - console.log(`${prefix} ✓ ${file.filename}`) - } - - // Phase 3: optional server client factory - if (config.server_client === true) { - const serverFile = generateServer(spec) - const serverFilePath = join(outputDir, serverFile.filename) - await writeFile(serverFilePath, await formatTs(serverFile.content, serverFilePath), 'utf-8') - console.log(`${prefix} ✓ ${serverFile.filename}`) + for (const [filename, content] of outputMap) { + const filePath = join(outputDir, filename) + await writeFile(filePath, content, 'utf-8') + console.log(`${prefix} ✓ ${filename}`) } - // Phase 4: Zod integration (bootstrap on first run, schema-enhanced thereafter). + // Phase 4: Zod integration (bootstrap on first run; schema-enhanced files are + // already in outputMap and written above by the write loop). if (config.input_schema !== undefined && driftPlan !== undefined) { - await writeZodIntegration( - cwd, - config, - spec, - outputDir, - prefix, - writableVariantMap, - driftPlan, - resetSchema - ) + await writeZodIntegration(cwd, config, spec, prefix, driftPlan, resetSchema) } - console.log(`${prefix}Done! Generated ${generatedFiles.length} file(s).`) + console.log(`${prefix}Done! Generated ${outputMap.size} file(s).`) } /** Result of the read-only drift detection pass. */ @@ -235,18 +369,16 @@ async function detectSchemaDrift( } /** - * Write the Zod integration files. Only called in non-check mode after the drift - * gate has passed. On first run it bootstraps the input_schema file (write once, - * never overwritten). On subsequent runs it re-generates models.ts and client.ts - * with Zod validation using the schema names discovered during drift detection. + * Write the Zod integration schema file. Only called in non-check, non-drift-check mode + * after the drift gate has passed. On first run it bootstraps the input_schema file + * (write once, never overwritten thereafter). The schema-enhanced models.ts and client.ts + * are handled by buildFinalOutputMap and written by the output map write loop above. */ async function writeZodIntegration( cwd: string, config: Config, spec: Awaited>, - outputDir: string, prefix: string, - writableVariantMap: ReturnType, plan: SchemaDriftPlan, resetSchema: boolean ): Promise { @@ -255,8 +387,8 @@ async function writeZodIntegration( if (!plan.schemaExists || resetSchema) { // First run bootstraps the schema file (write once). --reset-schema force-rewrites // an existing file from the spec, which is how a user clears reported drift. Either - // way the schema-enhanced regeneration of models/client/router happens on the next - // run, against the freshly written file. + // way the schema-enhanced regeneration of models/client happens on the next run, + // against the freshly written file. const zodFile = generateZodSchemas(spec) await writeFile(schemaPath, zodFile.content, 'utf-8') console.log( @@ -267,41 +399,7 @@ async function writeZodIntegration( return } - console.log( - `${prefix}Skipping ${config.input_schema}: already exists (edit freely, it's yours).` - ) - - // Compute relative import path for use in generated imports. - // 'schemas.ts' -> './schemas.js', '../schemas.ts' -> '../schemas.js' - const relPath = relative(outputDir, schemaPath) - const schemaImportPath = (relPath.startsWith('.') ? '' : './') + relPath.replace(/\.ts$/, '.js') - - // Re-generate (overwrite) models.ts and client.ts with schema-enhanced versions. - const enhancedTypes = generateTypes( - spec, - { schemaNames: plan.exportedSchemas, schemaImportPath }, - writableVariantMap - ) - const enhancedClient = generateClient( - spec, - { - schemaNames: plan.exportedSchemas, - schemaImportPath, - errorBodyType: config.error_body_type, - errorBodyTypeImport: config.error_body_type_import, - }, - writableVariantMap - ) - const enhancedTypesPath = join(outputDir, enhancedTypes.filename) - const enhancedClientPath = join(outputDir, enhancedClient.filename) - await writeFile(enhancedTypesPath, await formatTs(enhancedTypes.content, enhancedTypesPath), 'utf-8') - await writeFile( - enhancedClientPath, - await formatTs(enhancedClient.content, enhancedClientPath), - 'utf-8' - ) - console.log(`${prefix} ✓ models.ts (schema-enhanced, types from z.infer)`) - console.log(`${prefix} ✓ client.ts (schema-enhanced, Zod validation added)`) + console.log(`${prefix}Skipping ${config.input_schema}: already exists (edit freely, it's yours).`) } // fallow-ignore-next-line complexity @@ -328,8 +426,7 @@ export async function generate(cwd: string, opts?: GenerateOptions | string): Pr // Overrides are incompatible with multi-spec "projects" array configs. When overrides // are present we fall back to single-spec loading so overrides apply to a single config. - const hasOverrides = - options.inputOverride !== undefined || options.outputOverride !== undefined + const hasOverrides = options.inputOverride !== undefined || options.outputOverride !== undefined if (hasOverrides) { const config = applyOverrides(await loadConfig(cwd, options.configPath), options)