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,876 @@
1
+ import { ZopiaError } from '../errors';
2
+
3
+ /** ๐Ÿงพ One input line with its 1-based number kept for diagnostics. */
4
+ interface YamlLine {
5
+ /** 1-based source line number used in diagnostics. */
6
+ no: number;
7
+ /** Raw line text after BOM/newline normalization. */
8
+ text: string;
9
+ }
10
+
11
+ /** ๐Ÿ”Ž A structural line: blank and full-comment lines are already skipped. */
12
+ interface SignificantLine {
13
+ /** 1-based source line number used in diagnostics. */
14
+ no: number;
15
+ /** Count of leading spaces (tab indentation is rejected before scanning). */
16
+ indent: number;
17
+ /** Line text after the indentation columns. */
18
+ content: string;
19
+ }
20
+
21
+ /** ๐Ÿง  Mutable parse state shared by every nested parse; no environment or I/O access (P-3). */
22
+ interface ParserContext {
23
+ /** Normalized source lines; `index` selects the next unconsumed raw line. */
24
+ lines: YamlLine[];
25
+ /** Index of the next raw line to consume. */
26
+ index: number;
27
+ /** Anchor table; anchors are declared before their aliases, so aliases cannot cycle. */
28
+ anchors: Map<string, unknown>;
29
+ /** Recursion depth guard against hostile nesting. */
30
+ depth: number;
31
+ }
32
+
33
+ /** Value position inside a captured flow collection. */
34
+ interface FlowCursor {
35
+ /** Complete captured flow text (may span several physical lines). */
36
+ text: string;
37
+ /** Current scan position inside `text`. */
38
+ pos: number;
39
+ /** Source line where the flow collection started, for diagnostics. */
40
+ line: number;
41
+ }
42
+
43
+ /** A single- or double-quote character used as a scalar delimiter. */
44
+ type YamlQuote = '"' | "'";
45
+
46
+ /** ๐Ÿ” Result of detecting a `key: โ€ฆ` entry on a line. */
47
+ interface KeyProbe {
48
+ /** Decoded key text (quotes removed; plain text verbatim). */
49
+ key: string;
50
+ /** Whether the key was quoted โ€” a quoted `<<` is never a merge indicator. */
51
+ quoted: boolean;
52
+ /** Remainder of the line after the separating colon. */
53
+ rest: string;
54
+ }
55
+
56
+ /** One parsed mapping entry before duplicate/merge resolution. */
57
+ interface MappingEntry {
58
+ /** Stringified mapping key (YAML scalar keys become strings). */
59
+ key: string;
60
+ /** Whether the entry is an unquoted `<<` merge indicator. */
61
+ merge: boolean;
62
+ /** Parsed entry value. */
63
+ value: unknown;
64
+ }
65
+
66
+ const YAML_MAX_DEPTH = 512;
67
+
68
+ /** Every YAML syntax/structure rejection is one stable typed error. */
69
+ function invalidYaml(reason: string, line: number): ZopiaError {
70
+ return new ZopiaError('ZOPIA_SPEC_INVALID_YAML', `invalid YAML at line ${line}: ${reason}`, { at: `line ${line}`, hint: 'fix the YAML syntax' });
71
+ }
72
+
73
+ function fail(reason: string, line: number): never {
74
+ throw invalidYaml(reason, line);
75
+ }
76
+
77
+ function normalizeLines(text: string): YamlLine[] {
78
+ const normalized = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
79
+ const parts = normalized.split('\n');
80
+ // A terminal line break closes the final line; it does not open an extra empty one.
81
+ if (parts[parts.length - 1] === '') parts.pop();
82
+ return parts.map((lineText, index) => ({ no: index + 1, text: lineText }));
83
+ }
84
+
85
+ function measureIndent(line: YamlLine): number {
86
+ let indent = 0;
87
+ while (indent < line.text.length) {
88
+ const character = line.text[indent];
89
+ if (character === ' ') indent += 1;
90
+ else if (character === '\t') fail('tab indentation is not supported; use spaces', line.no);
91
+ else break;
92
+ }
93
+ return indent;
94
+ }
95
+
96
+ function isSkippable(text: string): boolean {
97
+ const trimmed = text.replace(/^[ \t]+/, '');
98
+ return trimmed === '' || trimmed.startsWith('#');
99
+ }
100
+
101
+ function peekSignificant(ctx: ParserContext): SignificantLine | null {
102
+ for (let probe = ctx.index; probe < ctx.lines.length; probe += 1) {
103
+ const line = ctx.lines[probe];
104
+ if (isSkippable(line.text)) continue;
105
+ const indent = measureIndent(line);
106
+ return { no: line.no, indent, content: line.text.slice(indent) };
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function consumeSignificant(ctx: ParserContext): SignificantLine | null {
112
+ const line = peekSignificant(ctx);
113
+ if (!line) return null;
114
+ ctx.index = line.no;
115
+ return line;
116
+ }
117
+
118
+ function isDashEntry(content: string): boolean {
119
+ return content === '-' || (content.startsWith('-') && (content[1] === ' ' || content[1] === '\t'));
120
+ }
121
+
122
+ function isDocumentStart(content: string): boolean {
123
+ return /^---(?:[ \t]|$)/.test(content);
124
+ }
125
+
126
+ function isDocumentEnd(content: string): boolean {
127
+ return /^\.{3}(?:[ \t]|$)/.test(content);
128
+ }
129
+
130
+ function enter(ctx: ParserContext, line: number): void {
131
+ ctx.depth += 1;
132
+ if (ctx.depth > YAML_MAX_DEPTH) fail(`document nesting is too deep (maximum ${YAML_MAX_DEPTH} levels)`, line);
133
+ }
134
+
135
+ const NULL_PATTERN = /^(?:~|null|Null|NULL)$/;
136
+ const TRUE_PATTERN = /^(?:true|True|TRUE)$/;
137
+ const FALSE_PATTERN = /^(?:false|False|FALSE)$/;
138
+ const INT_PATTERN = /^[-+]?[0-9]+$/;
139
+ const OCTAL_PATTERN = /^0o[0-7]+$/;
140
+ const HEX_PATTERN = /^0x[0-9a-fA-F]+$/;
141
+ const FLOAT_PATTERN = /^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?$/;
142
+ const NON_JSON_NUMBER_PATTERN = /^(?:[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/;
143
+
144
+ /** YAML 1.2 core-schema scalar resolution, keeping parsed values JSON-compatible (D-16). */
145
+ function resolvePlainScalar(text: string, line: number): unknown {
146
+ if (NULL_PATTERN.test(text)) return null;
147
+ if (TRUE_PATTERN.test(text)) return true;
148
+ if (FALSE_PATTERN.test(text)) return false;
149
+ if (INT_PATTERN.test(text)) return Number.parseInt(text, 10);
150
+ if (OCTAL_PATTERN.test(text)) return Number.parseInt(text.slice(2), 8);
151
+ if (HEX_PATTERN.test(text)) return Number.parseInt(text.slice(2), 16);
152
+ if (FLOAT_PATTERN.test(text)) return Number.parseFloat(text);
153
+ if (NON_JSON_NUMBER_PATTERN.test(text)) fail(`'${text}' cannot be represented as a JSON number; quote it to keep a string`, line);
154
+ return text;
155
+ }
156
+
157
+ /** Strip a trailing comment from plain text (a `#` after whitespace) and drop trailing separation. */
158
+ function cutPlainComment(text: string): string {
159
+ if (text.startsWith('#')) return '';
160
+ const marker = /[ \t]#/.exec(text);
161
+ const cut = marker ? text.slice(0, marker.index + 1) : text;
162
+ return cut.replace(/[ \t]+$/, '');
163
+ }
164
+
165
+ /** Fold pre-cut multi-line content: single breaks become spaces, blank lines keep one break each. */
166
+ function assembleMultiline(first: string, continuations: string[]): string {
167
+ let text = first;
168
+ let blanks = 0;
169
+ for (const content of continuations) {
170
+ if (content === '') {
171
+ blanks += 1;
172
+ continue;
173
+ }
174
+ if (blanks > 0) {
175
+ text += `${'\n'.repeat(blanks)}${content}`;
176
+ blanks = 0;
177
+ } else text += ` ${content}`;
178
+ }
179
+ return text;
180
+ }
181
+
182
+ const DOUBLE_QUOTE_ESCAPES: Record<string, string> = {
183
+ '0': '\0',
184
+ a: '\u0007',
185
+ b: '\b',
186
+ t: '\t',
187
+ n: '\n',
188
+ v: '\u000B',
189
+ f: '\f',
190
+ r: '\r',
191
+ e: '\u001B',
192
+ '"': '"',
193
+ '/': '/',
194
+ '\\': '\\',
195
+ ' ': ' ',
196
+ N: '\u0085',
197
+ _: '\u00A0',
198
+ L: '\u2028',
199
+ P: '\u2029',
200
+ };
201
+
202
+ /** Decode a double-quoted body: YAML escapes, folded line breaks, and escaped-line-break continuations. */
203
+ function decodeDoubleQuoted(raw: string, line: number): string {
204
+ let result = '';
205
+ let index = 0;
206
+ while (index < raw.length) {
207
+ const character = raw[index];
208
+ if (character === '\n') {
209
+ let feeds = 0;
210
+ while (index < raw.length && (raw[index] === '\n' || raw[index] === ' ' || raw[index] === '\t')) {
211
+ if (raw[index] === '\n') feeds += 1;
212
+ index += 1;
213
+ }
214
+ result += feeds <= 1 ? ' ' : '\n'.repeat(feeds - 1);
215
+ continue;
216
+ }
217
+ if (character !== '\\') {
218
+ result += character;
219
+ index += 1;
220
+ continue;
221
+ }
222
+ const next = raw[index + 1];
223
+ if (next === '\n') {
224
+ // Escaped line break: the continuation joins the next line without a folded space.
225
+ index += 2;
226
+ while (index < raw.length && (raw[index] === ' ' || raw[index] === '\t')) index += 1;
227
+ continue;
228
+ }
229
+ if (next === 'x' || next === 'u' || next === 'U') {
230
+ const length = next === 'x' ? 2 : next === 'u' ? 4 : 8;
231
+ const digits = raw.slice(index + 2, index + 2 + length);
232
+ if (digits.length !== length || !/^[0-9a-fA-F]+$/.test(digits)) fail(`invalid \\${next} escape in a double-quoted scalar`, line);
233
+ const codePoint = Number.parseInt(digits, 16);
234
+ if (!Number.isFinite(codePoint) || codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) fail('invalid Unicode escape in a double-quoted scalar', line);
235
+ result += String.fromCodePoint(codePoint);
236
+ index += 2 + length;
237
+ continue;
238
+ }
239
+ const replacement = next === undefined ? undefined : DOUBLE_QUOTE_ESCAPES[next];
240
+ if (replacement === undefined) fail(`unknown escape '\\${next}' in a double-quoted scalar`, line);
241
+ result += replacement;
242
+ index += 2;
243
+ }
244
+ return result;
245
+ }
246
+
247
+ /** Decode a single-quoted body: the only escape is a doubled quote; line breaks fold like plain text. */
248
+ function decodeSingleQuoted(raw: string): string {
249
+ return raw.replace(/''/g, "'").replace(/\n[ \t]*(?:\n[ \t]*)*/g, (breaks) => {
250
+ const feeds = breaks.replace(/[ \t]/g, '').length;
251
+ return feeds <= 1 ? ' ' : '\n'.repeat(feeds - 1);
252
+ });
253
+ }
254
+
255
+ function skipFlowWhitespace(cursor: FlowCursor): void {
256
+ for (;;) {
257
+ const character = cursor.text[cursor.pos];
258
+ if (character === ' ' || character === '\t' || character === '\n' || character === '\r') cursor.pos += 1;
259
+ else if (character === '#') {
260
+ while (cursor.pos < cursor.text.length && cursor.text[cursor.pos] !== '\n') cursor.pos += 1;
261
+ } else return;
262
+ }
263
+ }
264
+
265
+ /** Scan a quoted scalar body inside a captured buffer; reports where the closing quote sits. */
266
+ function scanQuotedClose(text: string, quote: YamlQuote): { closed: boolean; end: number; raw: string } {
267
+ let raw = '';
268
+ let index = 1;
269
+ while (index < text.length) {
270
+ const character = text[index];
271
+ if (quote === '"' && character === '\\') {
272
+ raw += character + (index + 1 < text.length ? text[index + 1] : '');
273
+ index += 2;
274
+ continue;
275
+ }
276
+ if (character === quote) {
277
+ if (quote === "'" && text[index + 1] === "'") {
278
+ raw += "''";
279
+ index += 2;
280
+ continue;
281
+ }
282
+ return { closed: true, end: index, raw };
283
+ }
284
+ raw += character;
285
+ index += 1;
286
+ }
287
+ return { closed: false, end: -1, raw };
288
+ }
289
+
290
+ function scanFlowQuoted(cursor: FlowCursor, quote: YamlQuote): string {
291
+ const scan = scanQuotedClose(cursor.text.slice(cursor.pos), quote);
292
+ if (!scan.closed) fail('unterminated quoted scalar inside a flow collection', cursor.line);
293
+ cursor.pos += scan.end + 1;
294
+ return quote === '"' ? decodeDoubleQuoted(scan.raw, cursor.line) : decodeSingleQuoted(scan.raw);
295
+ }
296
+
297
+ function parseFlowValue(ctx: ParserContext, cursor: FlowCursor): unknown {
298
+ enter(ctx, cursor.line);
299
+ try {
300
+ skipFlowWhitespace(cursor);
301
+ const character = cursor.text[cursor.pos];
302
+ if (character === undefined) fail('unexpected end of a flow collection', cursor.line);
303
+ if (character === '{') return parseFlowMapping(ctx, cursor);
304
+ if (character === '[') return parseFlowSequence(ctx, cursor);
305
+ if (character === '&') {
306
+ const anchorText = /^&([A-Za-z0-9_.-]+)/.exec(cursor.text.slice(cursor.pos));
307
+ if (!anchorText) fail('malformed anchor inside a flow collection', cursor.line);
308
+ cursor.pos += anchorText[0].length;
309
+ const value = parseFlowValue(ctx, cursor);
310
+ ctx.anchors.set(anchorText[1], value);
311
+ return value;
312
+ }
313
+ if (character === '*') {
314
+ const aliasText = /^\*([A-Za-z0-9_.-]+)/.exec(cursor.text.slice(cursor.pos));
315
+ if (!aliasText) fail('malformed alias inside a flow collection', cursor.line);
316
+ cursor.pos += aliasText[0].length;
317
+ if (!ctx.anchors.has(aliasText[1])) fail(`undefined alias: *${aliasText[1]}`, cursor.line);
318
+ return cloneValue(ctx.anchors.get(aliasText[1]));
319
+ }
320
+ if (character === '!') fail('YAML tags are not supported in flow collections', cursor.line);
321
+ if (character === '%' || character === '@' || character === '`') fail(`a plain scalar must not start with '${character}'`, cursor.line);
322
+ if (character === "'" || character === '"') return scanFlowQuoted(cursor, character);
323
+ const start = cursor.pos;
324
+ while (cursor.pos < cursor.text.length && ![',', ']', '}', '{', '['].includes(cursor.text[cursor.pos])) {
325
+ if (cursor.text[cursor.pos] === ':') {
326
+ const afterColon = cursor.text[cursor.pos + 1];
327
+ // A ':' followed by separation (or a bracket) ends a flow plain scalar;
328
+ // the missing comma then fails against the collection grammar.
329
+ if (afterColon === undefined || [' ', '\t', ',', ']', '}', '{', '['].includes(afterColon)) break;
330
+ }
331
+ if (cursor.text[cursor.pos] === '#' && cursor.pos > start && (cursor.text[cursor.pos - 1] === ' ' || cursor.text[cursor.pos - 1] === '\t')) break;
332
+ cursor.pos += 1;
333
+ }
334
+ const scalar = cursor.text.slice(start, cursor.pos).replace(/\s+/g, ' ').trim();
335
+ if (scalar === '') fail('expected a value inside the flow collection', cursor.line);
336
+ return resolvePlainScalar(scalar, cursor.line);
337
+ } finally {
338
+ ctx.depth -= 1;
339
+ }
340
+ }
341
+
342
+ /** Earlier merge sources win over later ones; explicit keys always win over merged keys. */
343
+ function applyMappingEntries(entries: MappingEntry[], line: number): Record<string, unknown> {
344
+ const explicit = new Map<string, unknown>();
345
+ const merges: Array<Record<string, unknown>> = [];
346
+ for (const entry of entries) {
347
+ if (entry.merge) {
348
+ const sources = Array.isArray(entry.value) ? entry.value : [entry.value];
349
+ for (const source of sources) {
350
+ if (!source || typeof source !== 'object' || Array.isArray(source)) fail('a `<<` merge value must be a mapping or a sequence of mappings (usually aliases)', line);
351
+ merges.push(source as Record<string, unknown>);
352
+ }
353
+ continue;
354
+ }
355
+ if (explicit.has(entry.key)) fail(`duplicate mapping key: '${entry.key}'`, line);
356
+ explicit.set(entry.key, entry.value);
357
+ }
358
+ const result: Record<string, unknown> = {};
359
+ for (const source of merges) for (const [key, value] of Object.entries(source)) if (!(key in result)) result[key] = value;
360
+ for (const [key, value] of explicit) result[key] = value;
361
+ return result;
362
+ }
363
+
364
+ function parseFlowMapping(ctx: ParserContext, cursor: FlowCursor): Record<string, unknown> {
365
+ enter(ctx, cursor.line);
366
+ try {
367
+ const entries: MappingEntry[] = [];
368
+ cursor.pos += 1;
369
+ skipFlowWhitespace(cursor);
370
+ if (cursor.text[cursor.pos] === '}') {
371
+ cursor.pos += 1;
372
+ return {};
373
+ }
374
+ for (;;) {
375
+ skipFlowWhitespace(cursor);
376
+ const character = cursor.text[cursor.pos];
377
+ if (character === undefined) fail('unterminated flow mapping', cursor.line);
378
+ if (character === '}' || character === ',') fail(`unexpected '${character}' inside a flow mapping`, cursor.line);
379
+ let key: string;
380
+ let quoted = false;
381
+ if (character === "'" || character === '"') {
382
+ quoted = true;
383
+ key = scanFlowQuoted(cursor, character);
384
+ } else {
385
+ const start = cursor.pos;
386
+ while (cursor.pos < cursor.text.length && ![':', ',', '}', '{', '['].includes(cursor.text[cursor.pos])) cursor.pos += 1;
387
+ key = cursor.text.slice(start, cursor.pos).replace(/\s+/g, ' ').trim();
388
+ if (key === '') fail('a flow mapping key must not be empty', cursor.line);
389
+ if (cursor.text[cursor.pos] !== ':' && cursor.text[cursor.pos] !== ',' && cursor.text[cursor.pos] !== '}') fail('complex flow mapping keys are not supported', cursor.line);
390
+ if (key !== '<<') key = String(resolvePlainScalar(key, cursor.line));
391
+ }
392
+ skipFlowWhitespace(cursor);
393
+ let value: unknown = null;
394
+ if (cursor.text[cursor.pos] === ':') {
395
+ cursor.pos += 1;
396
+ skipFlowWhitespace(cursor);
397
+ if (cursor.text[cursor.pos] === undefined) fail('unterminated flow mapping', cursor.line);
398
+ if (cursor.text[cursor.pos] !== ',' && cursor.text[cursor.pos] !== '}') value = parseFlowValue(ctx, cursor);
399
+ }
400
+ entries.push({ key, merge: key === '<<' && !quoted, value });
401
+ skipFlowWhitespace(cursor);
402
+ if (cursor.text[cursor.pos] === ',') {
403
+ cursor.pos += 1;
404
+ skipFlowWhitespace(cursor);
405
+ if (cursor.text[cursor.pos] === '}') {
406
+ cursor.pos += 1;
407
+ return applyMappingEntries(entries, cursor.line);
408
+ }
409
+ continue;
410
+ }
411
+ if (cursor.text[cursor.pos] === '}') {
412
+ cursor.pos += 1;
413
+ return applyMappingEntries(entries, cursor.line);
414
+ }
415
+ fail("expected ',' or '}' inside the flow mapping", cursor.line);
416
+ }
417
+ } finally {
418
+ ctx.depth -= 1;
419
+ }
420
+ }
421
+
422
+ function parseFlowSequence(ctx: ParserContext, cursor: FlowCursor): unknown[] {
423
+ enter(ctx, cursor.line);
424
+ try {
425
+ const items: unknown[] = [];
426
+ cursor.pos += 1;
427
+ skipFlowWhitespace(cursor);
428
+ if (cursor.text[cursor.pos] === ']') {
429
+ cursor.pos += 1;
430
+ return items;
431
+ }
432
+ for (;;) {
433
+ skipFlowWhitespace(cursor);
434
+ if (cursor.text[cursor.pos] === undefined) fail('unterminated flow sequence', cursor.line);
435
+ if (cursor.text[cursor.pos] === ',') fail("unexpected ',' inside a flow sequence", cursor.line);
436
+ items.push(parseFlowValue(ctx, cursor));
437
+ skipFlowWhitespace(cursor);
438
+ if (cursor.text[cursor.pos] === ',') {
439
+ cursor.pos += 1;
440
+ skipFlowWhitespace(cursor);
441
+ if (cursor.text[cursor.pos] === ']') {
442
+ cursor.pos += 1;
443
+ return items;
444
+ }
445
+ continue;
446
+ }
447
+ if (cursor.text[cursor.pos] === ']') {
448
+ cursor.pos += 1;
449
+ return items;
450
+ }
451
+ fail("expected ',' or ']' inside the flow sequence", cursor.line);
452
+ }
453
+ } finally {
454
+ ctx.depth -= 1;
455
+ }
456
+ }
457
+
458
+ /** The remainder after an inline value must be only separation and an optional comment. */
459
+ function assertNoTrailingContent(rest: string, line: number): void {
460
+ const trimmed = rest.replace(/^[ \t]+/, '');
461
+ if (trimmed !== '' && !trimmed.startsWith('#')) fail(`unexpected content after the value: '${trimmed.slice(0, 32)}'`, line);
462
+ }
463
+
464
+ /** Capture a (possibly multi-line) flow collection that starts at `[` or `{`, then parse it. */
465
+ function parseFlowNode(ctx: ParserContext, rest: string, lineNo: number, parentIndent: number): unknown {
466
+ let buffer = rest;
467
+ let depth = 0;
468
+ let quote: YamlQuote | undefined;
469
+ let index = 0;
470
+ for (;;) {
471
+ const character = buffer[index];
472
+ if (character === undefined) {
473
+ let extended = false;
474
+ while (ctx.index < ctx.lines.length) {
475
+ const line = ctx.lines[ctx.index];
476
+ if (isSkippable(line.text) && line.text.replace(/^[ \t]+/, '') === '') {
477
+ buffer += '\n';
478
+ ctx.index += 1;
479
+ continue;
480
+ }
481
+ // Flow collections ignore indentation: the closing bracket may be dedented.
482
+ buffer += `\n${line.text}`;
483
+ ctx.index += 1;
484
+ extended = true;
485
+ break;
486
+ }
487
+ if (!extended) fail('unterminated flow collection', lineNo);
488
+ continue;
489
+ }
490
+ if (quote) {
491
+ if (quote === '"' && character === '\\') index += 2;
492
+ else if (character === quote) {
493
+ if (quote === "'" && buffer[index + 1] === "'") index += 2;
494
+ else {
495
+ quote = undefined;
496
+ index += 1;
497
+ }
498
+ } else index += 1;
499
+ continue;
500
+ }
501
+ const previous = index > 0 ? buffer[index - 1] : '\\n';
502
+ if (character === '#' && (previous === ' ' || previous === '\\t' || previous === '\\n')) {
503
+ // Comments inside a captured flow collection are skipped for capture so
504
+ // quotes/braces inside them cannot corrupt quote- or depth-tracking.
505
+ while (index < buffer.length && buffer[index] !== '\\n') index += 1;
506
+ continue;
507
+ }
508
+ if (character === "'" || character === '"') {
509
+ quote = character;
510
+ index += 1;
511
+ continue;
512
+ }
513
+ if (character === '{' || character === '[') depth += 1;
514
+ else if (character === '}' || character === ']') {
515
+ depth -= 1;
516
+ if (depth === 0) {
517
+ assertNoTrailingContent(buffer.slice(index + 1), lineNo);
518
+ return parseFlowValue(ctx, { text: buffer, pos: 0, line: lineNo });
519
+ }
520
+ }
521
+ index += 1;
522
+ }
523
+ }
524
+
525
+ /** Alias resolutions clone the anchored value so shared identity never reaches downstream walkers. */
526
+ function cloneValue(value: unknown): unknown {
527
+ if (Array.isArray(value)) return value.map(cloneValue);
528
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, cloneValue(entry)]));
529
+ return value;
530
+ }
531
+
532
+ /** Literal/folded block scalar with every chomping mode and optional indent indicators. */
533
+ function parseBlockScalar(ctx: ParserContext, style: '|' | '>', header: string, lineNo: number, parentIndent: number): string {
534
+ let chomping: '+' | '-' | undefined;
535
+ let digit: number | undefined;
536
+ let index = 1;
537
+ for (; index < header.length; index += 1) {
538
+ const character = header[index];
539
+ if ((character === '+' || character === '-') && chomping === undefined) chomping = character as '+' | '-';
540
+ else if (/^[1-9]$/.test(character) && digit === undefined) digit = Number.parseInt(character, 10);
541
+ else break;
542
+ }
543
+ const remainder = header.slice(index);
544
+ if (!/^[ \t]*(?:#.*)?$/.test(remainder)) fail(`unexpected content in the block scalar header: '${remainder.replace(/^[ \t]+/, '').slice(0, 16)}'`, lineNo);
545
+ let contentIndent = digit !== undefined ? parentIndent + digit : undefined;
546
+ const rows: Array<{ blank: boolean; text: string; extraIndent: number }> = [];
547
+ while (ctx.index < ctx.lines.length) {
548
+ const line = ctx.lines[ctx.index];
549
+ const trimmed = line.text.replace(/^[ \t]+/, '');
550
+ if (trimmed === '') {
551
+ rows.push({ blank: true, text: '', extraIndent: 0 });
552
+ ctx.index += 1;
553
+ continue;
554
+ }
555
+ const indent = measureIndent(line);
556
+ if (trimmed.startsWith('#')) {
557
+ // A '#' line indented as deeply as the content is literal block-scalar
558
+ // content (block scalars embed no comments); a shallower '#' line is an
559
+ // ignorable comment that neither terminates nor folds the scalar.
560
+ const isContent = contentIndent === undefined ? indent > parentIndent : indent >= contentIndent;
561
+ if (isContent) {
562
+ if (contentIndent === undefined) contentIndent = indent;
563
+ rows.push({ blank: false, text: line.text.slice(contentIndent), extraIndent: indent - contentIndent });
564
+ }
565
+ ctx.index += 1;
566
+ continue;
567
+ }
568
+ if (contentIndent === undefined) {
569
+ if (indent <= parentIndent) break;
570
+ contentIndent = indent;
571
+ }
572
+ if (indent < contentIndent) break;
573
+ rows.push({ blank: false, text: line.text.slice(contentIndent), extraIndent: indent - contentIndent });
574
+ ctx.index += 1;
575
+ }
576
+ const lastContent = rows.reduce((last, row, index) => (row.blank ? last : index), -1);
577
+ const body = rows.slice(0, lastContent + 1);
578
+ const trailing = rows.length - body.length;
579
+ let text: string;
580
+ if (style === '|') text = body.map((row) => row.text).join('\n');
581
+ else {
582
+ // Folded style: empty lines keep exactly one break each; breaks around
583
+ // more-indented lines stay literal; every other line break folds to a space.
584
+ text = '';
585
+ let blanks = 0;
586
+ let previousExtra = 0;
587
+ let firstContent = true;
588
+ for (const row of body) {
589
+ if (row.blank) {
590
+ blanks += 1;
591
+ continue;
592
+ }
593
+ if (firstContent) {
594
+ text = `${'\n'.repeat(blanks)}${row.text}`;
595
+ firstContent = false;
596
+ } else text += `${blanks > 0 ? '\n'.repeat(blanks) : previousExtra > 0 || row.extraIndent > 0 ? '\n' : ' '}${row.text}`;
597
+ blanks = 0;
598
+ previousExtra = row.extraIndent;
599
+ }
600
+ }
601
+ if (lastContent === -1) return chomping === '+' ? '\n'.repeat(trailing) : '';
602
+ if (chomping === '-') return text;
603
+ if (chomping === '+') return `${text}\n${'\n'.repeat(trailing)}`;
604
+ return `${text}\n`;
605
+ }
606
+
607
+ /** Scan a possibly multi-line quoted scalar; the closing line must carry nothing but a comment. */
608
+ function parseQuotedNode(ctx: ParserContext, firstText: string, lineNo: number, parentIndent: number, quote: YamlQuote): string {
609
+ let text = firstText;
610
+ for (;;) {
611
+ const scan = scanQuotedClose(text, quote);
612
+ if (scan.closed) {
613
+ assertNoTrailingContent(text.slice(scan.end + 1), lineNo);
614
+ return quote === '"' ? decodeDoubleQuoted(scan.raw, lineNo) : decodeSingleQuoted(scan.raw);
615
+ }
616
+ const line = ctx.lines[ctx.index];
617
+ if (!line) fail(`unterminated ${quote === '"' ? 'double' : 'single'}-quoted scalar`, lineNo);
618
+ const trimmed = line.text.replace(/^[ \t]+/, '');
619
+ if (trimmed !== '' && measureIndent(line) <= parentIndent) fail(`unterminated ${quote === '"' ? 'double' : 'single'}-quoted scalar`, lineNo);
620
+ text += `\n${line.text}`;
621
+ ctx.index += 1;
622
+ }
623
+ }
624
+
625
+ /** Detect a `key: โ€ฆ` entry on a line; returns null when the line does not start a mapping entry. */
626
+ function probeMappingKey(content: string, line: number): KeyProbe | null {
627
+ const first = content[0];
628
+ if (first === undefined || '[{]},&*!|>%@`'.includes(first)) return null;
629
+ if (first === "'" || first === '"') {
630
+ const scan = scanQuotedClose(content, first);
631
+ if (!scan.closed) return null;
632
+ const rest = content.slice(scan.end + 1).replace(/^[ \t]+/, '');
633
+ if (!rest.startsWith(':')) return null;
634
+ const afterColon = rest.slice(1);
635
+ if (afterColon !== '' && !/^[ \t]/.test(afterColon)) return null;
636
+ return { key: first === '"' ? decodeDoubleQuoted(scan.raw, line) : decodeSingleQuoted(scan.raw), quoted: true, rest: afterColon };
637
+ }
638
+ for (let index = 0; index < content.length; index += 1) {
639
+ if (content[index] !== ':') continue;
640
+ const after = content[index + 1];
641
+ if (after !== undefined && after !== ' ' && after !== '\t') continue;
642
+ return { key: content.slice(0, index).replace(/[ \t]+$/, ''), quoted: false, rest: content.slice(index + 1) };
643
+ }
644
+ return null;
645
+ }
646
+
647
+ function resolveMappingKey(probe: KeyProbe, line: number): string {
648
+ if (probe.quoted) return probe.key;
649
+ if (probe.key === '') fail('a mapping key must not be empty', line);
650
+ if (probe.key === '<<') return probe.key;
651
+ const value = resolvePlainScalar(probe.key, line);
652
+ return typeof value === 'string' ? value : String(value);
653
+ }
654
+
655
+ /** Plain (unquoted) scalar, possibly folded across more-indented continuation lines. */
656
+ function parsePlainNode(ctx: ParserContext, first: string, lineNo: number, parentIndent: number): unknown {
657
+ const firstLine = cutPlainComment(first);
658
+ if (/:[ \t]/.test(firstLine) || firstLine.endsWith(':')) fail("a plain scalar must not contain ': ' or end with ':'", lineNo);
659
+ const continuations: string[] = [];
660
+ while (ctx.index < ctx.lines.length) {
661
+ const line = ctx.lines[ctx.index];
662
+ if (isSkippable(line.text)) {
663
+ // Blank lines fold to breaks; comment lines inside a multi-line plain scalar are invisible.
664
+ if (line.text.replace(/^[ \t]+/, '') === '') continuations.push('');
665
+ ctx.index += 1;
666
+ continue;
667
+ }
668
+ const indent = measureIndent(line);
669
+ if (indent <= parentIndent) break;
670
+ const content = line.text.slice(indent);
671
+ if (isDashEntry(content)) fail('a multi-line plain continuation must not start a sequence entry (bad indentation)', line.no);
672
+ const probe = probeMappingKey(content, line.no);
673
+ if (probe) fail(`a multi-line plain continuation must not start a mapping entry (bad indentation): '${probe.key}:'`, line.no);
674
+ continuations.push(cutPlainComment(content));
675
+ ctx.index += 1;
676
+ }
677
+ while (continuations.length > 0 && continuations[continuations.length - 1] === '') continuations.pop();
678
+ return resolvePlainScalar(assembleMultiline(firstLine, continuations), lineNo);
679
+ }
680
+
681
+ /** One mapping entry: its value may live inline or on nested following lines. */
682
+ function readMappingEntry(ctx: ParserContext, probe: KeyProbe, line: number, mappingIndent: number): MappingEntry {
683
+ const key = resolveMappingKey(probe, line);
684
+ if (!probe.quoted && probe.key === '?') fail('explicit `?` mapping keys are not supported', line);
685
+ const value = parseInlineNode(ctx, probe.rest, line, mappingIndent, mappingIndent + 2, false);
686
+ return { key, merge: key === '<<' && !probe.quoted, value };
687
+ }
688
+
689
+ /** Collect every `key: value` line at `mappingIndent`, starting with an already-read inline entry. */
690
+ function collectMappingEntries(ctx: ParserContext, mappingIndent: number, inlineStart?: { probe: KeyProbe; line: number }): MappingEntry[] {
691
+ const entries: MappingEntry[] = [];
692
+ if (inlineStart) entries.push(readMappingEntry(ctx, inlineStart.probe, inlineStart.line, mappingIndent));
693
+ for (;;) {
694
+ const line = peekSignificant(ctx);
695
+ if (!line || line.indent !== mappingIndent || isDashEntry(line.content)) break;
696
+ if (isDocumentStart(line.content) || isDocumentEnd(line.content)) break;
697
+ const probe = probeMappingKey(line.content, line.no);
698
+ if (!probe) fail('expected a `key: value` mapping entry', line.no);
699
+ consumeSignificant(ctx);
700
+ entries.push(readMappingEntry(ctx, probe, line.no, mappingIndent));
701
+ }
702
+ return entries;
703
+ }
704
+
705
+ /** Sequence items at a fixed indent; inline starts are dispatched through `parseInlineNode`. */
706
+ function parseSequenceFrom(ctx: ParserContext, sequenceIndent: number, inline?: { content: string; line: number; itemIndent: number }, leading: unknown[] = []): unknown[] {
707
+ const items: unknown[] = [...leading];
708
+ if (inline) items.push(parseInlineNode(ctx, inline.content, inline.line, sequenceIndent, inline.itemIndent, true));
709
+ for (;;) {
710
+ const line = peekSignificant(ctx);
711
+ if (!line || line.indent !== sequenceIndent || !isDashEntry(line.content)) break;
712
+ consumeSignificant(ctx);
713
+ const afterDash = line.content.slice(1);
714
+ const leadingSpaces = afterDash.length - afterDash.replace(/^[ \t]+/, '').length;
715
+ const content = afterDash.slice(leadingSpaces);
716
+ if (content === '' || content.startsWith('#')) items.push(parseNestedBlock(ctx, sequenceIndent, false));
717
+ else items.push(parseInlineNode(ctx, content, line.no, sequenceIndent, sequenceIndent + 1 + leadingSpaces, true));
718
+ }
719
+ return items;
720
+ }
721
+
722
+ /** A value that may start a nested block sequence/mapping deeper, or a sequence at the parent indent. */
723
+ function parseNestedBlock(ctx: ParserContext, parentIndent: number, allowSiblingSequence = true): unknown {
724
+ const line = peekSignificant(ctx);
725
+ if (!line) return null;
726
+ if (line.indent > parentIndent) return parseBlockNode(ctx, line.indent);
727
+ // Only a mapping key may open an indentless sibling sequence; an empty `-`
728
+ // item must leave same-indent dashes for its own (outer) sequence loop.
729
+ if (allowSiblingSequence && line.indent === parentIndent && isDashEntry(line.content)) return parseSequenceFrom(ctx, parentIndent);
730
+ return null;
731
+ }
732
+
733
+ /** Dispatch a node whose first significant line sits at `nodeIndent`. */
734
+ function parseBlockNode(ctx: ParserContext, nodeIndent: number): unknown {
735
+ const line = peekSignificant(ctx);
736
+ if (!line || line.indent !== nodeIndent) fail('could not parse the nested block', line?.no ?? 1);
737
+ if (isDashEntry(line.content)) return parseSequenceFrom(ctx, nodeIndent);
738
+ const probe = probeMappingKey(line.content, line.no);
739
+ if (probe) {
740
+ consumeSignificant(ctx);
741
+ return applyMappingEntries(collectMappingEntries(ctx, nodeIndent, { probe, line: line.no }), line.no);
742
+ }
743
+ consumeSignificant(ctx);
744
+ return parseInlineNode(ctx, line.content, line.no, nodeIndent - 1, nodeIndent + 1, true);
745
+ }
746
+
747
+ /**
748
+ * Value that starts inline on an already-consumed line (after `key:` or `- `).
749
+ * `allowInlineMapping` is true only where YAML permits a mapping to begin
750
+ * inline (sequence items), so `key: a: b` stays a hard error.
751
+ */
752
+ function parseInlineNode(ctx: ParserContext, text: string, lineNo: number, parentIndent: number, itemIndent: number, allowInlineMapping: boolean): unknown {
753
+ enter(ctx, lineNo);
754
+ try {
755
+ let rest = text.replace(/^[ \t]+/, '');
756
+ let anchor: string | undefined;
757
+ for (;;) {
758
+ const anchored = /^&([A-Za-z0-9_.-]+)(?=$|[ \t#])/.exec(rest);
759
+ if (!anchored) break;
760
+ anchor = anchored[1];
761
+ rest = rest.slice(anchored[0].length).replace(/^[ \t]+/, '');
762
+ }
763
+ const finish = (resolved: unknown): unknown => {
764
+ if (anchor !== undefined) ctx.anchors.set(anchor, resolved);
765
+ return resolved;
766
+ };
767
+ if (rest === '' || rest.startsWith('#')) return finish(parseNestedBlock(ctx, parentIndent));
768
+ const aliased = /^\*([A-Za-z0-9_.-]+)(?=$|[ \t#])/.exec(rest);
769
+ if (aliased) {
770
+ if (anchor !== undefined) fail('an anchor cannot prefix an alias', lineNo);
771
+ assertNoTrailingContent(rest.slice(aliased[0].length), lineNo);
772
+ if (!ctx.anchors.has(aliased[1])) fail(`undefined alias: *${aliased[1]}`, lineNo);
773
+ return cloneValue(ctx.anchors.get(aliased[1]));
774
+ }
775
+ const probe = probeMappingKey(rest, lineNo);
776
+ if (probe) {
777
+ if (!allowInlineMapping) fail(`a mapping value cannot start another inline mapping ('${probe.key}: โ€ฆ' is not allowed in this position)`, lineNo);
778
+ return finish(applyMappingEntries(collectMappingEntries(ctx, itemIndent, { probe, line: lineNo }), lineNo));
779
+ }
780
+ const first = rest[0];
781
+ if (first === '!') fail('YAML tags are not supported', lineNo);
782
+ if (first === '%' || first === '@' || first === '`') fail(`a plain scalar must not start with '${first}'`, lineNo);
783
+ if (first === '|' || first === '>') return finish(parseBlockScalar(ctx, first, rest, lineNo, parentIndent));
784
+ if (first === '{' || first === '[') return finish(parseFlowNode(ctx, rest, lineNo, parentIndent));
785
+ if (first === "'" || first === '"') return finish(parseQuotedNode(ctx, rest, lineNo, parentIndent, first));
786
+ if (first === '-' && (rest === '-' || rest[1] === ' ' || rest[1] === '\\t')) {
787
+ // An inline dash starts a nested sequence whose items continue at the dash column.
788
+ const afterDash = rest.slice(1);
789
+ const leadingSpaces = afterDash.length - afterDash.replace(/^[ \\t]+/, '').length;
790
+ let content = afterDash.slice(leadingSpaces);
791
+ if (content.startsWith('#')) content = '';
792
+ if (content === '') {
793
+ // A lone dash is the plain scalar '-' after a mapping key; after a
794
+ // sequence dash it opens a nested block sequence with an empty item.
795
+ if (!allowInlineMapping) return finish(parsePlainNode(ctx, '-', lineNo, parentIndent));
796
+ return finish(parseSequenceFrom(ctx, itemIndent, undefined, [null]));
797
+ }
798
+ return finish(parseSequenceFrom(ctx, itemIndent, { content, line: lineNo, itemIndent: itemIndent + 1 + leadingSpaces }));
799
+ }
800
+ if (first === '?' || first === ':' || first === ',') fail(`a plain scalar must not start with '${first}'`, lineNo);
801
+ return finish(parsePlainNode(ctx, rest, lineNo, parentIndent));
802
+ } finally {
803
+ ctx.depth -= 1;
804
+ }
805
+ }
806
+
807
+ /** Root node: after directives/markers, everything else is one complete document. */
808
+ function parseDocumentRoot(ctx: ParserContext, first: SignificantLine | null): unknown {
809
+ enter(ctx, first?.no ?? 1);
810
+ try {
811
+ let line = first;
812
+ if (line && isDocumentStart(line.content)) {
813
+ const afterMarker = line.content.slice(3).replace(/^[ \t]+/, '');
814
+ consumeSignificant(ctx);
815
+ if (afterMarker !== '' && !afterMarker.startsWith('#')) return parseInlineNode(ctx, afterMarker, line.no, -1, 4, true);
816
+ line = peekSignificant(ctx);
817
+ }
818
+ if (!line || isDocumentEnd(line.content)) fail('YAML document is empty', line?.no ?? 1);
819
+ return parseBlockNode(ctx, line.indent);
820
+ } finally {
821
+ ctx.depth -= 1;
822
+ }
823
+ }
824
+
825
+ /**
826
+ * ๐Ÿงพ Parse YAML spec text into JSON-compatible values (single-document YAML 1.2 core schema).
827
+ *
828
+ * Supported: block/flow mappings and sequences, plain/single/double-quoted
829
+ * scalars, literal and folded block scalars with every chomping mode and
830
+ * indent indicators, comments, anchors, aliases, `<<` merge keys, `%YAML 1.x`
831
+ * directives, and `---`/`...` document markers. Rejected with a typed error:
832
+ * tab indentation, duplicate mapping keys, undefined aliases, custom tags,
833
+ * multiple documents, complex `?` keys, and the non-JSON numbers
834
+ * `.inf`/`.nan`. Output values are plain JavaScript (objects, arrays,
835
+ * strings, numbers, booleans, null) so the OpenAPI pipeline sees exactly what
836
+ * an equivalent JSON document would produce (D-16).
837
+ *
838
+ * @param text YAML document text read from a `.yaml`/`.yml` spec or passed inline.
839
+ * @returns Plain JavaScript values (`Record`, arrays, strings, numbers, booleans, or null).
840
+ * @throws {ZopiaError} ๐Ÿ†” `ZOPIA_SPEC_INVALID_YAML` โ€” malformed structure, unsupported constructs, or non-JSON values.
841
+ * @example
842
+ * ```ts
843
+ * import { parseYaml } from './src/conversions/yaml';
844
+ *
845
+ * const document = parseYaml("paths:\n /health:\n get:\n responses: {}\n");
846
+ * console.log(document);
847
+ * ```
848
+ * @see [docs/06-conversions.md โ†’ Engine โ‘ข](../../docs/06-conversions.md)
849
+ */
850
+ export function parseYaml(text: string): unknown {
851
+ if (typeof text !== 'string' || text.trim() === '') {
852
+ throw new ZopiaError('ZOPIA_SPEC_INVALID_YAML', 'invalid YAML: document is empty', { at: 'line 1', hint: 'provide non-empty YAML spec text' });
853
+ }
854
+ const ctx: ParserContext = { lines: normalizeLines(text), index: 0, anchors: new Map(), depth: 0 };
855
+ let line = peekSignificant(ctx);
856
+ let directive = false;
857
+ while (line && line.content.startsWith('%')) {
858
+ if (!/^%YAML[ \t]+1\.[0-9]+[ \t]*$/.test(line.content)) fail(`unsupported YAML directive: ${line.content}`, line.no);
859
+ directive = true;
860
+ consumeSignificant(ctx);
861
+ line = peekSignificant(ctx);
862
+ }
863
+ if (directive && (!line || !isDocumentStart(line.content))) fail("YAML directives must be followed by a '---' document start", line?.no ?? 1);
864
+ const root = parseDocumentRoot(ctx, line);
865
+ const trailing = peekSignificant(ctx);
866
+ if (trailing) {
867
+ if (isDocumentEnd(trailing.content)) {
868
+ if (!/^\.{3}(?:[ \t]+#.*)?$/.test(trailing.content)) fail("unexpected content after the end-of-document marker '...'", trailing.no);
869
+ consumeSignificant(ctx);
870
+ const beyond = peekSignificant(ctx);
871
+ if (beyond) fail('unexpected content after the end-of-document marker', beyond.no);
872
+ } else if (isDocumentStart(trailing.content)) fail('multiple YAML documents are not supported', trailing.no);
873
+ else fail(`unexpected content: '${trailing.content.slice(0, 32)}'`, trailing.no);
874
+ }
875
+ return root;
876
+ }