zopia 0.5.2 β†’ 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-30
11
+
12
+ ### πŸ› Fixed
13
+ - πŸ› **Swagger 2.0 `collectionFormat` was dropped when reversing to OpenAPI
14
+ 3.x.** Array parameters generated from a Swagger 2.0 source came back as
15
+ bare 3.x parameters, silently changing the wire format from (for example)
16
+ `?tags=a,b` to `?tags=a&tags=b`. Reverse conversion now maps the recorded
17
+ format onto its 3.x spelling: on `query` parameters `csv` β†’
18
+ `style: "form", explode: false`, `multi` β†’ `style: "form", explode: true`,
19
+ `pipes` β†’ `style: "pipeDelimited"`, `ssv` β†’ `style: "spaceDelimited"`; on
20
+ `path`/`header` `csv` β†’ `style: "simple", explode: false`. `formData`
21
+ array serialization now lands in the media type's `encoding` map
22
+ (`csv` β†’ `form` + `explode: false`, `multi` β†’ `form` + `explode: true`).
23
+ Inline parameters and shared `#/parameters/<Name>` declarations (which
24
+ reverse into `components.parameters`) take the same mapping. Formats with
25
+ no legal 3.x spelling β€” `tsv` anywhere, plus `pipes`/`ssv`/`tsv` inside
26
+ `encoding` objects or on `path`/`header` β€” keep the original value in an
27
+ `x-collectionFormat` extension and emit the new
28
+ `ZOPIA_WARN_COLLECTION_FORMAT` warning at the affected pointer instead of
29
+ discarding it (D-12). Reversing to `'2.0'` still restores every
30
+ `collectionFormat` verbatim and warning-free.
31
+ - πŸ› **Swagger 2.0 `type: "file"` lost its binary format in 3.x output.**
32
+ Multipart file properties reversed to a plain `{ "type": "string" }`,
33
+ which describes a text field, because the version-rewrite pass stripped
34
+ the `format: "binary"` the operation builder had already produced.
35
+ Reversing to `'3.0'`/`'3.1'` now yields
36
+ `{ "type": "string", "format": "binary" }`; reversing to `'2.0'` still
37
+ restores `type: "file"`.
38
+ - πŸ› **Reversing a `--preset` split root reported a misleading missing
39
+ manifest.** `apiDocsToOpenApi()` (and `zopia reverse`) pointed at a preset
40
+ root β€” where the manifests live one directory down, one per bucket β€”
41
+ failed with `ZOPIA_DOCS_MISSING_MANIFEST`, which reads as "you never
42
+ generated anything". It now fails with the new typed
43
+ `ZOPIA_DOCS_PRESET_ROOT` code, naming the bucket directories that actually
44
+ contain a manifest in alphabetical order plus the command for a single
45
+ bucket, e.g. `this directory is a preset split (3 trees: orders,
46
+ untagged, users). Reverse one bucket (zopia reverse api_docs/orders) to
47
+ convert a single tree.` Directories with no bucket manifest anywhere keep
48
+ `ZOPIA_DOCS_MISSING_MANIFEST`.
49
+ - πŸ› **Authored numeric bound pairs were lost on the OpenAPI 3.1 round
50
+ trip.** A schema carrying both an inclusive and an exclusive bound on the
51
+ same side (e.g. `{ "minimum": 0, "exclusiveMinimum": 0 }`) generated the
52
+ correct Zod (`.min(0)` … `.gt(0)`), but `z.toJSONSchema()` keeps only the
53
+ tighter keyword, so reverse conversion emitted just
54
+ `{ "exclusiveMinimum": 0 }`. Generation now records the authored pair in
55
+ the manifest overlay β€” the same mechanism already used for legacy boolean
56
+ exclusive bounds β€” and reverse conversion restores both keywords verbatim
57
+ for endpoint-local **and** component schemas. Schemas with a single bound
58
+ on a side are unaffected and record no overlay.
59
+
10
60
  ## [0.5.2] - 2026-09-30
11
61
 
