Initial Commit
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-export (add-api-plugin-export)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-export`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Plugin descriptor factory
|
||||
|
||||
The package SHALL expose `createExportPlugin(options?: Partial<ExportPluginConfig>): PluginDescriptor<ExportPluginConfig>`, returning a `definePlugin(...)`-shaped descriptor named `"export"`, version `0.1.0`, with `optionalDependencies: ["database", "events"]`, `tags: ["export","data","csv","json","xlsx"]`, a `configSchema` bound to `ExportPluginConfigSchema`, and `defaults` merged from caller-supplied input + `DEFAULT_EXPORT_CONFIG`. The four lifecycle hooks (`onInit`, `onReady`, `onClose`) MUST be present and typed against `PluginContext<ExportPluginConfig>`.
|
||||
|
||||
#### Scenario: descriptor metadata is correct
|
||||
|
||||
- **WHEN** `createExportPlugin()` is called with no arguments
|
||||
- **THEN** the returned descriptor has `name === "export"`, `version === "0.1.0"`, `tags` contains `"csv"`/`"json"`/`"xlsx"`, and `optionalDependencies` contains `"database"` and `"events"`
|
||||
|
||||
#### Scenario: factory-supplied options merge with defaults
|
||||
|
||||
- **WHEN** `createExportPlugin({ basePath: "/api/data-export" })` is called
|
||||
- **THEN** `descriptor.defaults.basePath === "/api/data-export"` AND `descriptor.defaults.maxRows === 50000`
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"returns a plugin descriptor with the expected metadata"` AND `src/plugin/plugin.test.ts::"merges factory-supplied options with DEFAULT_EXPORT_CONFIG"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: onInit registers services and emits event
|
||||
|
||||
When `onInit(ctx)` runs, the plugin SHALL register `exportProviders` (a `Map<string, DataProvider>`) and `exportJobs` (a `Map<string, ExportJob>`) on `ctx.registerService`, then emit `"export:initialized"` on `ctx.eventBus`.
|
||||
|
||||
#### Scenario: services and event after init
|
||||
|
||||
- **WHEN** the descriptor's `onInit` is invoked with a real `PluginContext`
|
||||
- **THEN** `ctx.getService("exportProviders")` returns a `Map`, `ctx.getService("exportJobs")` returns a `Map`, and the bus has received `"export:initialized"`
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"onInit registers exportProviders and exportJobs and emits export:initialized"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: onReady mounts routes on the host app
|
||||
|
||||
`onReady(ctx)` SHALL resolve `app` from `ctx.getService("app")`, build the route group via `createExportRoutes({ providers, exportConfig, jobs, onExportStarted, onExportCompleted })`, mount it via `app.route(basePath, routes)`, then emit `"export:ready"`. If `app` is not present on the registry the hook SHALL throw `ExportError(EXPORT_CODES.PROVIDER_NOT_FOUND)` is NOT the right code — the hook SHALL throw a `ExportError` carrying code `EXPORT_CODES.UNSUPPORTED_FORMAT`-style precondition? No — the hook SHALL throw `ExportError` carrying a clear message naming the missing `app` slot.
|
||||
|
||||
#### Scenario: routes mount at configured basePath
|
||||
|
||||
- **WHEN** `onReady` runs against a `PluginContext` whose `ServiceRegistry` carries an `OpenAPIHono` at `app` and the config's `basePath === "/exports"`
|
||||
- **THEN** `app.route` has been invoked with `"/exports"` and an `OpenAPIHono` instance, AND `"export:ready"` has been emitted
|
||||
|
||||
#### Scenario: missing app slot fails fast
|
||||
|
||||
- **WHEN** `onReady` runs against a context with no `app` service registered
|
||||
- **THEN** the hook throws an `Error` (or `ExportError`) whose message names `"app"`
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"onReady mounts routes on the host app and emits export:ready"` AND `src/plugin/plugin.test.ts::"onReady throws when no app service is registered"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: onClose clears in-process state
|
||||
|
||||
`onClose()` SHALL clear both the `providers` and `jobs` maps held by the plugin closure so the next boot starts from an empty state.
|
||||
|
||||
#### Scenario: maps are cleared
|
||||
|
||||
- **WHEN** the descriptor's `onClose` is invoked after `onInit` populated state
|
||||
- **THEN** the same map instances passed to `onInit` are `.size === 0`
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"onClose clears providers and jobs maps"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: registerDataProvider helper
|
||||
|
||||
The package SHALL export `registerDataProvider(providers: Map<string, DataProvider>, provider: DataProvider): void` that sets `providers.set(provider.resource, provider)` (last-write-wins).
|
||||
|
||||
#### Scenario: register and overwrite
|
||||
|
||||
- **WHEN** the same `resource` is registered twice with different `DataProvider` values
|
||||
- **THEN** the second registration replaces the first
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"registerDataProvider stores the provider keyed by its resource"` AND `src/plugin/plugin.test.ts::"registerDataProvider overwrites an existing provider for the same resource"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod-validated plugin config
|
||||
|
||||
`ExportPluginConfigSchema` SHALL Zod-validate `{ basePath: string default "/exports", maxRows: int 1..1_000_000 default 50_000, maxFileSizeBytes: int >=1024 default 52_428_800, defaultFormat: "csv"|"json"|"xlsx" default "csv" }`. `ExportPluginConfig = z.infer<typeof ExportPluginConfigSchema>`.
|
||||
|
||||
#### Scenario: empty input fills all defaults
|
||||
|
||||
- **WHEN** `ExportPluginConfigSchema.safeParse({})` runs
|
||||
- **THEN** the result is `{ success: true, data: { basePath: "/exports", maxRows: 50000, maxFileSizeBytes: 52428800, defaultFormat: "csv" } }`
|
||||
|
||||
#### Scenario: invalid bounds rejected
|
||||
|
||||
- **WHEN** `ExportPluginConfigSchema.safeParse({ maxRows: 0 })`, `({ maxRows: 2_000_000 })`, `({ defaultFormat: "pdf" })`, or `({ maxFileSizeBytes: 100 })` run
|
||||
- **THEN** each result is `{ success: false }`
|
||||
|
||||
**Verified by:** `src/schemas/schemas.test.ts::"ExportPluginConfigSchema fills every default for empty input"` AND `src/schemas/schemas.test.ts::"ExportPluginConfigSchema rejects out-of-range maxRows"` AND `src/schemas/schemas.test.ts::"ExportPluginConfigSchema rejects maxRows above the 1_000_000 ceiling"` AND `src/schemas/schemas.test.ts::"ExportPluginConfigSchema rejects an invalid defaultFormat"` AND `src/schemas/schemas.test.ts::"ExportPluginConfigSchema rejects maxFileSizeBytes below 1024"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Domain Zod schemas
|
||||
|
||||
The package SHALL export Zod schemas `ExportFormatSchema` (`"csv"|"json"|"xlsx"`), `ExportStatusSchema` (`"pending"|"processing"|"completed"|"failed"`), `ColumnDefinitionSchema` (non-empty `key` + non-empty `header` + optional `transform`), `CreateExportJobSchema` (format + resource[1..128] + optional filters/columns(≥1)/filename(≤256)/options), `ExportJobSchema` (id + format + resource + status + filters? + filename? + nullable totalRows/exportedRows/fileSize/error + createdAt + completedAt nullable), `ExportJobListQuerySchema` (status? + format? + resource? + limit 1..100 default 20 + offset ≥0 default 0).
|
||||
|
||||
#### Scenario: rejects empty resource and out-of-range limit
|
||||
|
||||
- **WHEN** `CreateExportJobSchema.safeParse({ format:"csv", resource:"" })` and `ExportJobListQuerySchema.safeParse({ limit: 101 })` run
|
||||
- **THEN** both results are `{ success: false }`
|
||||
|
||||
**Verified by:** `src/schemas/schemas.test.ts::"CreateExportJobSchema rejects an empty resource"` AND `src/schemas/schemas.test.ts::"ExportJobListQuerySchema rejects limit above 100"` AND `src/schemas/schemas.test.ts::"ColumnDefinitionSchema rejects an empty header"` AND `src/schemas/schemas.test.ts::"ExportJobSchema accepts a completed job with all fields populated"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CSV exporter — RFC 4180
|
||||
|
||||
`CsvExporter.export(data, columns, options?)` SHALL produce a `text/csv` `ExportResult` whose `content` quotes any field containing the configured delimiter, a literal `"`, `\n`, or `\r`, doubling embedded `"` per RFC 4180. The header row is emitted unless `options.includeHeaders === false`. The default delimiter is `,`; `options.delimiter` overrides it.
|
||||
|
||||
#### Scenario: quoting and delimiter
|
||||
|
||||
- **WHEN** `escapeCsvField('say "hi"', ",")` runs
|
||||
- **THEN** the return value equals `'"say ""hi"""'`
|
||||
|
||||
#### Scenario: convertToCsv emits header then rows
|
||||
|
||||
- **WHEN** `convertToCsv([{id:1,name:"Alice"}], [{key:"id",header:"ID"},{key:"name",header:"Name"}])` runs
|
||||
- **THEN** the return value equals `"ID,Name\n1,Alice"`
|
||||
|
||||
**Verified by:** `src/exporters/csv/csv.test.ts::"escapeCsvField doubles embedded double-quotes per RFC 4180"` AND `src/exporters/csv/csv.test.ts::"escapeCsvField quotes a value containing the delimiter"` AND `src/exporters/csv/csv.test.ts::"convertToCsv emits a header row followed by data rows"` AND `src/exporters/csv/csv.test.ts::"convertToCsv applies column transforms before escaping"` AND `src/exporters/csv/csv.test.ts::"CsvExporter.export returns text/csv with a non-zero byteSize"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: JSON exporter
|
||||
|
||||
`JsonExporter.export(data, columns, options?)` SHALL produce an `application/json` `ExportResult` whose `content` is `JSON.stringify` of each row projected through `projectRow(row, columns)` (`col.header` as key, `col.transform(row[col.key])` as value when transform is set, raw `row[col.key]` otherwise). `options.pretty === true` SHALL use 2-space indentation.
|
||||
|
||||
#### Scenario: projection and pretty-print
|
||||
|
||||
- **WHEN** `convertToJson([{id:1,name:"Alice"}], [{key:"name",header:"Full Name"}], { pretty: true })` runs
|
||||
- **THEN** the parsed result equals `[{"Full Name":"Alice"}]` AND the raw string contains a `\n`
|
||||
|
||||
**Verified by:** `src/exporters/json/json.test.ts::"projectRow rekeys using column.header"` AND `src/exporters/json/json.test.ts::"projectRow applies column.transform when present"` AND `src/exporters/json/json.test.ts::"convertToJson produces compact output by default"` AND `src/exporters/json/json.test.ts::"convertToJson produces pretty output when options.pretty is true"` AND `src/exporters/json/json.test.ts::"JsonExporter.export returns application/json with a non-zero byteSize"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: XLSX (SpreadsheetML XML) exporter
|
||||
|
||||
`XlsxExporter.export(data, columns, options?)` SHALL produce an `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` `ExportResult` whose `content` is a SpreadsheetML XML document with a single `<sheetData>` block, one `<row>` per input record (plus a header row unless `options.includeHeaders === false`), and cells typed per JS value: numbers → `<v>`, booleans → `t="b"` with `1`/`0`, `null`/`undefined` → empty inline-string, everything else (incl. `Date`) → escaped inline-string.
|
||||
|
||||
#### Scenario: column-letter math wraps after Z
|
||||
|
||||
- **WHEN** `columnIndexToLetter(0)`, `columnIndexToLetter(25)`, and `columnIndexToLetter(26)` run
|
||||
- **THEN** the return values are `"A"`, `"Z"`, `"AA"`
|
||||
|
||||
#### Scenario: XML escaping
|
||||
|
||||
- **WHEN** `escapeXml('<a & "b">')` runs
|
||||
- **THEN** the return value equals `"<a & "b">"`
|
||||
|
||||
**Verified by:** `src/exporters/xlsx/xlsx.test.ts::"escapeXml escapes all five XML special characters"` AND `src/exporters/xlsx/xlsx.test.ts::"columnIndexToLetter wraps to AA after Z"` AND `src/exporters/xlsx/xlsx.test.ts::"formatXlsxCell emits a numeric cell for finite numbers"` AND `src/exporters/xlsx/xlsx.test.ts::"formatXlsxCell emits a boolean cell as t=b with 1 or 0"` AND `src/exporters/xlsx/xlsx.test.ts::"formatXlsxCell emits an inline-string cell for NaN"` AND `src/exporters/xlsx/xlsx.test.ts::"buildSpreadsheetXml includes a header row by default"` AND `src/exporters/xlsx/xlsx.test.ts::"buildSpreadsheetXml omits the header row when includeHeaders is false"` AND `src/exporters/xlsx/xlsx.test.ts::"XlsxExporter.export returns the spreadsheetml MIME type"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Export service surface
|
||||
|
||||
The package SHALL export `getExporter(format)` returning a fresh exporter per format, `inferColumns(data)` returning unique `{key,header:key}` entries deduped across rows, `runExport(provider, format, columns?, filters?, options?, config?)` that fetches via `provider.fetch(filters)`, applies row + size limits, and returns the `ExportResult`, plus `DEFAULT_EXPORT_CONFIG` and `ExportServiceConfig`.
|
||||
|
||||
#### Scenario: row limit raises ExportError
|
||||
|
||||
- **WHEN** `runExport(provider, "csv", undefined, undefined, undefined, { maxRows: 5, ... })` is called with a provider returning 10 rows
|
||||
- **THEN** the promise rejects with `ExportError` whose `code === EXPORT_CODES.ROW_LIMIT_EXCEEDED` AND `context.actual === 10` AND `context.limit === 5`
|
||||
|
||||
#### Scenario: file-size limit raises ExportError
|
||||
|
||||
- **WHEN** `runExport(provider, "csv", undefined, undefined, undefined, { maxFileSizeBytes: 100, ... })` is called with data that produces a >100-byte CSV
|
||||
- **THEN** the promise rejects with `ExportError` whose `code === EXPORT_CODES.SIZE_LIMIT_EXCEEDED`
|
||||
|
||||
#### Scenario: unsupported format raises ExportError
|
||||
|
||||
- **WHEN** `getExporter("pdf" as ExportFormat)` is called
|
||||
- **THEN** the call throws `ExportError` whose `code === EXPORT_CODES.UNSUPPORTED_FORMAT`
|
||||
|
||||
**Verified by:** `src/services/export-service.test.ts::"getExporter returns a CsvExporter for csv"` AND `src/services/export-service.test.ts::"getExporter throws ExportError(UNSUPPORTED_FORMAT) for an unknown format"` AND `src/services/export-service.test.ts::"inferColumns deduplicates keys across rows"` AND `src/services/export-service.test.ts::"runExport throws ExportError(ROW_LIMIT_EXCEEDED) when data exceeds maxRows"` AND `src/services/export-service.test.ts::"runExport throws ExportError(SIZE_LIMIT_EXCEEDED) when output exceeds maxFileSizeBytes"` AND `src/services/export-service.test.ts::"runExport passes filters through to the data provider"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: HTTP route group
|
||||
|
||||
`createExportRoutes(deps)` SHALL return an `OpenAPIHono` exposing `GET /` (list with optional `status`/`format`/`resource` filters + `limit`/`offset` pagination), `POST /` (create export, returning `{ job, downloadUrl }` with `downloadUrl === \`/${jobId}/download\``), `GET /:id` (lookup with 404), `DELETE /:id` (delete with 404). The create handler MUST validate the body via `CreateExportJobSchema`, return 404 with `{ error }` when the resource has no provider, return 400 with `{ error }` when the row or size limit is exceeded, return 400 with `{ error }` when the provider's `fetch` rejects, persist the job in `deps.jobs` regardless of outcome, and call the optional `onExportStarted` / `onExportCompleted` callbacks.
|
||||
|
||||
#### Scenario: 200 happy path with CSV
|
||||
|
||||
- **WHEN** `POST /` is called with `{ format:"csv", resource:"users" }` and the `users` provider returns two rows
|
||||
- **THEN** the response status is 200, `body.job.status === "completed"`, `body.job.exportedRows === 2`, `body.downloadUrl` ends with `/download`
|
||||
|
||||
#### Scenario: 404 on unknown resource
|
||||
|
||||
- **WHEN** `POST /` is called with `resource:"unknown"`
|
||||
- **THEN** the response status is 404 AND `body.error` contains `"No data provider registered"`
|
||||
|
||||
#### Scenario: 400 on row-limit overflow
|
||||
|
||||
- **WHEN** `POST /` is called against a `maxRows: 10` config with a provider returning 100 rows
|
||||
- **THEN** the response status is 400 AND the persisted job's `status === "failed"`
|
||||
|
||||
#### Scenario: 400 on provider failure
|
||||
|
||||
- **WHEN** the provider's `fetch` throws
|
||||
- **THEN** the response status is 400 AND `body.error` contains the thrown message
|
||||
|
||||
#### Scenario: list, get, delete
|
||||
|
||||
- **WHEN** a job is created, then listed (with each of `status=completed`/`format=json`/`resource=orders`/pagination filters), then fetched by id, then deleted, then fetched again
|
||||
- **THEN** the list filters are honoured, the GET /:id returns 200 with the job, the DELETE returns 200 with `{success:true}`, the second GET /:id returns 404, AND DELETE on an unknown id returns 404
|
||||
|
||||
**Verified by:** `src/routes/exports.test.ts::"POST / creates a CSV export job"` AND `src/routes/exports.test.ts::"POST / creates a JSON export job"` AND `src/routes/exports.test.ts::"POST / creates an XLSX export job"` AND `src/routes/exports.test.ts::"POST / uses custom columns and filename"` AND `src/routes/exports.test.ts::"POST / returns 404 when the resource has no provider"` AND `src/routes/exports.test.ts::"POST / returns 400 when the row limit is exceeded"` AND `src/routes/exports.test.ts::"POST / returns 400 when the file-size limit is exceeded"` AND `src/routes/exports.test.ts::"POST / returns 400 when the provider rejects"` AND `src/routes/exports.test.ts::"POST / persists the job in the jobs map"` AND `src/routes/exports.test.ts::"GET / returns the empty list by default"` AND `src/routes/exports.test.ts::"GET / filters by status, format, and resource"` AND `src/routes/exports.test.ts::"GET / supports limit + offset pagination"` AND `src/routes/exports.test.ts::"GET /:id returns the job by id"` AND `src/routes/exports.test.ts::"GET /:id returns 404 for an unknown id"` AND `src/routes/exports.test.ts::"DELETE /:id deletes the job"` AND `src/routes/exports.test.ts::"DELETE /:id returns 404 for an unknown id"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable error-code registry
|
||||
|
||||
`EXPORT_CODES = defineCodes("EXPORT", [...])` SHALL register `UNSUPPORTED_FORMAT`, `ROW_LIMIT_EXCEEDED`, `SIZE_LIMIT_EXCEEDED`, `PROVIDER_NOT_FOUND`, `PROVIDER_FETCH_FAILED`. Every code MUST be reachable from a real production path inside `src/`. `ExportError extends SuaveplanError<ExportErrorCode>` and exposes a `public` constructor `(code, message, options?)`.
|
||||
|
||||
#### Scenario: each code is thrown from production
|
||||
|
||||
- **WHEN** the test calls (a) `getExporter("pdf" as never)`, (b) `runExport` with row-overflow, (c) `runExport` with size-overflow, (d) a `POST /` to an unknown resource, (e) a `POST /` whose provider rejects
|
||||
- **THEN** each of the five codes appears in the resulting `ExportError.code`
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every EXPORT_CODES entry is thrown from at least one production path"` AND `src/error-codes/error-codes.test.ts::"ExportError narrows code to ExportErrorCode"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instruments
|
||||
|
||||
`getExportInstruments()` SHALL return a cached bundle of OTel instruments — `exportsTotal{format,outcome}` (Counter), `exportRowsTotal{format}` (Counter), `exportDurationMs` (Histogram) — built against `metrics.getMeter("@suaveplan/api-plugin-export")`. `runExport` and the route create-handler MUST increment `exportsTotal` with `outcome ∈ "success"|"failure"`, record `exportDurationMs`, and add `exportRowsTotal` on success. `resetExportInstruments()` clears the cache so tests can rebind.
|
||||
|
||||
#### Scenario: counters fire against an in-memory MeterProvider
|
||||
|
||||
- **WHEN** a successful `runExport` followed by a failing `runExport` (row-overflow) is executed against a global `MeterProvider` backed by `InMemoryMetricExporter`
|
||||
- **THEN** the collected metrics contain `export.exports_total` with both `outcome=success` and `outcome=failure` data points, `export.export_rows_total` with the success row count, and `export.export_duration_ms` with ≥2 recorded samples
|
||||
|
||||
**Verified by:** `src/otel/instruments.test.ts::"runExport emits exports_total, export_rows_total, and export_duration_ms on success"` AND `src/otel/instruments.test.ts::"runExport emits exports_total{outcome=failure} on row-limit overflow"` AND `src/otel/instruments.test.ts::"resetExportInstruments forces a re-bind on next get"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ServiceRegistry module augmentation
|
||||
|
||||
The package SHALL `declare module "@suaveplan/api-plugin/service-registry"` to add typed slots `exportProviders: Map<string, DataProvider>`, `exportJobs: Map<string, ExportJob>`, AND re-declare host-owned slots (`app: OpenAPIHono`, `eventBus`, `logger`) so callers typecheck against `ctx.getService("exportProviders")` without casts. The augmentation MUST be side-effect imported from the package barrel.
|
||||
|
||||
#### Scenario: type narrowing on ctx.getService
|
||||
|
||||
- **WHEN** `expectTypeOf<ServiceRegistry["exportProviders"]>()` and `expectTypeOf<ServiceRegistry["exportJobs"]>()` are asserted
|
||||
- **THEN** the first equals `Map<string, DataProvider>` AND the second equals `Map<string, ExportJob>`
|
||||
|
||||
**Verified by:** `src/api-plugin-export.spec-conformance.test.ts::"ServiceRegistry augmentation declares exportProviders and exportJobs slots"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Public surface and subpath exports
|
||||
|
||||
The package root barrel (`src/index.ts`) SHALL use **explicit named re-exports** (never `export *`) for every public symbol listed in `proposal.md` "What changes". `package.json#exports` SHALL list a subpath for every public module (`./plugin/plugin`, `./routes/exports`, `./services/export-service`, `./exporters/csv/csv`, `./exporters/json/json`, `./exporters/xlsx/xlsx`, `./error-codes/error-codes`, `./otel/instruments`, `./schemas/schemas`, `./types/types`).
|
||||
|
||||
#### Scenario: barrel is explicit
|
||||
|
||||
- **WHEN** the source of `src/index.ts` is scanned
|
||||
- **THEN** zero `export *` lines exist in the file
|
||||
|
||||
**Verified by:** `src/api-plugin-export.spec-conformance.test.ts::"package barrel re-exports the full public surface as explicit named exports"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user