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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,46 @@ await describePackagesApiSnapshots({

Run `vitest -u` to update snapshots when you intentionally change the API.

#### Per-entry hooks

Three hooks let you intervene per entry point, at increasing depth. They live on `ApiSnapshotOptions`, so they work with `snapshotApiPerEntry`, `describePackagesApiSnapshots`, `generateApiSnapshot`, the CLI helpers, and the rolldown plugin alike.

`entryFilter` skips entries entirely — useful when a package exports large default-data objects (themes, color palettes) that are low-signal for an API guard:

```ts
import { describePackagesApiSnapshots } from 'tsnapi/vitest'

await describePackagesApiSnapshots({
entryFilter: ({ packageName, entryName }) =>
entryName !== './theme' && entryName !== './colors',
})
```

`transformEntries` modifies the structural representation before it is serialized: each snapshot surface is a list of entries (`{ name, kind, text }` — one per export or referenced declaration). Mutate the array in place, or return a replacement array:

```ts
await describePackagesApiSnapshots({
transformEntries(entries, { entryName, surface }) {
for (const entry of entries) {
// Collapse a huge data export to an opaque declaration,
// while still guarding everything else
if (entry.name === 'theme' && surface === 'dts')
entry.text = 'export declare const theme: Record<string, string>'
}
// or filter: return entries.filter(e => e.kind !== 'variable')
},
})
```

`transformSnapshot` rewrites the final snapshot string (header excluded) before it is written or compared:

```ts
await describePackagesApiSnapshots({
transformSnapshot: ({ entryName, surface, content }) =>
surface === 'dts' ? content.replaceAll('\u00A0', ' ') : null, // null keeps content unchanged
})
```

#### Low-level

You can also use `generateApiSnapshot` directly with Vitest's built-in snapshot system:
Expand Down Expand Up @@ -379,9 +419,17 @@ interface ApiSnapshotOptions {
* @default false
*/
allowBreaking?: boolean
/** Skip entry points: return false to skip. */
entryFilter?: (ctx: SnapshotEntryContext) => boolean | void
/** Modify the structural entries of a surface before serialization. */
transformEntries?: (entries: Entry[], ctx: TransformEntriesContext) => Entry[] | null | void
/** Rewrite snapshot content before write/compare. */
transformSnapshot?: (ctx: TransformSnapshotContext) => string | null | void
}
```

See [Per-entry hooks](#per-entry-hooks) for details on the three hooks.

### `typeWidening`

When `typeWidening` is `true` (default), literal values are widened to hide implementation details:
Expand Down
24 changes: 24 additions & 0 deletions __snapshots__/tsnapi/index.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export interface ApiSnapshotOptions {
referenceTracingDepth?: number;
update?: boolean;
allowBreaking?: boolean;
entryFilter?: (_: SnapshotEntryContext) => boolean | void;
transformEntries?: (_: Entry[], _: TransformEntriesContext) => Entry[] | null | void;
transformSnapshot?: (_: TransformSnapshotContext) => string | null | void;
}
export interface BreakingChange {
entryName: string;
Expand All @@ -21,11 +24,20 @@ export interface BreakingChange {
widened: string[];
added: string[];
}
export interface Entry {
name: string;
text: string;
kind: EntryKind;
}
export interface ResolvedEntry {
name: string;
runtime: string | null;
dts: string | null;
}
export interface SnapshotEntryContext {
packageName: string;
entryName: string;
}
export interface SnapshotExtensions {
runtime: string;
dts: string;
Expand All @@ -49,6 +61,17 @@ export interface SnapshotResult {
diff: string | null;
breaking: BreakingChange[];
}
export interface TransformEntriesContext extends SnapshotEntryContext {
surface: SnapshotSurface;
}
export interface TransformSnapshotContext extends TransformEntriesContext {
content: string;
}
// #endregion

// #region Types
export type EntryKind = 'interface' | 'type' | 'enum' | 'class' | 'namespace' | 'function' | 'variable' | 'default' | 're-export' | 'referenced' | 'other';
export type SnapshotSurface = 'runtime' | 'dts';
// #endregion

// #region Functions
Expand Down Expand Up @@ -82,5 +105,6 @@ interface ExtractOptions {
typeWidening?: boolean;
categorizedExports?: boolean;
referenceTracingDepth?: number;
transformEntries?: (_: Entry[]) => Entry[] | null | void;
}
// #endregion
22 changes: 21 additions & 1 deletion __snapshots__/tsnapi/vitest.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,36 @@ export interface DescribePackagesApiSnapshotsOptions extends SnapshotApiOptions
beforeEach?: (_: PackageContext) => void | Promise<void>;
afterEach?: (_: PackageContext) => void | Promise<void>;
}
export interface Entry {
name: string;
text: string;
kind: EntryKind;
}
export interface PackageContext {
cwd: string;
workspaceRoot: string;
packageRoot: string;
packageName: string;
outputDir: string;
}
export interface SnapshotApiOptions extends Pick<ApiSnapshotOptions, 'omitArgumentNames' | 'header' | 'allowBreaking' | 'referenceTracingDepth'> {
export interface SnapshotApiOptions extends Pick<ApiSnapshotOptions, 'omitArgumentNames' | 'header' | 'allowBreaking' | 'referenceTracingDepth' | 'typeWidening' | 'categorizedExports' | 'entryFilter' | 'transformEntries' | 'transformSnapshot'> {
outputDir?: string;
}
export interface SnapshotEntryContext {
packageName: string;
entryName: string;
}
export interface TransformEntriesContext extends SnapshotEntryContext {
surface: SnapshotSurface;
}
export interface TransformSnapshotContext extends TransformEntriesContext {
content: string;
}
// #endregion

// #region Types
export type EntryKind = 'interface' | 'type' | 'enum' | 'class' | 'namespace' | 'function' | 'variable' | 'default' | 're-export' | 'referenced' | 'other';
export type SnapshotSurface = 'runtime' | 'dts';
// #endregion

// #region Functions
Expand Down
7 changes: 4 additions & 3 deletions src/core/extract-dts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,12 @@ export async function extractDts(fileName: string, code: string, options?: impor
traceReferencedDeclarations(s, program, declMap, entries, referenceTracingDepth)
}

const finalEntries = options?.transformEntries?.(entries) ?? entries
if (categorized) {
return formatGroupedEntries(entries)
return formatGroupedEntries(finalEntries)
}
entries.sort((a, b) => a.name.localeCompare(b.name))
return `${entries.map(e => e.text).join('\n')}\n`
finalEntries.sort((a, b) => a.name.localeCompare(b.name))
return `${finalEntries.map(e => e.text).join('\n')}\n`
}

/**
Expand Down
9 changes: 6 additions & 3 deletions src/core/extract-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface ExtractOptions {
typeWidening?: boolean
categorizedExports?: boolean
referenceTracingDepth?: number
/** Transform the extracted entries before serialization; mutate in place or return a replacement array. */
transformEntries?: (entries: Entry[]) => Entry[] | null | void
}

/** Minimal marker prepended above declarations that carry an `@deprecated` tag. */
Expand Down Expand Up @@ -218,11 +220,12 @@ export async function extractRuntime(fileName: string, code: string, options?: E
applyDeprecated(entries, entriesBefore)
}

const finalEntries = options?.transformEntries?.(entries) ?? entries
if (categorized) {
return formatGroupedEntries(entries)
return formatGroupedEntries(finalEntries)
}
entries.sort((a, b) => a.name.localeCompare(b.name))
return `${entries.map(e => e.text).join('\n')}\n`
finalEntries.sort((a, b) => a.name.localeCompare(b.name))
return `${finalEntries.map(e => e.text).join('\n')}\n`
}

/**
Expand Down
30 changes: 30 additions & 0 deletions src/core/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Entry } from './kind.ts'
import type { ApiSnapshotOptions, SnapshotSurface } from './types.ts'

export interface EntryHooks {
/** Whether an entry passes the user's `entryFilter`. */
includeEntry: (entryName: string) => boolean
/** Bind the user's `transformEntries` hook to one entry + surface, for `ExtractOptions`. */
transformEntriesFor: (entryName: string, surface: SnapshotSurface) => ((entries: Entry[]) => Entry[] | null | void) | undefined
/** Apply the user's `transformSnapshot` hook to generated content. */
transformSnapshot: (entryName: string, surface: SnapshotSurface, content: string) => string
}

/**
* Bind the per-entry hooks from {@link ApiSnapshotOptions} to a package, so
* every integration (core, Vitest, rolldown) applies them identically.
*/
export function createEntryHooks(
packageName: string,
options?: Pick<ApiSnapshotOptions, 'entryFilter' | 'transformEntries' | 'transformSnapshot'>,
): EntryHooks {
const { entryFilter, transformEntries, transformSnapshot } = options ?? {}
return {
includeEntry: entryName => entryFilter?.({ packageName, entryName }) !== false,
transformEntriesFor: (entryName, surface) => transformEntries
? entries => transformEntries(entries, { packageName, entryName, surface })
: undefined,
transformSnapshot: (entryName, surface, content) =>
transformSnapshot?.({ packageName, entryName, surface, content }) ?? content,
}
}
34 changes: 23 additions & 11 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { hasArgvFlag } from './argv.ts'
import { analyzeApiChanges, formatBreakingChanges, isBreakingChange } from './breaking.ts'
import { extractDts } from './extract-dts.ts'
import { extractRuntime } from './extract-runtime.ts'
import { createEntryHooks } from './hooks.ts'
import { resolvePackageEntries } from './resolve.ts'
import {
compareSnapshots,
Expand All @@ -20,14 +21,16 @@ export type { BreakingChange } from './breaking.ts'
export { analyzeApiChanges, formatBreakingChanges, isBreakingChange } from './breaking.ts'
export { extractDts } from './extract-dts.ts'
export { extractRuntime } from './extract-runtime.ts'
export type { EntryKind } from './kind.ts'
export type { EntryHooks } from './hooks.ts'
export { createEntryHooks } from './hooks.ts'
export type { Entry, EntryKind } from './kind.ts'
export { KIND_LABELS, KIND_ORDER } from './kind.ts'
export type { DiffMember, DiffStatus, Member } from './members.ts'
export { diffMembers, displayName, parseMembers } from './members.ts'
export { resolvePackageEntries, resolvePackageEntriesSync } from './resolve.ts'
export type { SnapshotExtensions, SnapshotFile, SnapshotMismatch } from './snapshot.ts'
export { compareSnapshots, formatMismatchError, generateHeader, readSnapshot, stripHeader, writeSnapshot } from './snapshot.ts'
export type { ApiSnapshotOptions, ResolvedEntry, SnapshotResult } from './types.ts'
export type { ApiSnapshotOptions, ResolvedEntry, SnapshotEntryContext, SnapshotResult, SnapshotSurface, TransformEntriesContext, TransformSnapshotContext } from './types.ts'
export { discoverPackages, isPrivatePackage, readPackageName, readWorkspacePatterns, resolveWorkspacePackages } from './workspace.ts'

async function readPackageName(cwd: string): Promise<string> {
Expand Down Expand Up @@ -165,20 +168,23 @@ export async function generateApiSnapshot(cwd: string, options?: ApiSnapshotOpti
const result: Record<string, { runtime: string, dts: string }> = {}
const extractOptions = { omitArgumentNames: options?.omitArgumentNames, typeWidening: options?.typeWidening, categorizedExports: options?.categorizedExports, referenceTracingDepth: options?.referenceTracingDepth }
const showHeader = options?.header ?? true
const packageName = showHeader ? await readPackageName(cwd) : ''
const packageName = await readPackageName(cwd)
const hooks = createEntryHooks(packageName, options)
const chunkSourcesFor = createChunkSourceLoader()

for (const entry of entries) {
if (!hooks.includeEntry(entry.name))
continue
const runtime = entry.runtime
? await extractRuntime(entry.runtime, await readFile(entry.runtime, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.runtime)).runtime })
? await extractRuntime(entry.runtime, await readFile(entry.runtime, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.runtime)).runtime, transformEntries: hooks.transformEntriesFor(entry.name, 'runtime') })
: ''
const dts = entry.dts
? await extractDts(entry.dts, await readFile(entry.dts, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.dts)).dts })
? await extractDts(entry.dts, await readFile(entry.dts, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.dts)).dts, transformEntries: hooks.transformEntriesFor(entry.name, 'dts') })
: ''
const prefix = showHeader ? generateHeader(packageName, entry.name) : ''
result[entry.name] = {
runtime: prefix + (runtime.trim() || '/* no exports */'),
dts: prefix + (dts.trim() || '/* no exports */'),
runtime: prefix + hooks.transformSnapshot(entry.name, 'runtime', runtime.trim() || '/* no exports */'),
dts: prefix + hooks.transformSnapshot(entry.name, 'dts', dts.trim() || '/* no exports */'),
}
}

Expand Down Expand Up @@ -218,25 +224,31 @@ async function snapshotEntries(
const resolvedOutputDir = resolve(cwd, outputDir)
const extractOptions = { omitArgumentNames: options?.omitArgumentNames, typeWidening: options?.typeWidening, categorizedExports: options?.categorizedExports, referenceTracingDepth: options?.referenceTracingDepth }
const showHeader = options?.header ?? true
const packageName = showHeader ? await readPackageName(cwd) : ''
const packageName = await readPackageName(cwd)
const hooks = createEntryHooks(packageName, options)
const chunkSourcesFor = createChunkSourceLoader()

const mismatches: SnapshotResult['mismatches'] = []
const allMismatchDetails: import('./snapshot.ts').SnapshotMismatch[] = []
const breaking: SnapshotResult['breaking'] = []

for (const entry of entries) {
if (!hooks.includeEntry(entry.name))
continue
const stem = entryNameToStem(entry.name)

const runtime = entry.runtime
? await extractRuntime(entry.runtime, await readFile(entry.runtime, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.runtime)).runtime })
? await extractRuntime(entry.runtime, await readFile(entry.runtime, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.runtime)).runtime, transformEntries: hooks.transformEntriesFor(entry.name, 'runtime') })
: ''
const dts = entry.dts
? await extractDts(entry.dts, await readFile(entry.dts, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.dts)).dts })
? await extractDts(entry.dts, await readFile(entry.dts, 'utf-8'), { ...extractOptions, chunkSources: (await chunkSourcesFor(entry.dts)).dts, transformEntries: hooks.transformEntriesFor(entry.name, 'dts') })
: ''

const header = showHeader ? generateHeader(packageName, entry.name) : undefined
const current = { runtime, dts }
const current = {
runtime: hooks.transformSnapshot(entry.name, 'runtime', runtime),
dts: hooks.transformSnapshot(entry.name, 'dts', dts),
}
const existing = await readSnapshot(resolvedOutputDir, stem, ext)

if (!existing) {
Expand Down
61 changes: 61 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
import type { Entry } from './kind.ts'

/** Which snapshot surface a hook is operating on. */
export type SnapshotSurface = 'runtime' | 'dts'

/** Identifies the entry point a per-entry hook is running for. */
export interface SnapshotEntryContext {
/** Package name from `package.json` (`'unknown'` when unavailable). */
packageName: string
/** Export path of the entry, e.g. `'.'`, `'./utils'`. */
entryName: string
}

export interface TransformEntriesContext extends SnapshotEntryContext {
/** Surface being generated. */
surface: SnapshotSurface
}

export interface TransformSnapshotContext extends TransformEntriesContext {
/** Generated snapshot content for the surface (header excluded). */
content: string
}

export interface ApiSnapshotOptions {
/**
* Snapshot output directory, relative to the project root.
Expand Down Expand Up @@ -89,6 +112,44 @@ export interface ApiSnapshotOptions {
* @default false
*/
allowBreaking?: boolean

/**
* Filter entry points before snapshotting.
* Return `false` to skip the entry entirely. Keep it pure — integrations
* may call it more than once per entry (e.g. Vitest filters both at test
* registration and at generation time).
* @example
* ```ts
* entryFilter: ({ entryName }) => entryName !== './theme'
* ```
*/
entryFilter?: (ctx: SnapshotEntryContext) => boolean | void

/**
* Transform the structural representation of an entry's exports before it
* is serialized into snapshot text. Runs once per surface with the full
* list of extracted entries (each an export or referenced declaration with
* `name`, `kind`, and rendered `text`). Mutate the array in place, or
* return a replacement array; return `null`/`undefined` to keep it as-is.
* @example
* ```ts
* // Collapse a large data export to an opaque declaration
* transformEntries(entries, { surface }) {
* for (const entry of entries) {
* if (entry.name === 'theme' && surface === 'dts')
* entry.text = 'export declare const theme: Record<string, string>'
* }
* }
* ```
*/
transformEntries?: (entries: Entry[], ctx: TransformEntriesContext) => Entry[] | null | void

/**
* Rewrite snapshot content before it is written or compared.
* Receives the generated content for one surface (header excluded);
* return the replacement string, or `null`/`undefined` to leave it unchanged.
*/
transformSnapshot?: (ctx: TransformSnapshotContext) => string | null | void
}

export interface SnapshotResult {
Expand Down
Loading