feat: add core registry schemas

This commit is contained in:
chungyeong
2026-05-09 23:56:10 +09:00
parent 44103839af
commit 4a7fc94f5c
18 changed files with 1267 additions and 12 deletions

View File

@@ -16,7 +16,7 @@ function renderCanonical(value: JsonValue): string {
}
if (typeof value === "number") {
return JSON.stringify(value);
return renderCanonicalNumber(value);
}
if (Array.isArray(value)) {
@@ -37,7 +37,17 @@ function assertJsonValue(value: unknown): JsonValue {
Array.isArray(value)
) {
if (Array.isArray(value)) {
return value.map((item) => assertJsonValue(item));
assertOnlyArrayIndexKeys(value);
const arrayValue: JsonValue[] = [];
for (let index = 0; index < value.length; index += 1) {
if (!(index in value)) {
throw new TypeError(`Cannot canonicalize sparse array at index ${index}`);
}
arrayValue.push(assertJsonValue(value[index]));
}
return arrayValue;
}
return value;
@@ -57,7 +67,8 @@ function assertJsonValue(value: unknown): JsonValue {
throw new TypeError("Cannot canonicalize non-plain object");
}
const objectValue: Record<string, JsonValue> = {};
assertOnlyEnumerableStringKeys(value);
const objectValue: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>;
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
if (childValue === undefined) {
@@ -72,3 +83,183 @@ function assertJsonValue(value: unknown): JsonValue {
throw new TypeError(`Cannot canonicalize ${typeof value}`);
}
function assertOnlyEnumerableStringKeys(value: object) {
const enumerableStringKeys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
const hasOnlyEnumerableStringKeys =
ownKeys.length === enumerableStringKeys.length &&
ownKeys.every(
(key) => typeof key === "string" && Object.prototype.propertyIsEnumerable.call(value, key),
);
if (!hasOnlyEnumerableStringKeys) {
throw new TypeError("Cannot canonicalize non-enumerable or symbol object keys");
}
}
function assertOnlyArrayIndexKeys(value: unknown[]) {
for (const key of Reflect.ownKeys(value)) {
if (key === "length") {
continue;
}
if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) {
throw new TypeError("Cannot canonicalize non-index array object keys");
}
const index = Number(key);
if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) {
throw new TypeError("Cannot canonicalize non-index array object keys");
}
}
}
function renderCanonicalNumber(value: number): string {
if (Object.is(value, -0) || value === 0) {
return "0";
}
const candidates = new Set<string>();
addNumberCandidate(candidates, value, value.toString());
const jsonCandidate = JSON.stringify(value);
if (jsonCandidate !== undefined) {
addNumberCandidate(candidates, value, jsonCandidate);
}
for (let precision = 1; precision <= 17; precision += 1) {
addNumberCandidate(candidates, value, value.toPrecision(precision));
addNumberCandidate(candidates, value, value.toExponential(precision - 1));
}
const [best] = [...candidates].sort(
(left, right) => left.length - right.length || compareCodeUnits(left, right),
);
if (!best) {
throw new TypeError(`Cannot canonicalize number ${value}`);
}
return best;
}
function addNumberCandidate(candidates: Set<string>, value: number, raw: string) {
const candidate = normalizeNumberLiteral(raw);
for (const equivalent of expandNumberLiteral(candidate)) {
if (Number(equivalent) === value) {
candidates.add(equivalent);
}
}
}
function compareCodeUnits(left: string, right: string) {
if (left < right) {
return -1;
}
if (left > right) {
return 1;
}
return 0;
}
function normalizeNumberLiteral(raw: string): string {
const [mantissaText, exponentText] = raw.toLowerCase().split("e");
const mantissa = normalizeDecimal(mantissaText ?? "");
if (exponentText === undefined) {
return mantissa;
}
const exponent = normalizeExponent(exponentText);
if (exponent === "0") {
return mantissa;
}
return `${mantissa}e${exponent}`;
}
function normalizeDecimal(raw: string): string {
if (!raw.includes(".")) {
return raw;
}
const trimmed = raw.replace(/0+$/, "").replace(/\.$/, "");
if (trimmed === "-0") {
return "0";
}
return trimmed;
}
function normalizeExponent(raw: string): string {
const sign = raw.startsWith("-") ? "-" : "";
const unsigned = raw.replace(/^[+-]/, "").replace(/^0+/, "");
if (unsigned === "") {
return "0";
}
return `${sign}${unsigned}`;
}
function expandNumberLiteral(raw: string): string[] {
const parsed = parseNumberLiteral(raw);
if (!parsed) {
return [raw];
}
const plain = renderPlainDecimal(parsed);
const candidates = new Set([plain]);
for (let integerDigits = 1; integerDigits <= parsed.digits.length; integerDigits += 1) {
const exponent = parsed.power + parsed.digits.length - integerDigits;
if (exponent === 0) {
continue;
}
const mantissa =
integerDigits === parsed.digits.length
? parsed.digits
: `${parsed.digits.slice(0, integerDigits)}.${parsed.digits.slice(integerDigits)}`;
candidates.add(`${parsed.sign}${mantissa}e${exponent}`);
}
return [...candidates];
}
function parseNumberLiteral(raw: string) {
const match = raw.match(/^(-?)(\d+)(?:\.(\d+))?(?:e(-?\d+))?$/);
if (!match) {
return undefined;
}
const [, sign, integerPart, fractionalPart = "", exponentPart] = match;
let digits = `${integerPart}${fractionalPart}`;
let power = (exponentPart === undefined ? 0 : Number(exponentPart)) - fractionalPart.length;
digits = digits.replace(/^0+/, "");
if (digits === "") {
return { sign: "", digits: "0", power: 0 };
}
const lengthBeforeTrailingTrim = digits.length;
digits = digits.replace(/0+$/, "");
power += lengthBeforeTrailingTrim - digits.length;
return { sign: sign ?? "", digits, power };
}
function renderPlainDecimal(parsed: { sign: string; digits: string; power: number }) {
if (parsed.digits === "0") {
return "0";
}
if (parsed.power >= 0) {
return `${parsed.sign}${parsed.digits}${"0".repeat(parsed.power)}`;
}
const pointIndex = parsed.digits.length + parsed.power;
if (pointIndex > 0) {
return `${parsed.sign}${parsed.digits.slice(0, pointIndex)}.${parsed.digits.slice(pointIndex)}`;
}
return `${parsed.sign}0.${"0".repeat(-pointIndex)}${parsed.digits}`;
}