zopia 0.3.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +354 -0
  2. package/LICENSE +21 -0
  3. package/README.md +167 -0
  4. package/bin/zopia.js +20 -0
  5. package/docs/01-overview.md +94 -0
  6. package/docs/02-targets.md +55 -0
  7. package/docs/03-roadmap.md +205 -0
  8. package/docs/04-architecture.md +345 -0
  9. package/docs/05-concepts.md +239 -0
  10. package/docs/06-conversions.md +493 -0
  11. package/docs/07-api-docs.md +337 -0
  12. package/docs/08-components.md +223 -0
  13. package/docs/09-configuration.md +167 -0
  14. package/docs/10-usage.md +208 -0
  15. package/docs/11-testing.md +267 -0
  16. package/docs/12-standards.md +242 -0
  17. package/docs/README.md +42 -0
  18. package/docs/publish-workflow.yml.example +48 -0
  19. package/package.json +77 -0
  20. package/src/api-docs-navigation.ts +353 -0
  21. package/src/cli-command.ts +537 -0
  22. package/src/cli.ts +4 -0
  23. package/src/config.ts +190 -0
  24. package/src/conversions/api-docs-facade.ts +42 -0
  25. package/src/conversions/api-docs-generate.ts +567 -0
  26. package/src/conversions/api-docs-layout.ts +39 -0
  27. package/src/conversions/api-docs-plan.ts +130 -0
  28. package/src/conversions/api-docs-presets.ts +246 -0
  29. package/src/conversions/json-schema-to-zod.ts +931 -0
  30. package/src/conversions/manifest-staleness.ts +211 -0
  31. package/src/conversions/manifest-to-openapi.ts +1861 -0
  32. package/src/conversions/manifest-writer.ts +778 -0
  33. package/src/conversions/openapi-contracts.ts +333 -0
  34. package/src/conversions/openapi-external-ref.ts +233 -0
  35. package/src/conversions/openapi-ir.ts +74 -0
  36. package/src/conversions/openapi-ref.ts +38 -0
  37. package/src/conversions/openapi-to-api-docs-public.ts +466 -0
  38. package/src/conversions/openapi-to-api-docs.ts +203 -0
  39. package/src/conversions/openapi.ts +80 -0
  40. package/src/conversions/reverse-security.ts +68 -0
  41. package/src/conversions/yaml.ts +876 -0
  42. package/src/conversions/zod-to-json-schema.ts +536 -0
  43. package/src/diff.ts +353 -0
  44. package/src/errors.ts +114 -0
  45. package/src/index.ts +80 -0
  46. package/src/validation.ts +299 -0
  47. package/src/warnings.ts +164 -0
