const assert = require("assert"); const fs = require("fs"); const path = require("path"); const root = path.resolve(__dirname, ".."); const LOSSLESS_NUMBER = Symbol("openapi-parity-number"); // JSON/YAML 的 number 语义不等于 JavaScript 的 IEEE-754 Number。统一归一化为 // “有效数字 e 十进制指数”,可让 1、1.0、10e-1 等价,同时无损区分任意大整数。 const normalizeNumber = (source) => { const match = /^(-?)(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(source); if (!match) throw new Error(`Invalid JSON/YAML number: ${source}`); const fraction = match[3] || ""; let digits = `${match[2]}${fraction}`.replace(/^0+/, ""); if (!digits) return "0e0"; let exponent = BigInt(match[4] || "0") - BigInt(fraction.length); const trailingZeros = digits.match(/0+$/)?.[0].length || 0; if (trailingZeros) { digits = digits.slice(0, -trailingZeros); exponent += BigInt(trailingZeros); } return `${match[1] ? "-" : ""}${digits}e${exponent}`; }; const createLosslessNumber = (source) => Object.freeze({ [LOSSLESS_NUMBER]: normalizeNumber(source) }); const getLosslessNumber = (value) => value && typeof value === "object" ? value[LOSSLESS_NUMBER] : undefined; const losslessNumberToPlainString = (canonical) => { const match = /^(-?)(\d+)e(-?\d+)$/.exec(canonical); if (!match) throw new Error(`Invalid canonical number: ${canonical}`); const exponentValue = BigInt(match[3]); if (exponentValue > 10000n || exponentValue < -10000n) { throw new Error(`YAML numeric mapping key exponent is too large: ${canonical}`); } const exponent = Number(exponentValue); const sign = match[1]; const digits = match[2]; if (exponent >= 0) return `${sign}${digits}${"0".repeat(exponent)}`; const point = digits.length + exponent; return point > 0 ? `${sign}${digits.slice(0, point)}.${digits.slice(point)}` : `${sign}0.${"0".repeat(-point)}${digits}`; }; // 后端会同时交付 JSON 与 YAML。这个解析器只实现当前 OpenAPI 导出实际使用的 // YAML 1.2 子集,但不依赖固定缩进、键是否加引号或字段顺序;遇到锚点、tag、 // tab 缩进等未声明语法会立即失败,不能把无法理解的 YAML 当成“已经一致”。 const parseYamlScalar = (source) => { const value = source.trim(); if (value === "[]") return []; if (value === "{}") return {}; if (value === "null" || value === "~") return null; if (value === "true") return true; if (value === "false") return false; if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) { return createLosslessNumber(value); } if (value.startsWith('"')) { try { return JSON.parse(value); } catch (error) { throw new Error(`Invalid double-quoted YAML scalar: ${value}`); } } if (value.startsWith("'")) { if (!value.endsWith("'")) throw new Error(`Invalid single-quoted YAML scalar: ${value}`); return value.slice(1, -1).replace(/''/g, "'"); } if (/^[!&*]/.test(value)) { throw new Error(`Unsupported YAML tag/anchor/alias: ${value}`); } if (findMappingColon(value) >= 0) { throw new Error(`Invalid YAML plain scalar containing a mapping separator: ${value}`); } return value; }; const stripInlineComment = (source) => { let singleQuoted = false; let doubleQuoted = false; let escaped = false; for (let index = 0; index < source.length; index += 1) { const character = source[index]; if (doubleQuoted && escaped) { escaped = false; continue; } if (doubleQuoted && character === "\\") { escaped = true; continue; } if (!doubleQuoted && character === "'") { if (singleQuoted && source[index + 1] === "'") { index += 1; continue; } singleQuoted = !singleQuoted; continue; } if (!singleQuoted && character === '"') { doubleQuoted = !doubleQuoted; continue; } if (!singleQuoted && !doubleQuoted && character === "#" && (index === 0 || /\s/.test(source[index - 1]))) { return source.slice(0, index).trimEnd(); } } return source.trimEnd(); }; const findMappingColon = (source) => { let singleQuoted = false; let doubleQuoted = false; let escaped = false; for (let index = 0; index < source.length; index += 1) { const character = source[index]; if (doubleQuoted && escaped) { escaped = false; continue; } if (doubleQuoted && character === "\\") { escaped = true; continue; } if (!doubleQuoted && character === "'") { if (singleQuoted && source[index + 1] === "'") { index += 1; continue; } singleQuoted = !singleQuoted; continue; } if (!singleQuoted && character === '"') { doubleQuoted = !doubleQuoted; continue; } if ( !singleQuoted && !doubleQuoted && character === ":" && (index === source.length - 1 || /\s/.test(source[index + 1])) ) { return index; } } return -1; }; const parseYamlKey = (source) => { const key = parseYamlScalar(source); const numericKey = getLosslessNumber(key); if (numericKey !== undefined) return losslessNumberToPlainString(numericKey); if (typeof key !== "string" && typeof key !== "number") { throw new Error(`YAML mapping key must be scalar: ${source}`); } return String(key); }; const foldBlockLines = (lines) => { let result = ""; for (let index = 0; index < lines.length; index += 1) { const current = lines[index]; if (index === 0) { result = current; continue; } const previous = lines[index - 1]; result += previous === "" || current === "" ? `\n${current}` : ` ${current}`; } return result; }; const applyBlockChomping = (value, indicator) => { if (indicator.endsWith("-")) return value.replace(/\n+$/, ""); if (indicator.endsWith("+")) return value; return `${value.replace(/\n+$/, "")}\n`; }; const parseOpenApiYaml = (source) => { const rawLines = source.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n"); const lines = rawLines.map((raw, index) => { if (/^\t+/.test(raw) || /^ +\t/.test(raw)) { throw new Error(`YAML tab indentation is not supported at line ${index + 1}`); } const indent = raw.match(/^ */)[0].length; return { raw, indent, content: stripInlineComment(raw.slice(indent)), line: index + 1 }; }); const nextSignificant = (start) => { let index = start; while (index < lines.length && lines[index].content.trim() === "") index += 1; return index; }; const parseBlockScalar = (lineIndex, parentIndent, indicator) => { let end = lineIndex + 1; while (end < lines.length) { const line = lines[end]; if (line.raw.trim() !== "" && line.indent <= parentIndent) break; end += 1; } const block = lines.slice(lineIndex + 1, end); const nonBlank = block.filter((line) => line.raw.trim() !== ""); const contentIndent = nonBlank.length ? Math.min(...nonBlank.map((line) => line.indent)) : parentIndent + 1; if (contentIndent <= parentIndent) { throw new Error(`Invalid YAML block indentation at line ${lines[lineIndex].line}`); } const values = block.map((line) => (line.raw.trim() === "" ? "" : line.raw.slice(contentIndent))); const rawValue = indicator.startsWith(">") ? foldBlockLines(values) : values.join("\n"); return { value: applyBlockChomping(rawValue, indicator), next: end }; }; const parsePair = (content, lineIndex, entryIndent) => { const colon = findMappingColon(content); if (colon < 0) throw new Error(`Missing YAML mapping colon at line ${lines[lineIndex].line}`); const key = parseYamlKey(content.slice(0, colon).trim()); const rawValue = content.slice(colon + 1).trim(); if (/^[>|][+-]?$/.test(rawValue)) { const parsed = parseBlockScalar(lineIndex, entryIndent, rawValue); return { key, value: parsed.value, next: parsed.next }; } if (rawValue !== "") { return { key, value: parseYamlScalar(rawValue), next: lineIndex + 1 }; } const childIndex = nextSignificant(lineIndex + 1); if (childIndex < lines.length && lines[childIndex].indent > entryIndent) { const parsed = parseBlock(childIndex, lines[childIndex].indent); return { key, value: parsed.value, next: parsed.next }; } return { key, value: null, next: lineIndex + 1 }; }; const parseMap = (start, indent) => { const result = {}; let index = start; while (index < lines.length) { index = nextSignificant(index); if (index >= lines.length || lines[index].indent < indent) break; if (lines[index].indent > indent) { throw new Error(`Unexpected YAML indentation at line ${lines[index].line}`); } const content = lines[index].content.trim(); if (content.startsWith("-")) break; const parsed = parsePair(content, index, indent); if (Object.prototype.hasOwnProperty.call(result, parsed.key)) { throw new Error(`Duplicate YAML key '${parsed.key}' at line ${lines[index].line}`); } result[parsed.key] = parsed.value; index = parsed.next; } return { value: result, next: index }; }; const parseSequence = (start, indent) => { const result = []; let index = start; while (index < lines.length) { index = nextSignificant(index); if (index >= lines.length || lines[index].indent < indent) break; if (lines[index].indent > indent) { throw new Error(`Unexpected YAML sequence indentation at line ${lines[index].line}`); } const content = lines[index].content.trim(); if (!content.startsWith("-") || (content.length > 1 && !/\s/.test(content[1]))) break; const item = content.slice(1).trim(); if (item === "") { const childIndex = nextSignificant(index + 1); if (childIndex >= lines.length || lines[childIndex].indent <= indent) { result.push(null); index += 1; } else { const parsed = parseBlock(childIndex, lines[childIndex].indent); result.push(parsed.value); index = parsed.next; } continue; } if (findMappingColon(item) < 0) { result.push(parseYamlScalar(item)); index += 1; continue; } const object = {}; const first = parsePair(item, index, indent + 2); object[first.key] = first.value; index = first.next; const continuation = nextSignificant(index); if (continuation < lines.length && lines[continuation].indent === indent + 2 && !lines[continuation].content.trim().startsWith("-")) { const parsed = parseMap(continuation, indent + 2); for (const [key, value] of Object.entries(parsed.value)) { if (Object.prototype.hasOwnProperty.call(object, key)) { throw new Error(`Duplicate YAML sequence-object key '${key}' at line ${lines[continuation].line}`); } object[key] = value; } index = parsed.next; } result.push(object); } return { value: result, next: index }; }; function parseBlock(start, indent) { const index = nextSignificant(start); if (index >= lines.length) return { value: null, next: index }; if (lines[index].indent !== indent) { throw new Error(`Expected YAML indentation ${indent} at line ${lines[index].line}`); } return lines[index].content.trim().startsWith("-") ? parseSequence(index, indent) : parseMap(index, indent); } const start = nextSignificant(0); const parsed = parseBlock(start, lines[start]?.indent || 0); if (nextSignificant(parsed.next) < lines.length) { throw new Error(`Unparsed YAML content begins at line ${lines[nextSignificant(parsed.next)].line}`); } return parsed.value; }; // Node 没有内置的 lossless JSON 解析器。这个小型递归下降解析器只实现 RFC 8259, // 但把 number token 直接交给同一个任意精度归一化 owner,避免 JSON.parse 先行舍入。 const parseJsonLossless = (source) => { let index = 0; const fail = (message) => { throw new Error(`${message} at JSON offset ${index}`); }; const skipWhitespace = () => { while (index < source.length && /[\u0020\u000a\u000d\u0009]/.test(source[index])) index += 1; }; const parseString = () => { const start = index; index += 1; while (index < source.length) { const character = source[index]; if (character === '"') { index += 1; try { return JSON.parse(source.slice(start, index)); } catch (error) { fail("Invalid JSON string"); } } if (character === "\\") { index += 2; } else { index += 1; } } fail("Unterminated JSON string"); }; const parseNumber = () => { const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(source.slice(index)); if (!match) fail("Invalid JSON number"); index += match[0].length; return createLosslessNumber(match[0]); }; const parseLiteral = (literal, value) => { if (source.slice(index, index + literal.length) !== literal) fail(`Invalid JSON literal`); index += literal.length; return value; }; const parseArray = () => { const result = []; index += 1; skipWhitespace(); if (source[index] === "]") { index += 1; return result; } while (index < source.length) { result.push(parseValue()); skipWhitespace(); if (source[index] === "]") { index += 1; return result; } if (source[index] !== ",") fail("Expected ',' or ']' in JSON array"); index += 1; skipWhitespace(); } fail("Unterminated JSON array"); }; const parseObject = () => { const result = Object.create(null); index += 1; skipWhitespace(); if (source[index] === "}") { index += 1; return result; } while (index < source.length) { if (source[index] !== '"') fail("JSON object key must be a string"); const key = parseString(); if (Object.prototype.hasOwnProperty.call(result, key)) fail(`Duplicate JSON key '${key}'`); skipWhitespace(); if (source[index] !== ":") fail("Expected ':' after JSON object key"); index += 1; result[key] = parseValue(); skipWhitespace(); if (source[index] === "}") { index += 1; return result; } if (source[index] !== ",") fail("Expected ',' or '}' in JSON object"); index += 1; skipWhitespace(); } fail("Unterminated JSON object"); }; function parseValue() { skipWhitespace(); const character = source[index]; if (character === "{") return parseObject(); if (character === "[") return parseArray(); if (character === '"') return parseString(); if (character === "t") return parseLiteral("true", true); if (character === "f") return parseLiteral("false", false); if (character === "n") return parseLiteral("null", null); if (character === "-" || /\d/.test(character || "")) return parseNumber(); fail("Unexpected JSON token"); } const value = parseValue(); skipWhitespace(); if (index !== source.length) fail("Unexpected trailing JSON content"); return value; }; const firstDifference = (left, right, trail = "$") => { const leftNumber = getLosslessNumber(left); const rightNumber = getLosslessNumber(right); if (leftNumber !== undefined || rightNumber !== undefined) { return leftNumber === rightNumber ? null : `${trail}: number ${leftNumber ?? ""} != ${rightNumber ?? ""}`; } if (Object.is(left, right)) return null; if (Array.isArray(left) || Array.isArray(right)) { if (!Array.isArray(left) || !Array.isArray(right)) return `${trail}: type mismatch`; if (left.length !== right.length) return `${trail}: array length ${left.length} != ${right.length}`; for (let index = 0; index < left.length; index += 1) { const difference = firstDifference(left[index], right[index], `${trail}[${index}]`); if (difference) return difference; } return null; } if (left && right && typeof left === "object" && typeof right === "object") { const leftKeys = Object.keys(left).sort(); const rightKeys = Object.keys(right).sort(); if (leftKeys.length !== rightKeys.length || leftKeys.some((key, index) => key !== rightKeys[index])) { return `${trail}: keys ${leftKeys.join(",")} != ${rightKeys.join(",")}`; } for (const key of leftKeys) { const difference = firstDifference(left[key], right[key], `${trail}.${key}`); if (difference) return difference; } return null; } return `${trail}: ${JSON.stringify(left)} != ${JSON.stringify(right)}`; }; // 先用与 OpenAPI 导出相同的关键 YAML 形状验证解析器本身,防止 parity 检查因 // quoted key、非二空格缩进、对象数组或 block scalar 变化而静默误判。 const fixture = parseOpenApiYaml(` openapi: 3.1.0 paths: "/items/{id}": get: security: - SaToken: [] responses: 200: description: >- first line second line headers: {} components: schemas: Sample: type: object required: - id properties: id: type: string # lexical identity `); assert.strictEqual(fixture.openapi, "3.1.0"); assert.deepStrictEqual(fixture.paths["/items/{id}"].get.security, [{ SaToken: [] }]); assert.strictEqual(fixture.paths["/items/{id}"].get.responses["200"].description, "first line second line"); assert.deepStrictEqual(fixture.components.schemas.Sample.required, ["id"]); assert.throws(() => parseOpenApiYaml("openapi:3.0.1\n"), /mapping colon/); assert.throws(() => parseOpenApiYaml("description: a: b\n"), /plain scalar/); // JavaScript Number 会把这两个相邻的大整数舍入为同一个值;parity 合同必须先证明 // 自己能够发现这类双导出漂移,才能检查包含 int64 示例的真实 OpenAPI。 const unsafeIntegerDifference = firstDifference( parseOpenApiYaml("id: 2060000000000000001\n"), parseJsonLossless('{"id":2060000000000000000}'), ); assert.match(unsafeIntegerDifference || "", /^\$\.id:/); assert.strictEqual( firstDifference( parseOpenApiYaml("value: 1.00e2\n"), parseJsonLossless('{"value":100}'), ), null, ); const yamlDocument = parseOpenApiYaml(fs.readFileSync(path.join(root, "APP.openapi.yaml"), "utf8")); const jsonDocument = parseJsonLossless(fs.readFileSync(path.join(root, "APP.openapi.json"), "utf8")); const difference = firstDifference(yamlDocument, jsonDocument); if (difference) throw new Error(`Protected OpenAPI JSON/YAML semantic drift: ${difference}`); console.log("OPENAPI-YAML-JSON-PARITY PASS");