12
62
  ### πŸ› Fixed
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  [![Runtime](https://img.shields.io/badge/Runtime-Bun%201.x-black.svg)](https://bun.sh/)
14
14
  [![Tests](https://img.shields.io/badge/Tests-vitest-10b981.svg)](https://vitest.dev/)
15
15
 
16
- βœ… **Status β€” Phase 3 complete Β· v0.5.2 released**
16
+ βœ… **Status β€” Phase 3 complete Β· v0.6.0 released**
17
17
 
18
18
  </div>
19
19
 
package/docs/10-usage.md CHANGED
@@ -136,7 +136,7 @@ zopia navigate <docs-dir> (--to-code <spec-pointer> | --to-spec <tree-file>) [--
136
136
  | 🚩 Command | πŸ“ What it does | πŸ’‘ Example |
137
137
  | --- | --- | --- |
138
138
  | `zopia generate` | generates the endpoint tree and manifest; `--watch` keeps it running and regenerates whenever the spec file changes (survives atomic editor saves; Ctrl+C stops) | `zopia generate swagger.json api_docs`, `zopia generate swagger.yaml api_docs --watch` |
139
- | `zopia reverse` | imports the manifest's endpoint and emitted component modules, then writes the reconstructed OpenAPI document to stdout or `--out` | `zopia reverse api_docs/.zopia-manifest.json --out openapi.json` |
139
+ | `zopia reverse` | imports the manifest's endpoint and emitted component modules, then writes the reconstructed OpenAPI document to stdout or `--out`. Swagger 2.0 sources keep their wire format in 3.x output (`collectionFormat` β†’ `style`/`explode`/`encoding`, `type: file` β†’ `format: binary`); values 3.x cannot spell are kept as `x-collectionFormat` with a `ZOPIA_WARN_COLLECTION_FORMAT` warning. One tree per run: pointing at a `--preset` split root fails with `ZOPIA_DOCS_PRESET_ROOT` listing the bucket directories to choose from | `zopia reverse api_docs/.zopia-manifest.json --out openapi.json`, `zopia reverse api_docs/users` |
140
140
  | `zopia validate` | lints a spec (broken refs, name collisions, cross-namespace duplicate operationIds, unreachable components) or checks a generated tree (manifest validity, reverse dry-run, km-api peer drift); prints sorted diagnostics and a summary line to stdout | `zopia validate openapi.yaml`, `zopia validate api_docs` |
141
141
  | `zopia diff` | compares two specs semantically (dialect, info, endpoints with parameter/request-body/response details, webhooks, schema components and named registries (security schemes, reusable parameters/responses, request bodies…), path-item and webhook-item metadata, document fields, `x-` extensions) β€” key order is ignored and JSON/YAML inputs mix freely; prints `+`/`-`/`~` lines plus a summary to stdout; differences are data, so a changed pair still exits `0` | `zopia diff v1.json v2.yaml` |
142
142
  | `zopia navigate` | manifest-driven jump table between a generated tree and its source spec (S-93): `--to-code '#/paths/~1pets/get'` prints the generated file(s) implementing the pointer (endpoints, webhooks, components, plus custom companions when enabled); `--to-spec pets/get/index.ts` prints the owning pointer β€” both directions print one stable line per location and exit non-zero with `ZOPIA_CONFIG_INVALID` for unmatched pointers/files or `ZOPIA_DOCS_MISSING_MANIFEST` for generation-less roots | `zopia navigate api_docs --to-spec pets/get/index.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zopia",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Type-safe OpenAPI, JSON Schema, and Zod conversion toolkit.",
5
5
  "keywords": [
6
6
  "api-docs",
@@ -161,6 +161,14 @@ function analyzeSchema(source: JsonSchema): { warnings: AnalysisWarning[]; overl
161
161
  const inclusive = name === 'exclusiveMinimum' ? 'minimum' : 'maximum';
162
162
  addOverlay({ at, set: { [name]: true, ...(Object.prototype.hasOwnProperty.call(node, inclusive) ? { [inclusive]: node[inclusive] } : {}) } });
163
163
  }
164
+ // A schema carrying BOTH an inclusive and a numeric exclusive bound emits both Zod
165
+ // checks (`.min(n).gt(m)`), but `z.toJSONSchema()` keeps only the tighter one. Record
166
+ // the authored pair so reverse conversion restores it instead of silently normalizing
167
+ // the document (D-12) β€” the same overlay pattern the legacy boolean form above uses.
168
+ for (const [exclusive, inclusive] of [['exclusiveMinimum', 'minimum'], ['exclusiveMaximum', 'maximum']] as const) {
169
+ if (typeof node[exclusive] !== 'number' || typeof node[inclusive] !== 'number') continue;
170
+ addOverlay({ at, set: { [exclusive]: node[exclusive], [inclusive]: node[inclusive] } });
171
+ }
164
172
 
165
173
  if (typeof node.format === 'string') {
166
174
  const exact = new Set(['email', 'uuid', 'hostname', 'ipv4', 'ipv6', 'date-time', 'date', 'duration', 'uri']);
@@ -1,7 +1,7 @@
1
1
  import { asZopiaError, ZopiaError, type ZopiaErrorCode } from '../errors';
2
2
  import { createHash } from 'node:crypto';
3
- import { readFile, realpath, stat } from 'node:fs/promises';
4
- import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { zodSchemasToJsonSchema, zodToJsonSchema } from './zod-to-json-schema';
7
7
  import { jsonSchemaToZod } from './json-schema-to-zod';
@@ -365,17 +365,67 @@ function swaggerInlineSchema(value: Record<string, any>, sourceKind: string, ver
365
365
  return rewriteSchemaVersion(Object.fromEntries(Object.entries(value).filter(([key]) => SWAGGER_SCHEMA_KEYS.has(key))), sourceKind, version) as Record<string, any>;
366
366
  }
367
367
 
368
- function swaggerParameterToOpenApi(parameter: Record<string, any>, version: OpenApiReverseVersion): Record<string, any> {
368
+ /**
369
+ * Swagger 2.0 `collectionFormat` β†’ OpenAPI 3.x parameter serialization (R-660).
370
+ *
371
+ * `query` (and `formData` when it becomes an `encoding` entry) is the only location with a
372
+ * full 3.x vocabulary; `path`/`header` only have `simple`, so everything but `csv` is lossy
373
+ * there. A lossy value is never dropped silently: it keeps `x-collectionFormat` and emits
374
+ * `ZOPIA_WARN_COLLECTION_FORMAT` (D-12).
375
+ */
376
+ const SWAGGER_QUERY_COLLECTION_STYLES: Record<string, Record<string, unknown>> = {
377
+ csv: { style: 'form', explode: false },
378
+ multi: { style: 'form', explode: true },
379
+ pipes: { style: 'pipeDelimited' },
380
+ ssv: { style: 'spaceDelimited' },
381
+ };
382
+
383
+ function swaggerCollectionFormatSerialization(parameter: Record<string, any>, at: string, warnings?: ZopiaWarningCollector): Record<string, unknown> {
384
+ const format = parameter.collectionFormat;
385
+ if (typeof format !== 'string' || format === '') return {};
386
+ const location = typeof parameter.in === 'string' ? parameter.in : '';
387
+ if (location === 'query') {
388
+ const mapped = SWAGGER_QUERY_COLLECTION_STYLES[format];
389
+ if (mapped) return { ...mapped };
390
+ } else if (format === 'csv') return { style: 'simple', explode: false };
391
+ warnings?.add({
392
+ code: 'ZOPIA_WARN_COLLECTION_FORMAT',
393
+ at,
394
+ message: `collectionFormat \`${format}\` has no OpenAPI 3.x equivalent for an \`in: ${location || 'unknown'}\` parameter; the value is kept in \`x-collectionFormat\``,
395
+ });
396
+ return { 'x-collectionFormat': format };
397
+ }
398
+
399
+ /** Multipart/urlencoded `encoding` entries only honor `style: form`, so `pipes`/`ssv`/`tsv` stay lossy. */
400
+ function swaggerCollectionFormatEncoding(parameter: Record<string, any>, at: string, warnings?: ZopiaWarningCollector): Record<string, unknown> | undefined {
401
+ const format = parameter.collectionFormat;
402
+ if (typeof format !== 'string' || format === '') return undefined;
403
+ if (format === 'csv') return { style: 'form', explode: false };
404
+ if (format === 'multi') return { style: 'form', explode: true };
405
+ warnings?.add({
406
+ code: 'ZOPIA_WARN_COLLECTION_FORMAT',
407
+ at,
408
+ message: `collectionFormat \`${format}\` has no OpenAPI 3.x encoding equivalent (encoding objects only serialize \`form\`); the value is kept in \`x-collectionFormat\``,
409
+ });
410
+ return { style: 'form', explode: true, 'x-collectionFormat': format };
411
+ }
412
+
413
+ function swaggerParameterToOpenApi(parameter: Record<string, any>, version: OpenApiReverseVersion, at = '#', warnings?: ZopiaWarningCollector): Record<string, any> {
369
414
  if (typeof parameter.$ref === 'string' && parameter.$ref.startsWith('#/parameters/')) return { $ref: `#/components/parameters/${parameter.$ref.slice('#/parameters/'.length)}` };
370
415
  const metadata = Object.fromEntries(Object.entries(parameter).filter(([key]) => !SWAGGER_SCHEMA_KEYS.has(key) && key !== 'schema' && key !== 'collectionFormat'));
371
- return { ...metadata, schema: rewriteSchemaVersion(parameter.schema ?? swaggerInlineSchema(parameter, 'swagger-2.0', version), 'swagger-2.0', version) };
416
+ return {
417
+ ...metadata,
418
+ ...swaggerCollectionFormatSerialization(parameter, at, warnings),
419
+ schema: rewriteSchemaVersion(parameter.schema ?? swaggerInlineSchema(parameter, 'swagger-2.0', version), 'swagger-2.0', version),
420
+ };
372
421
  }
373
422
 
374
- function swaggerPathsOverlayToOpenApi(pathsOverlay: Record<string, unknown> | undefined, version: OpenApiReverseVersion): Record<string, unknown> | undefined {
423
+ function swaggerPathsOverlayToOpenApi(pathsOverlay: Record<string, unknown> | undefined, version: OpenApiReverseVersion, warnings?: ZopiaWarningCollector): Record<string, unknown> | undefined {
375
424
  if (pathsOverlay === undefined) return undefined;
376
425
  return Object.fromEntries(Object.entries(pathsOverlay).map(([path, metadata]) => {
377
426
  if (!isRecord(metadata) || !Array.isArray(metadata.parameters)) return [path, metadata];
378
- return [path, { ...metadata, parameters: metadata.parameters.map((parameter: unknown) => isRecord(parameter) ? swaggerParameterToOpenApi(parameter, version) : parameter) }];
427
+ const at = `#/paths/${pointerToken(path)}/parameters`;
428
+ return [path, { ...metadata, parameters: metadata.parameters.map((parameter: unknown, index: number) => isRecord(parameter) ? swaggerParameterToOpenApi(parameter, version, `${at}/${index}`, warnings) : parameter) }];
379
429
  }));
380
430
  }
381
431
 
@@ -420,7 +470,7 @@ function collectManifestRefs(value: unknown, at = '', mapEntries = false): Array
420
470
  return refs;
421
471
  }
422
472
 
423
- function swaggerOperationToOpenApi(operation: Record<string, any>, manifest: ZopiaManifest, version: OpenApiReverseVersion): Record<string, any> {
473
+ function swaggerOperationToOpenApi(operation: Record<string, any>, manifest: ZopiaManifest, version: OpenApiReverseVersion, at = '#', warnings?: ZopiaWarningCollector): Record<string, any> {
424
474
  const consumes = Array.isArray(operation.consumes) ? operation.consumes : manifest.swaggerConsumes ?? [];
425
475
  const produces = Array.isArray(operation.produces) ? operation.produces : manifest.swaggerProduces ?? [];
426
476
  const requestTypes = consumes.length ? consumes : ['application/json'];
@@ -428,7 +478,9 @@ function swaggerOperationToOpenApi(operation: Record<string, any>, manifest: Zop
428
478
  const parameters = (Array.isArray(operation.parameters) ? operation.parameters : []).filter(isRecord).map((raw) => ({ raw, resolved: resolveParameter(raw, manifest) }));
429
479
  const body = parameters.find(({ resolved }) => resolved.in === 'body')?.resolved;
430
480
  const form = parameters.filter(({ resolved }) => resolved.in === 'formData').map(({ resolved }) => resolved);
431
- const ordinary = parameters.filter(({ resolved }) => resolved.in !== 'body' && resolved.in !== 'formData').map(({ raw, resolved }) => swaggerParameterToOpenApi(typeof raw.$ref === 'string' ? raw : resolved, version));
481
+ const ordinary = parameters
482
+ .filter(({ resolved }) => resolved.in !== 'body' && resolved.in !== 'formData')
483
+ .map(({ raw, resolved }, index) => swaggerParameterToOpenApi(typeof raw.$ref === 'string' ? raw : resolved, version, `${at}/parameters/${index}`, warnings));
432
484
  let requestBody: Record<string, any> | undefined;
433
485
  if (body) {
434
486
  const schema = rewriteSchemaVersion(body.schema ?? {}, 'swagger-2.0', version);
@@ -438,7 +490,15 @@ function swaggerOperationToOpenApi(operation: Record<string, any>, manifest: Zop
438
490
  const properties = Object.fromEntries(form.map((parameter) => [parameter.name, rewriteSchemaVersion(parameter.type === 'file' ? { type: 'string', format: 'binary' } : swaggerInlineSchema(parameter, 'swagger-2.0', version), 'swagger-2.0', version)]));
439
491
  const required = form.filter((parameter) => parameter.required === true).map((parameter) => parameter.name);
440
492
  const schema = { type: 'object', properties, ...(required.length ? { required } : {}) };
441
- requestBody = { required: required.length > 0, content: Object.fromEntries(requestTypes.map((type) => [type, { schema }])) };
493
+ // `collectionFormat` on a formData parameter is array serialization, which 3.x expresses
494
+ // through the media type's `encoding` map (R-660) β€” never a property-level keyword.
495
+ const primaryType = requestTypes[0];
496
+ const encoding = Object.fromEntries(form.flatMap((parameter) => {
497
+ const entry = swaggerCollectionFormatEncoding(parameter, `${at}/requestBody/content/${pointerToken(String(primaryType))}/encoding/${pointerToken(String(parameter.name))}`, warnings);
498
+ return entry === undefined ? [] : [[String(parameter.name), entry] as const];
499
+ }));
500
+ const media = { schema, ...(Object.keys(encoding).length ? { encoding } : {}) };
501
+ requestBody = { required: required.length > 0, content: Object.fromEntries(requestTypes.map((type) => [type, media])) };
442
502
  }
443
503
  const responses = Object.fromEntries(Object.entries(operation.responses ?? {}).map(([status, response]) => [status, swaggerResponseToOpenApi(response, responseTypes, version)]));
444
504
  return { ...Object.fromEntries(Object.entries(operation).filter(([key]) => !['parameters', 'responses', 'consumes', 'produces', 'schemes'].includes(key))), ...(ordinary.length ? { parameters: ordinary } : {}), ...(requestBody === undefined ? {} : { requestBody }), responses };
@@ -800,12 +860,13 @@ function manifestForOutputVersion(manifest: ZopiaManifest, version: OpenApiRever
800
860
  const host = manifest.swaggerHost;
801
861
  const schemes = manifest.swaggerSchemes?.length ? manifest.swaggerSchemes : ['https'];
802
862
  const servers = host ? schemes.map((scheme) => ({ url: `${scheme}://${host}${basePath === '/' ? '' : basePath}` })) : [{ url: basePath }];
803
- const reusableParameters = Object.fromEntries(Object.entries(manifest.swaggerParameters ?? {}).filter(([, parameter]) => !isRecord(parameter) || parameter.in !== 'body' && parameter.in !== 'formData').map(([name, parameter]) => [name, isRecord(parameter) ? swaggerParameterToOpenApi(parameter, version) : parameter]));
863
+ const reusableParameters = Object.fromEntries(Object.entries(manifest.swaggerParameters ?? {}).filter(([, parameter]) => !isRecord(parameter) || parameter.in !== 'body' && parameter.in !== 'formData').map(([name, parameter]) => [name, isRecord(parameter) ? swaggerParameterToOpenApi(parameter, version, `#/components/parameters/${pointerToken(name)}`, warnings) : parameter]));
804
864
  const responseTypes = manifest.swaggerProduces?.length ? manifest.swaggerProduces : ['application/json'];
805
865
  const reusableResponses = Object.fromEntries(Object.entries(manifest.swaggerResponses ?? {}).map(([name, response]) => [name, swaggerResponseToOpenApi(response, responseTypes, version)]));
806
866
  const componentsOverlay = { ...(manifest.componentsOverlay ?? {}), ...(Object.keys(reusableParameters).length ? { parameters: reusableParameters } : {}), ...(Object.keys(reusableResponses).length ? { responses: reusableResponses } : {}) };
807
867
  const apis = manifest.apis.map((api) => {
808
- const sourceOperation = api.sourceOperation === undefined ? undefined : swaggerOperationToOpenApi(api.sourceOperation, manifest, version);
868
+ const apiAt = `#/paths/${pointerToken(api.path)}/${api.method}`;
869
+ const sourceOperation = api.sourceOperation === undefined ? undefined : swaggerOperationToOpenApi(api.sourceOperation, manifest, version, apiAt, warnings);
809
870
  const responseOverlay = sourceOperation === undefined ? api.responseOverlay : Object.entries(sourceOperation.responses ?? {}).flatMap(([status, response]) => isRecord(response) && response.headers !== undefined ? [{ status, headers: response.headers }] : []);
810
871
  return { ...api, sourceOperation, refs: sourceOperation === undefined ? api.refs : collectManifestRefs(sourceOperation), overlay: rewriteOverlaysVersion(api.overlay, sourceKind, version), responseOverlay };
811
872
  });
@@ -813,7 +874,7 @@ function manifestForOutputVersion(manifest: ZopiaManifest, version: OpenApiRever
813
874
  ...manifest,
814
875
  source: { ...manifest.source, kind: targetKind, openapiVersion: `${version}.0` },
815
876
  documentOverlay,
816
- pathsOverlay: swaggerPathsOverlayToOpenApi(manifest.pathsOverlay, version),
877
+ pathsOverlay: swaggerPathsOverlayToOpenApi(manifest.pathsOverlay, version, warnings),
817
878
  servers,
818
879
  components,
819
880
  componentsOverlay,
@@ -848,12 +909,46 @@ function sweepSwaggerDowngradeDocument(document: Record<string, any>): void {
848
909
  sweepResponses(isRecord(document.responses) ? document.responses : undefined);
849
910
  }
850
911
 
912
+ /**
913
+ * List the immediate subdirectories of `directory` that carry their own manifest,
914
+ * sorted alphabetically β€” exactly the shape a `preset` split writes (R-661).
915
+ *
916
+ * @param directory Directory that has no manifest of its own.
917
+ * @returns Sorted bucket directory names, empty when this is not a preset split.
918
+ */
919
+ async function presetBucketNames(directory: string): Promise<string[]> {
920
+ let entries;
921
+ try { entries = await readdir(directory, { withFileTypes: true }); }
922
+ catch { return []; }
923
+ const buckets: string[] = [];
924
+ for (const entry of entries) {
925
+ if (!entry.isDirectory()) continue;
926
+ try {
927
+ if ((await stat(join(directory, entry.name, ZOPIA_MANIFEST_FILE))).isFile()) buckets.push(entry.name);
928
+ } catch { continue; }
929
+ }
930
+ return buckets.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
931
+ }
932
+
933
+ /** Distinguish a preset split root from a genuinely manifest-less directory (R-661). */
934
+ async function missingManifestError(file: string): Promise<ZopiaError> {
935
+ const directory = dirname(resolve(file));
936
+ const buckets = await presetBucketNames(directory);
937
+ if (buckets.length === 0) return new ZopiaError('ZOPIA_DOCS_MISSING_MANIFEST', `manifest file not found: ${file}`, { at: file, hint: 'generate api docs first or pass the manifest path' });
938
+ const label = basename(directory) || directory;
939
+ return new ZopiaError(
940
+ 'ZOPIA_DOCS_PRESET_ROOT',
941
+ `this directory is a preset split (${buckets.length} trees: ${buckets.join(', ')}). Reverse one bucket (zopia reverse ${label}/${buckets[0]}) to convert a single tree.`,
942
+ { at: directory, hint: 'reverse one preset bucket directory instead of the preset root' },
943
+ );
944
+ }
945
+
851
946
  async function manifestFileToOpenApiInternal(file: string, version: OpenApiReverseVersion | undefined, warnings: ZopiaWarningCollector): Promise<Record<string, unknown>> {
852
947
  if (typeof file !== 'string' || !file) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'manifest file path is required', { at: 'file', hint: 'provide the .zopia-manifest.json path' });
853
948
  let source: string;
854
949
  try { source = await readFile(file, 'utf8'); }
855
950
  catch (error) {
856
- if (isMissingFileError(error)) throw new ZopiaError('ZOPIA_DOCS_MISSING_MANIFEST', `manifest file not found: ${file}`, { at: file, hint: 'generate api docs first or pass the manifest path' });
951
+ if (isMissingFileError(error)) throw await missingManifestError(file);
857
952
  throw asZopiaError(error, 'ZOPIA_MANIFEST_INVALID', 'unable to read manifest file', { at: file, hint: 'check that the manifest is readable JSON' });
858
953
  }
859
954
  let parsed: unknown;
@@ -1356,6 +1451,12 @@ function restoreSourceSchemaStructure(generated: unknown, source: unknown): unkn
1356
1451
  return generated;
1357
1452
  }
1358
1453
  if (!isRecord(generated) || !isRecord(source)) return generated;
1454
+ // Zod has no binary string type, so a Swagger `type: file` (and an authored 3.x
1455
+ // `format: binary`) serializes back as a bare `z.string()`. The source keeps the
1456
+ // canonical 2.0β†’3.x mapping, so restore the format rather than change the wire shape.
1457
+ if (source.format === 'binary' && generated.type === 'string' && !Object.prototype.hasOwnProperty.call(generated, 'format')) {
1458
+ Object.defineProperty(generated, 'format', { value: 'binary', enumerable: true, configurable: true, writable: true });
1459
+ }
1359
1460
  if (Array.isArray(source.required) && source.required.every((key: unknown) => typeof key === 'string')) {
1360
1461
  const generatedRequired = Array.isArray(generated.required) && generated.required.every((key: unknown) => typeof key === 'string') ? generated.required as string[] : [];
1361
1462
  const sourceRequired = source.required as string[];
@@ -16,7 +16,7 @@ export const ZOPIA_MANIFEST_SCHEMA = 'zopia:manifest@1' as const;
16
16
  export const ZOPIA_MANIFEST_FILE = '.zopia-manifest.json' as const;
17
17
 
18
18
  /** Package version recorded by the current manifest writer. */
19
- export const ZOPIA_VERSION = '0.5.2' as const;
19
+ export const ZOPIA_VERSION = '0.6.0' as const;
20
20
 
21
21
  /** Supported source dialect labels stored in a manifest. */
22
22
  export type ZopiaManifestSourceKind = 'swagger-2.0' | 'openapi-3.0' | 'openapi-3.1';
package/src/errors.ts CHANGED
@@ -4,6 +4,7 @@ export const ZOPIA_ERROR_CODES = Object.freeze([
4
4
  'ZOPIA_DOCS_IMPORT_FAILED',
5
5
  'ZOPIA_DOCS_MANIFEST_MISMATCH',
6
6
  'ZOPIA_DOCS_MISSING_MANIFEST',
7
+ 'ZOPIA_DOCS_PRESET_ROOT',
7
8
  'ZOPIA_FS_OUTSIDE_OUTDIR',
8
9
  'ZOPIA_FS_WRITE_FAILED',
9
10
  'ZOPIA_MANIFEST_INVALID',
@@ -54,6 +55,7 @@ const DEFAULT_ERROR_HINTS: Record<ZopiaErrorCode, string> = {
54
55
  ZOPIA_REF_EXTERNAL: 'replace the external reference with a local reference',
55
56
  ZOPIA_MANIFEST_INVALID: 'regenerate the manifest or fix its invalid metadata',
56
57
  ZOPIA_DOCS_MISSING_MANIFEST: 'generate api docs first or pass the manifest path',
58
+ ZOPIA_DOCS_PRESET_ROOT: 'reverse one preset bucket directory instead of the preset root',
57
59
  ZOPIA_DOCS_MANIFEST_MISMATCH: 'regenerate the api-docs tree or restore its generated files',
58
60
  ZOPIA_DOCS_IMPORT_FAILED: 'fix or regenerate the affected generated module',
59
61
  ZOPIA_FS_OUTSIDE_OUTDIR: 'keep generated paths inside the output directory',
package/src/warnings.ts CHANGED
@@ -2,6 +2,7 @@ import { asZopiaError, ZopiaError } from './errors';
2
2
 
3
3
  /** Stable warning codes emitted by zopia's conversion pipelines. */
4
4
  export const ZOPIA_WARNING_CODES = [
5
+ 'ZOPIA_WARN_COLLECTION_FORMAT',
5
6
  'ZOPIA_WARN_CONTENT_ENCODING',
6
7
  'ZOPIA_WARN_CUSTOM_FORMAT',
7
8
  'ZOPIA_WARN_DEFAULT_INFO',