@@ -0,0 +1,130 @@
1
+ import { ZopiaError } from '../errors';
2
+ import { collectOpenApiOperations, collectOpenApiWebhookOperations, type OpenApiOperation } from './openapi-to-api-docs';
3
+ import { normalizeOpenApiDocument } from './openapi';
4
+ import { endpointFilePath, isPortableApiDocsSegment, type ApiDocsMode } from './api-docs-layout';
5
+
6
+ /**
7
+ * Enforce document-wide `operationId` uniqueness across the path and webhook namespaces.
8
+ *
9
+ * Each collector dedupes only its own namespace, but OpenAPI requires the id to be
10
+ * unique across the whole document — a `$ref` alias can surface the same explicit id
11
+ * in both. Generation and validation share this guard.
12
+ *
13
+ * @param plans Path endpoint plans from {@link planApiDocsFiles}.
14
+ * @param webhookPlans Webhook endpoint plans from {@link planWebhookDocsFiles}.
15
+ * @returns Nothing.
16
+ * @throws {@link ZopiaError} `ZOPIA_SPEC_INVALID` when both scopes mint the same id.
17
+ */
18
+ export function assertUniqueOperationIdsAcrossScopes(plans: readonly ApiDocsFilePlan[], webhookPlans: readonly ApiDocsFilePlan[]): void {
19
+ const pathOperationIds = new Map(plans.map((plan) => [plan.operationId, plan.path]));
20
+ for (const webhookPlan of webhookPlans) {
21
+ const owner = pathOperationIds.get(webhookPlan.operationId);
22
+ if (owner !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Duplicate operationId across paths and webhooks: ${webhookPlan.operationId}`, { at: `#/webhooks/${webhookPlan.path.replace(/~/g, '~0').replace(/\//g, '~1')}`, hint: `rename one operationId (also used by path ${owner})` });
23
+ }
24
+ }
25
+
26
+ /** One normalized operation paired with its collision-safe output path. */
27
+ export interface ApiDocsFilePlan extends OpenApiOperation {
28
+ /** Portable endpoint-module path relative to the generation root. */
29
+ file: string;
30
+ }
31
+
32
+ /**
33
+ * Plan generated endpoint files without touching the filesystem.
34
+ *
35
+ * @param input Valid Swagger/OpenAPI object or JSON text.
36
+ * @param mode Directory or flattened endpoint layout.
37
+ * @returns Deterministically ordered operations with portable output paths.
38
+ * @throws {@link ZopiaError} when the source or layout mode is invalid.
39
+ */
40
+ export function planApiDocsFiles(input: Record<string, any> | string, mode: ApiDocsMode = 'directory'): ApiDocsFilePlan[] {
41
+ if (mode !== 'directory' && mode !== 'flat') throw new ZopiaError('ZOPIA_CONFIG_INVALID', `Unsupported API docs mode: ${mode}`);
42
+ const operations = collectOpenApiOperations(input);
43
+ const names = new Map<string, string>();
44
+ const methodsByPath = new Map<string, string[]>();
45
+ const assigned: Array<{ segments: string[]; methods: string[] }> = [];
46
+ const plan: ApiDocsFilePlan[] = [];
47
+ for (const operation of operations) {
48
+ const methods = methodsByPath.get(operation.path);
49
+ if (methods) methods.push(operation.method);
50
+ else methodsByPath.set(operation.path, [operation.method]);
51
+ }
52
+ const startsWith = (value: readonly string[], prefix: readonly string[]): boolean => prefix.length <= value.length && prefix.every((segment, index) => value[index].toLowerCase() === segment.toLowerCase());
53
+ const conflictIndex = (candidate: readonly string[], methods: readonly string[]): number | undefined => {
54
+ for (const previous of assigned) {
55
+ if (candidate.length === previous.segments.length && startsWith(candidate, previous.segments)) return candidate.length - 1;
56
+ for (const method of previous.methods) {
57
+ const methodDirectory = [...previous.segments, method];
58
+ if (startsWith(candidate, methodDirectory)) return previous.segments.length;
59
+ }
60
+ for (const method of methods) if (startsWith(previous.segments, [...candidate, method])) return candidate.length - 1;
61
+ }
62
+ return undefined;
63
+ };
64
+ for (const operation of operations) {
65
+ let name = names.get(operation.path);
66
+ if (name === undefined) {
67
+ let originalSegments: string[];
68
+ if (mode === 'flat') {
69
+ endpointFilePath(operation.path, operation.method, 'directory');
70
+ originalSegments = [operation.path.split('/').filter(Boolean).join('-') || 'root'];
71
+ } else {
72
+ const methodSuffix = `/${operation.method}/index.ts`;
73
+ originalSegments = endpointFilePath(operation.path, operation.method, mode).slice(0, -methodSuffix.length).split('/');
74
+ }
75
+ const candidate = [...originalSegments];
76
+ const suffixes = new Map<number, number>();
77
+ let conflict = conflictIndex(candidate, methodsByPath.get(operation.path)!);
78
+ while (conflict !== undefined) {
79
+ const suffix = (suffixes.get(conflict) ?? 1) + 1;
80
+ suffixes.set(conflict, suffix);
81
+ candidate[conflict] = `${originalSegments[conflict]}-${suffix}`;
82
+ conflict = conflictIndex(candidate, methodsByPath.get(operation.path)!);
83
+ }
84
+ name = candidate.join('/');
85
+ names.set(operation.path, name);
86
+ assigned.push({ segments: candidate, methods: methodsByPath.get(operation.path)! });
87
+ }
88
+ plan.push({ ...operation, file: `${name}/${operation.method}/index.ts` });
89
+ }
90
+ return plan;
91
+ }
92
+
93
+ /**
94
+ * Deterministic runtime placeholder path emitted inside generated webhook endpoint modules.
95
+ *
96
+ * Webhook names are not URL paths, but the km-api endpoint contract requires a leading-slash
97
+ * `/` runtime path string. Reverse conversion ignores this placeholder and restores the
98
+ * authoritative webhook name held by the manifest.
99
+ *
100
+ * @param name Source webhook name.
101
+ * @returns Sanitized `/webhooks/<name>` placeholder path.
102
+ */
103
+ export const webhookRuntimePath = (name: string): string =>
104
+ `/webhooks/${name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'webhook'}`;
105
+
106
+ /**
107
+ * Plan generated webhook endpoint files without touching the filesystem (OpenAPI 3.1).
108
+ *
109
+ * @param input Valid OpenAPI object containing a `webhooks` section.
110
+ * @param mode Directory or flattened endpoint layout.
111
+ * @returns Deterministically ordered webhook operations with portable output paths.
112
+ * @throws {@link ZopiaError} when the webhook section or layout mode is invalid.
113
+ */
114
+ export function planWebhookDocsFiles(input: Record<string, any> | string, mode: ApiDocsMode = 'directory'): ApiDocsFilePlan[] {
115
+ if (mode !== 'directory' && mode !== 'flat') throw new ZopiaError('ZOPIA_CONFIG_INVALID', `Unsupported API docs mode: ${mode}`);
116
+ const { document } = normalizeOpenApiDocument(input);
117
+ const operations = collectOpenApiWebhookOperations(document);
118
+ const stems = new Map<string, string>(); const usedStems = new Set<string>();
119
+ for (const operation of operations) {
120
+ if (stems.has(operation.path)) continue;
121
+ const base = operation.path.replace(/[^A-Za-z0-9._-]+/g, '-').toLowerCase() || 'webhook';
122
+ // Sanitization can leave hazardous stems for exotic legal names (e.g. `..` is a
123
+ // valid webhook name but never a valid segment); reject them deterministically.
124
+ if (!isPortableApiDocsSegment(base) || base.startsWith('.')) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Unsafe webhook name: ${operation.path}`, { at: `#/webhooks/${operation.path}` });
125
+ let stem = base; let suffix = 1;
126
+ while (usedStems.has(stem)) stem = `${base}-${++suffix}`;
127
+ stems.set(operation.path, stem); usedStems.add(stem);
128
+ }
129
+ return operations.map((operation) => ({ ...operation, file: endpointFilePath(webhookRuntimePath(stems.get(operation.path)!), operation.method, mode) }));
130
+ }
@@ -0,0 +1,246 @@
1
+ import { ZopiaError } from '../errors';
2
+ import { isPortableApiDocsSegment } from './api-docs-layout';
3
+ import { OPENAPI_METHODS, type OpenApiMethod } from './openapi-to-api-docs';
4
+ import { type OpenApiDocument } from './openapi';
5
+ import { resolveOpenApiLocalRef } from './openapi-ref';
6
+ import type { ZopiaWarning } from '../warnings';
7
+
8
+ /** Built-in split-generation preset: one api-docs tree per routed bucket. */
9
+ export type ZopiaGeneratePreset = 'multi-tag' | 'multi-server';
10
+
11
+ /** One routed preset bucket: a filtered source document plus its target sub-directory. */
12
+ export interface ZopiaPresetBucket {
13
+ /** Human bucket identity: the routing tag, server URL, `'(untagged)'`, or `'(default server)'`. */
14
+ name: string;
15
+ /** Portable sub-directory under the configured output directory (collision-safe). */
16
+ directory: string;
17
+ /** Filtered document holding only this bucket's routed operations, all other source facts verbatim. */
18
+ document: OpenApiDocument;
19
+ /** Routing notices (for example an operation with several tags uses its primary tag). */
20
+ warnings: ZopiaWarning[];
21
+ }
22
+
23
+ /** Summary of one generated preset tree, relative to the output directory. */
24
+ export interface ZopiaPresetTree {
25
+ /** Human bucket identity of the tree (`name` of its {@link ZopiaPresetBucket}). */
26
+ name: string;
27
+ /** Portable sub-directory of the tree, relative to the output directory. */
28
+ directory: string;
29
+ /** Manifest path relative to the output directory, present when manifests are enabled. */
30
+ manifestPath?: string;
31
+ }
32
+
33
+ /** All built-in preset names, in CLI-accepted order. */
34
+ export const ZOPIA_GENERATE_PRESETS = ['multi-tag', 'multi-server'] as const;
35
+
36
+ const pointerToken = (value: string): string => value.replace(/~/g, '~0').replace(/\//g, '~1');
37
+
38
+ /** Portable lower-case directory slug for one bucket name (empty/unsafe → `'untagged'`). */
39
+ function slugify(name: string): string {
40
+ const slug = name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
41
+ return slug && isPortableApiDocsSegment(slug) ? slug : 'untagged';
42
+ }
43
+
44
+ /** Identity of one server entry — the whole object, compared module structurally for routing stability. */
45
+ const serverKey = (server: unknown): string => JSON.stringify(server ?? null);
46
+
47
+ /** Human name for one routed server entry: its URL when available. */
48
+ const serverName = (server: unknown): string => {
49
+ if (server && typeof server === 'object' && !Array.isArray(server) && typeof (server as { url?: unknown }).url === 'string') return (server as { url: string }).url;
50
+ return JSON.stringify(server) ?? '(default server)';
51
+ };
52
+
53
+ /** One collected operation with everything bucket routing needs. */
54
+ interface PresetOperation {
55
+ /** Stable bucket key for this operation's preset. */
56
+ key: string;
57
+ /** Human bucket label for this operation. */
58
+ name: string;
59
+ /** JSON Pointer of the operation in the source document. */
60
+ at: string;
61
+ /** Item routing fields (`paths`/`webhooks` section, item name, method). */
62
+ section: 'paths' | 'webhooks';
63
+ /** Path template or webhook name. */
64
+ item: string;
65
+ /** HTTP method of the operation. */
66
+ method: OpenApiMethod;
67
+ /** Warning emitted when an operation carries several routing keys. */
68
+ notice?: ZopiaWarning;
69
+ }
70
+
71
+ /** Resolve one item for ROUTING only (read-only; buckets keep the original verbatim). */
72
+ function resolveForRouting(document: OpenApiDocument, item: unknown): Record<string, any> | undefined {
73
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
74
+ let resolved: any = item;
75
+ const seen = new Set<string>();
76
+ while ('$ref' in resolved) {
77
+ if (typeof resolved.$ref !== 'string' || !resolved.$ref || seen.has(resolved.$ref)) return resolved;
78
+ seen.add(resolved.$ref);
79
+ const target = resolveOpenApiLocalRef(document, resolved.$ref);
80
+ if (!target || typeof target !== 'object' || Array.isArray(target)) return resolved;
81
+ resolved = { ...target, ...Object.fromEntries(Object.entries(resolved).filter(([key]) => key !== '$ref')) };
82
+ }
83
+ return resolved;
84
+ }
85
+
86
+ /** First entry of a valid non-empty string array (`undefined` otherwise). */
87
+ function firstString(value: unknown): string | undefined {
88
+ return Array.isArray(value) ? value.find((entry): entry is string => typeof entry === 'string' && entry.length > 0) : undefined;
89
+ }
90
+
91
+ /** Collect every operation from the paths/webhooks sections with its preset routing identity. */
92
+ function collectPresetOperations(document: OpenApiDocument, preset: ZopiaGeneratePreset): PresetOperation[] {
93
+ const operations: PresetOperation[] = [];
94
+ const servers = preset === 'multi-server' && document.swagger !== '2.0' ? document.servers : undefined;
95
+ for (const section of ['paths', 'webhooks'] as const) {
96
+ if (section === 'webhooks' && (document.swagger === '2.0' || !document.webhooks)) continue;
97
+ const map = (section === 'paths' ? document.paths : document.webhooks) ?? {};
98
+ if (!map || typeof map !== 'object' || Array.isArray(map)) continue;
99
+ for (const item of Object.keys(map as Record<string, unknown>)) {
100
+ if (item.startsWith('x-')) continue;
101
+ const resolved = resolveForRouting(document, (map as Record<string, unknown>)[item as string]);
102
+ if (!resolved) continue;
103
+ for (const method of OPENAPI_METHODS) {
104
+ const operation = resolved[method];
105
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) continue;
106
+ const at = `#/${section}/${pointerToken(item)}/${method}`;
107
+ if (preset === 'multi-tag') {
108
+ const tags = Array.isArray(operation.tags) ? (operation.tags as unknown[]).filter((tag): tag is string => typeof tag === 'string' && tag.length > 0) : [];
109
+ const primary = firstString(tags);
110
+ operations.push({
111
+ key: `tag:${primary ?? ''}`,
112
+ name: primary ?? '(untagged)',
113
+ at,
114
+ section,
115
+ item,
116
+ method,
117
+ ...(tags.length > 1 ? { notice: { code: 'ZOPIA_WARN_PRESET_PRIMARY_TAG' as const, at, message: `operation has ${tags.length} tags; using primary tag "${primary}" for --preset multi-tag` } } : {}),
118
+ });
119
+ } else {
120
+ // An explicit `servers` array at the nearest level is decisive — even
121
+ // an EMPTY one (which the specification treats as the default server
122
+ // `/`), so it routes to the default-server bucket instead of silently
123
+ // inheriting the parent/document servers.
124
+ const level = Array.isArray(operation.servers) ? operation.servers : Array.isArray(resolved.servers) ? resolved.servers : Array.isArray(servers) ? servers : undefined;
125
+ const server = level !== undefined && level.length > 0 ? level[0] : undefined;
126
+ operations.push({
127
+ key: server === undefined ? 'server:' : `server:${serverKey(server)}`,
128
+ name: server === undefined ? '(default server)' : serverName(server),
129
+ at,
130
+ section,
131
+ item,
132
+ method,
133
+ });
134
+ }
135
+ }
136
+ }
137
+ }
138
+ return operations;
139
+ }
140
+
141
+ /** Recognized operation methods present on one resolved path/webhook item. */
142
+ function operationMethodsOf(resolved: Record<string, any> | undefined): OpenApiMethod[] {
143
+ if (!resolved) return [];
144
+ return OPENAPI_METHODS.filter((method) => resolved[method] && typeof resolved[method] === 'object' && !Array.isArray(resolved[method]));
145
+ }
146
+
147
+ /** Keep only the routed methods of one item; `$ref`s stay verbatim unless the route takes a proper subset of the item's operations (then the resolved item is inlined). */
148
+ function filteredItem(document: OpenApiDocument, item: unknown, routed: Set<OpenApiMethod>): unknown {
149
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
150
+ const resolved = resolveForRouting(document, item) ?? {};
151
+ const allMethods = operationMethodsOf(resolved);
152
+ // When the route covers the item's whole operation set the original stays
153
+ // verbatim (`$ref`s survive for manifest-faithful reverse conversion); a
154
+ // partial route would leak the other operations through the `$ref`, so the
155
+ // resolved item is expanded in place.
156
+ const source = routed.size < allMethods.length ? resolved : item as Record<string, any>;
157
+ const filtered: Record<string, any> = {};
158
+ for (const [key, value] of Object.entries(source)) {
159
+ if (OPENAPI_METHODS.includes(key as OpenApiMethod)) {
160
+ if (routed.has(key as OpenApiMethod)) filtered[key] = value;
161
+ } else filtered[key] = value;
162
+ }
163
+ return filtered;
164
+ }
165
+
166
+ /**
167
+ * Plan preset buckets of one normalized document. **Pure** — no I/O, no writes.
168
+ *
169
+ * @param document Normalized Swagger/OpenAPI document to split.
170
+ * @param preset Split strategy: `multi-tag` (primary `tags[0]` per operation, untagged operations in `(untagged)`), or `multi-server` (effective first server: operation → path item → document).
171
+ * @returns One bucket per routed group in deterministic directory order, or `undefined` when the strategy has no effect (no tags anywhere / at most one distinct server).
172
+ * @throws {ZopiaError} ZOPIA_CONFIG_INVALID — the preset name is not one of {@link ZOPIA_GENERATE_PRESETS}.
173
+ */
174
+ export function planPresetBuckets(document: OpenApiDocument, preset: ZopiaGeneratePreset): ZopiaPresetBucket[] | undefined {
175
+ if (preset !== 'multi-tag' && preset !== 'multi-server') throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unsupported generate preset: ${String(preset)}`, { at: 'preset', hint: `use ${ZOPIA_GENERATE_PRESETS.join(' or ')}` });
176
+ const operations = collectPresetOperations(document, preset);
177
+ if (operations.length === 0) return undefined;
178
+ const keys = new Set(operations.map((operation) => operation.key));
179
+ // A single routed group (one tag / at most one server) renders exactly the
180
+ // normal tree — fall through instead of nesting a pointless sub-directory.
181
+ if (keys.size <= 1) return undefined;
182
+
183
+ // Deterministic bucket order: by display name, then collision-safe directory slug.
184
+ const buckets = new Map<string, { name: string; directory: string; operations: PresetOperation[]; warnings: ZopiaWarning[] }>();
185
+ for (const operation of operations) {
186
+ const existing = buckets.get(operation.key);
187
+ if (existing) existing.operations.push(operation);
188
+ else buckets.set(operation.key, { name: operation.name, directory: '', operations: [operation], warnings: [] });
189
+ if (operation.notice) buckets.get(operation.key)?.warnings.push(operation.notice);
190
+ }
191
+ const ordered = [...buckets.entries()].sort((left, right) => left[1].name < right[1].name ? -1 : left[1].name > right[1].name ? 1 : 0);
192
+ const claimed = new Map<string, string>();
193
+ for (const [key, bucket] of ordered) {
194
+ const base = slugify(bucket.name);
195
+ let directory = base;
196
+ let suffix = 2;
197
+ while ([...claimed.values()].includes(directory)) directory = `${base}-${suffix++}`;
198
+ claimed.set(key, directory);
199
+ bucket.directory = directory;
200
+ }
201
+
202
+ return ordered.map(([, bucket]) => {
203
+ const document2: OpenApiDocument = { ...document };
204
+ const routed = new Map<string, Set<OpenApiMethod>>();
205
+ for (const operation of bucket.operations) {
206
+ const routeKey = `${operation.section} ${operation.item}`;
207
+ const methods = routed.get(routeKey) ?? new Set<OpenApiMethod>();
208
+ methods.add(operation.method);
209
+ routed.set(routeKey, methods);
210
+ }
211
+ const paths: Record<string, unknown> = {};
212
+ for (const [path, item] of Object.entries((document.paths ?? {}) as Record<string, unknown>)) {
213
+ if (path.startsWith('x-')) { paths[path] = item; continue; }
214
+ const methods = routed.get(`paths ${path}`);
215
+ if (!methods) {
216
+ // Operations that routed elsewhere are dropped here, but an item with
217
+ // NO operations (shared `parameters`, `summary`/`x-` metadata, or an
218
+ // unresolved `$ref` when planning an unvalidated document directly)
219
+ // travels with every bucket — dropping it would silently lose facts.
220
+ if (operationMethodsOf(resolveForRouting(document, item)).length === 0) paths[path] = item;
221
+ continue;
222
+ }
223
+ const filtered = filteredItem(document, item, methods);
224
+ if (filtered && typeof filtered === 'object') paths[path] = filtered;
225
+ }
226
+ document2.paths = paths;
227
+ if (document.webhooks !== undefined) {
228
+ const webhooks: Record<string, unknown> = {};
229
+ for (const [name, item] of Object.entries((document.webhooks ?? {}) as Record<string, unknown>)) {
230
+ if (name.startsWith('x-')) { webhooks[name] = item; continue; }
231
+ const methods = routed.get(`webhooks ${name}`);
232
+ if (!methods) {
233
+ if (operationMethodsOf(resolveForRouting(document, item)).length === 0) webhooks[name] = item;
234
+ continue;
235
+ }
236
+ const filtered = filteredItem(document, item, methods);
237
+ if (filtered && typeof filtered === 'object') webhooks[name] = filtered;
238
+ }
239
+ // Drop the section entirely when this bucket kept nothing — an empty map
240
+ // would surface a spurious preserved-webhooks warning during generation.
241
+ if (Object.keys(webhooks).length > 0) document2.webhooks = webhooks;
242
+ else delete document2.webhooks;
243
+ }
244
+ return { name: bucket.name, directory: bucket.directory, document: document2, warnings: bucket.warnings };
245
+ });
246
+ }