Merge feat/analysis-type-compat: resolve conflicts in analysis/index.ts
This commit is contained in:
@@ -3,4 +3,5 @@ export {
|
||||
defaultNodeStatus,
|
||||
defaultEdgeType,
|
||||
resolveDefaultNodeAttrs,
|
||||
} from "./defaults.js";
|
||||
} from "./defaults.js";
|
||||
export { typeCompat, type TypeCompatResult, type TypeMismatch } from "./type-compat.js";
|
||||
@@ -1 +1,278 @@
|
||||
export {};
|
||||
import { KindGuard, Kind, type TSchema } from "@alkdev/typebox";
|
||||
|
||||
export interface TypeMismatch {
|
||||
path: string;
|
||||
expected: string;
|
||||
actual: string;
|
||||
}
|
||||
|
||||
export interface TypeCompatResult {
|
||||
compatible: boolean;
|
||||
detail?: string;
|
||||
mismatches?: TypeMismatch[];
|
||||
}
|
||||
|
||||
function schemaKind(schema: TSchema): string {
|
||||
return (schema as unknown as Record<typeof Kind, unknown>)[Kind] as string ?? "unknown";
|
||||
}
|
||||
|
||||
function typeName(schema: TSchema): string {
|
||||
if (KindGuard.IsUnknown(schema)) return "unknown";
|
||||
if (KindGuard.IsAny(schema)) return "any";
|
||||
if (KindGuard.IsNull(schema)) return "null";
|
||||
if (KindGuard.IsString(schema)) return "string";
|
||||
if (KindGuard.IsNumber(schema)) return "number";
|
||||
if (KindGuard.IsInteger(schema)) return "integer";
|
||||
if (KindGuard.IsBoolean(schema)) return "boolean";
|
||||
if (KindGuard.IsLiteral(schema)) {
|
||||
const val = schema.const;
|
||||
return typeof val === "string" ? `"${val}"` : String(val);
|
||||
}
|
||||
if (KindGuard.IsArray(schema)) return "array";
|
||||
if (KindGuard.IsObject(schema)) return "object";
|
||||
if (KindGuard.IsUnion(schema)) {
|
||||
const anyOf = schema.anyOf as TSchema[] | undefined;
|
||||
if (anyOf) return anyOf.map(typeName).join(" | ");
|
||||
return "union";
|
||||
}
|
||||
if (KindGuard.IsRecord(schema)) return "record";
|
||||
if (KindGuard.IsTuple(schema)) return "tuple";
|
||||
if (KindGuard.IsNever(schema)) return "never";
|
||||
return schemaKind(schema);
|
||||
}
|
||||
|
||||
function isUnknownType(schema: TSchema): boolean {
|
||||
if (KindGuard.IsUnknown(schema) || KindGuard.IsAny(schema)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function unwrapOptional(schema: TSchema): TSchema {
|
||||
if (KindGuard.IsOptional(schema)) {
|
||||
return (schema as unknown as Record<string, unknown>)["type"] === "object"
|
||||
? schema
|
||||
: ((schema as unknown as Record<string, unknown>)["anyOf"] as TSchema[] | undefined)?.[0] ?? schema;
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
function unwrapReadonlyOptional(schema: TSchema): TSchema {
|
||||
let s = schema;
|
||||
if (KindGuard.IsReadonly(s)) {
|
||||
const inner = (s as unknown as Record<string, unknown>)["anyOf"] as TSchema[] | undefined;
|
||||
if (inner) s = inner[0]!;
|
||||
}
|
||||
return unwrapOptional(s);
|
||||
}
|
||||
|
||||
function isOptionalField(schema: TSchema): boolean {
|
||||
if (KindGuard.IsOptional(schema)) return true;
|
||||
if (KindGuard.IsReadonly(schema)) {
|
||||
const inner = (schema as unknown as Record<string, unknown>)["anyOf"] as TSchema[] | undefined;
|
||||
if (inner && KindGuard.IsOptional(inner[0]!)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSupertypeOf(outer: TSchema, inner: TSchema): boolean {
|
||||
if (KindGuard.IsUnion(outer)) {
|
||||
const anyOf = outer.anyOf as TSchema[] | undefined;
|
||||
if (!anyOf) return false;
|
||||
return anyOf.some((variant) => isSupertypeOf(variant, inner));
|
||||
}
|
||||
if (KindGuard.IsUnion(inner)) {
|
||||
const anyOf = inner.anyOf as TSchema[] | undefined;
|
||||
if (!anyOf) return false;
|
||||
return anyOf.every((variant) => isSupertypeOf(outer, variant));
|
||||
}
|
||||
if (schemasEqual(outer, inner)) return true;
|
||||
if (KindGuard.IsLiteral(outer) && KindGuard.IsLiteral(inner)) {
|
||||
return outer.const === inner.const;
|
||||
}
|
||||
if (KindGuard.IsString(outer) && KindGuard.IsLiteral(inner) && typeof inner.const === "string") return true;
|
||||
if (KindGuard.IsNumber(outer) && KindGuard.IsLiteral(inner) && typeof inner.const === "number") return true;
|
||||
if (KindGuard.IsInteger(outer) && KindGuard.IsLiteral(inner) && typeof inner.const === "number") return true;
|
||||
if (KindGuard.IsBoolean(outer) && KindGuard.IsLiteral(inner) && typeof inner.const === "boolean") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function schemasEqual(a: TSchema, b: TSchema): boolean {
|
||||
const aKind = (a as unknown as Record<typeof Kind, unknown>)[Kind];
|
||||
const bKind = (b as unknown as Record<typeof Kind, unknown>)[Kind];
|
||||
if (aKind !== bKind) return false;
|
||||
if (KindGuard.IsString(a) && KindGuard.IsString(b)) return true;
|
||||
if (KindGuard.IsNumber(a) && KindGuard.IsNumber(b)) return true;
|
||||
if (KindGuard.IsInteger(a) && KindGuard.IsInteger(b)) return true;
|
||||
if (KindGuard.IsBoolean(a) && KindGuard.IsBoolean(b)) return true;
|
||||
if (KindGuard.IsNull(a) && KindGuard.IsNull(b)) return true;
|
||||
if (KindGuard.IsUnknown(a) && KindGuard.IsUnknown(b)) return true;
|
||||
if (KindGuard.IsAny(a) && KindGuard.IsAny(b)) return true;
|
||||
if (KindGuard.IsLiteral(a) && KindGuard.IsLiteral(b)) return a.const === b.const;
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkCompat(output: TSchema, input: TSchema, path: string, mismatches: TypeMismatch[]): void {
|
||||
const outUnwrapped = unwrapReadonlyOptional(output);
|
||||
const inUnwrapped = unwrapReadonlyOptional(input);
|
||||
|
||||
if (isUnknownType(outUnwrapped) || isUnknownType(inUnwrapped)) {
|
||||
mismatches.push({
|
||||
path,
|
||||
expected: typeName(inUnwrapped),
|
||||
actual: typeName(outUnwrapped),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (KindGuard.IsNull(outUnwrapped) && KindGuard.IsNull(inUnwrapped)) return;
|
||||
if (KindGuard.IsString(outUnwrapped) && KindGuard.IsString(inUnwrapped)) return;
|
||||
if (KindGuard.IsNumber(outUnwrapped) && KindGuard.IsNumber(inUnwrapped)) return;
|
||||
if (KindGuard.IsInteger(outUnwrapped) && KindGuard.IsInteger(inUnwrapped)) return;
|
||||
if (KindGuard.IsInteger(outUnwrapped) && KindGuard.IsNumber(inUnwrapped)) return;
|
||||
if (KindGuard.IsBoolean(outUnwrapped) && KindGuard.IsBoolean(inUnwrapped)) return;
|
||||
if (KindGuard.IsLiteral(outUnwrapped) && KindGuard.IsLiteral(inUnwrapped)) {
|
||||
if (outUnwrapped.const !== inUnwrapped.const) {
|
||||
mismatches.push({
|
||||
path,
|
||||
expected: typeName(inUnwrapped),
|
||||
actual: typeName(outUnwrapped),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (KindGuard.IsLiteral(outUnwrapped)) {
|
||||
if (!isSupertypeOf(inUnwrapped, outUnwrapped)) {
|
||||
mismatches.push({
|
||||
path,
|
||||
expected: typeName(inUnwrapped),
|
||||
actual: typeName(outUnwrapped),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (KindGuard.IsUnion(inUnwrapped) && !KindGuard.IsUnion(outUnwrapped)) {
|
||||
if (!isSupertypeOf(inUnwrapped, outUnwrapped)) {
|
||||
mismatches.push({
|
||||
path,
|
||||
expected: typeName(inUnwrapped),
|
||||
actual: typeName(outUnwrapped),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (KindGuard.IsUnion(outUnwrapped) && !KindGuard.IsUnion(inUnwrapped)) {
|
||||
const anyOf = outUnwrapped.anyOf as TSchema[] | undefined;
|
||||
if (anyOf) {
|
||||
const allCompat = anyOf.every((variant) => {
|
||||
const subMismatches: TypeMismatch[] = [];
|
||||
checkCompat(variant, inUnwrapped, path, subMismatches);
|
||||
return subMismatches.length === 0;
|
||||
});
|
||||
if (!allCompat) {
|
||||
mismatches.push({
|
||||
path,
|
||||
expected: typeName(inUnwrapped),
|
||||
actual: typeName(outUnwrapped),
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (KindGuard.IsUnion(outUnwrapped) && KindGuard.IsUnion(inUnwrapped)) {
|
||||
const outAnyOf = outUnwrapped.anyOf as TSchema[] | undefined;
|
||||
const inAnyOf = inUnwrapped.anyOf as TSchema[] | undefined;
|
||||
if (outAnyOf && inAnyOf) {
|
||||
for (const outVariant of outAnyOf) {
|
||||
const covered = inAnyOf.some((inVariant) => {
|
||||
const sub: TypeMismatch[] = [];
|
||||
checkCompat(outVariant, inVariant, path, sub);
|
||||
return sub.length === 0;
|
||||
});
|
||||
if (!covered) {
|
||||
mismatches.push({
|
||||
path,
|
||||
expected: typeName(inUnwrapped),
|
||||
actual: typeName(outUnwrapped),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (KindGuard.IsArray(outUnwrapped) && KindGuard.IsArray(inUnwrapped)) {
|
||||
const outItems = outUnwrapped.items as TSchema | undefined;
|
||||
const inItems = inUnwrapped.items as TSchema | undefined;
|
||||
if (outItems && inItems) {
|
||||
checkCompat(outItems, inItems, `${path}[]`, mismatches);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (KindGuard.IsObject(outUnwrapped) && KindGuard.IsObject(inUnwrapped)) {
|
||||
const outProps = outUnwrapped.properties as Record<string, TSchema> | undefined;
|
||||
const inProps = inUnwrapped.properties as Record<string, TSchema> | undefined;
|
||||
if (inProps) {
|
||||
for (const key of Object.keys(inProps)) {
|
||||
const inField = inProps[key]!;
|
||||
const isOpt = isOptionalField(inField);
|
||||
if (outProps && key in outProps) {
|
||||
checkCompat(outProps[key]!, inField, `${path}/${key}`, mismatches);
|
||||
} else if (!isOpt) {
|
||||
mismatches.push({
|
||||
path: `${path}/${key}`,
|
||||
expected: typeName(inField),
|
||||
actual: "missing",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (KindGuard.IsRecord(outUnwrapped) && KindGuard.IsRecord(inUnwrapped)) {
|
||||
const outVal = outUnwrapped.additionalProperties as TSchema | undefined;
|
||||
const inVal = inUnwrapped.additionalProperties as TSchema | undefined;
|
||||
if (outVal && inVal) {
|
||||
checkCompat(outVal, inVal, `${path}{}`, mismatches);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!schemasEqual(outUnwrapped, inUnwrapped) && !isSupertypeOf(inUnwrapped, outUnwrapped)) {
|
||||
mismatches.push({
|
||||
path,
|
||||
expected: typeName(inUnwrapped),
|
||||
actual: typeName(outUnwrapped),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function typeCompat(outputSchema: TSchema, inputSchema: TSchema): TypeCompatResult | undefined {
|
||||
if (isUnknownType(outputSchema) || isUnknownType(inputSchema)) return undefined;
|
||||
|
||||
const mismatches: TypeMismatch[] = [];
|
||||
checkCompat(outputSchema, inputSchema, "", mismatches);
|
||||
|
||||
if (mismatches.length === 0) {
|
||||
const outUnwrapped = unwrapReadonlyOptional(outputSchema);
|
||||
const inUnwrapped = unwrapReadonlyOptional(inputSchema);
|
||||
if (KindGuard.IsObject(outUnwrapped) && KindGuard.IsObject(inUnwrapped)) {
|
||||
const outProps = outUnwrapped.properties as Record<string, TSchema> | undefined;
|
||||
const inProps = inUnwrapped.properties as Record<string, TSchema> | undefined;
|
||||
if (outProps && inProps) {
|
||||
const hasExtra = Object.keys(outProps).some((k) => !(k in inProps));
|
||||
if (hasExtra) {
|
||||
return { compatible: true, detail: "output has extra fields beyond input requirements" };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { compatible: true };
|
||||
}
|
||||
|
||||
return { compatible: false, mismatches };
|
||||
}
|
||||
@@ -1,7 +1,414 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Type, type TSchema } from "@alkdev/typebox";
|
||||
import { typeCompat, type TypeCompatResult, type TypeMismatch } from "../../src/analysis/type-compat.js";
|
||||
|
||||
describe('analysis type-compat', () => {
|
||||
it('placeholder', () => {
|
||||
expect(true).toBe(true);
|
||||
describe("typeCompat", () => {
|
||||
describe("exact match", () => {
|
||||
it("identical string schemas are compatible", () => {
|
||||
const result = typeCompat(Type.String(), Type.String());
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("identical number schemas are compatible", () => {
|
||||
const result = typeCompat(Type.Number(), Type.Number());
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("identical boolean schemas are compatible", () => {
|
||||
const result = typeCompat(Type.Boolean(), Type.Boolean());
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("identical object schemas are compatible", () => {
|
||||
const output = Type.Object({ name: Type.String(), age: Type.Number() });
|
||||
const input = Type.Object({ name: Type.String(), age: Type.Number() });
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("superset (output has extra fields)", () => {
|
||||
it("output with extra fields is still compatible", () => {
|
||||
const output = Type.Object({ name: Type.String(), age: Type.Number(), email: Type.String() });
|
||||
const input = Type.Object({ name: Type.String(), age: Type.Number() });
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
expect(result!.detail).toBe("output has extra fields beyond input requirements");
|
||||
});
|
||||
|
||||
it("output with many extra fields is compatible", () => {
|
||||
const output = Type.Object({ a: Type.String(), b: Type.Number(), c: Type.Boolean(), d: Type.String() });
|
||||
const input = Type.Object({ a: Type.String() });
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subset (output missing required fields)", () => {
|
||||
it("output missing a required field is incompatible", () => {
|
||||
const output = Type.Object({ name: Type.String() });
|
||||
const input = Type.Object({ name: Type.String(), age: Type.Number() });
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
expect(result!.mismatches).toBeDefined();
|
||||
expect(result!.mismatches!.length).toBeGreaterThan(0);
|
||||
const ageMismatch = result!.mismatches!.find((m) => m.path === "/age");
|
||||
expect(ageMismatch).toBeDefined();
|
||||
expect(ageMismatch!.expected).toBe("number");
|
||||
expect(ageMismatch!.actual).toBe("missing");
|
||||
});
|
||||
|
||||
it("output missing multiple required fields reports all mismatches", () => {
|
||||
const output = Type.Object({ name: Type.String() });
|
||||
const input = Type.Object({ name: Type.String(), age: Type.Number(), email: Type.String() });
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
const paths = result!.mismatches!.map((m) => m.path);
|
||||
expect(paths).toContain("/age");
|
||||
expect(paths).toContain("/email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("type mismatch", () => {
|
||||
it("string output vs number input is incompatible", () => {
|
||||
const output = Type.Object({ value: Type.String() });
|
||||
const input = Type.Object({ value: Type.Number() });
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
const mismatch = result!.mismatches!.find((m) => m.path === "/value");
|
||||
expect(mismatch).toBeDefined();
|
||||
expect(mismatch!.expected).toBe("number");
|
||||
expect(mismatch!.actual).toBe("string");
|
||||
});
|
||||
|
||||
it("boolean output vs string input is incompatible", () => {
|
||||
const output = Type.Object({ flag: Type.Boolean() });
|
||||
const input = Type.Object({ flag: Type.String() });
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown passthrough", () => {
|
||||
it("output is Type.Unknown() returns undefined", () => {
|
||||
const result = typeCompat(Type.Unknown(), Type.String());
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("input is Type.Unknown() returns undefined", () => {
|
||||
const result = typeCompat(Type.String(), Type.Unknown());
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("both schemas are Type.Unknown() returns undefined", () => {
|
||||
const result = typeCompat(Type.Unknown(), Type.Unknown());
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested objects (recursive)", () => {
|
||||
it("deeply nested objects with exact match are compatible", () => {
|
||||
const output = Type.Object({
|
||||
address: Type.Object({
|
||||
city: Type.String(),
|
||||
zip: Type.String(),
|
||||
}),
|
||||
});
|
||||
const input = Type.Object({
|
||||
address: Type.Object({
|
||||
city: Type.String(),
|
||||
zip: Type.String(),
|
||||
}),
|
||||
});
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("nested object with superset fields is compatible", () => {
|
||||
const output = Type.Object({
|
||||
address: Type.Object({
|
||||
city: Type.String(),
|
||||
zip: Type.String(),
|
||||
country: Type.String(),
|
||||
}),
|
||||
});
|
||||
const input = Type.Object({
|
||||
address: Type.Object({
|
||||
city: Type.String(),
|
||||
zip: Type.String(),
|
||||
}),
|
||||
});
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("nested object missing required field is incompatible", () => {
|
||||
const output = Type.Object({
|
||||
address: Type.Object({
|
||||
city: Type.String(),
|
||||
}),
|
||||
});
|
||||
const input = Type.Object({
|
||||
address: Type.Object({
|
||||
city: Type.String(),
|
||||
zip: Type.String(),
|
||||
}),
|
||||
});
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
const mismatch = result!.mismatches!.find((m) => m.path === "/address/zip");
|
||||
expect(mismatch).toBeDefined();
|
||||
});
|
||||
|
||||
it("nested object with type mismatch reports correct path", () => {
|
||||
const output = Type.Object({
|
||||
address: Type.Object({
|
||||
city: Type.Number(),
|
||||
}),
|
||||
});
|
||||
const input = Type.Object({
|
||||
address: Type.Object({
|
||||
city: Type.String(),
|
||||
}),
|
||||
});
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
const mismatch = result!.mismatches!.find((m) => m.path === "/address/city");
|
||||
expect(mismatch).toBeDefined();
|
||||
expect(mismatch!.expected).toBe("string");
|
||||
expect(mismatch!.actual).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrays", () => {
|
||||
it("identical array element types are compatible", () => {
|
||||
const result = typeCompat(Type.Array(Type.String()), Type.Array(Type.String()));
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("array with incompatible element types is not compatible", () => {
|
||||
const result = typeCompat(Type.Array(Type.Number()), Type.Array(Type.String()));
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
expect(result!.mismatches!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("output array with union element type is not compatible with string array input", () => {
|
||||
const result = typeCompat(
|
||||
Type.Array(Type.Union([Type.String(), Type.Number()])),
|
||||
Type.Array(Type.String()),
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it("output array with string element is compatible with union array input", () => {
|
||||
const result = typeCompat(
|
||||
Type.Array(Type.String()),
|
||||
Type.Array(Type.Union([Type.String(), Type.Number()])),
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional fields", () => {
|
||||
it("optional field in input that is present in output is compatible", () => {
|
||||
const output = Type.Object({ name: Type.String(), email: Type.String() });
|
||||
const input = Type.Object({ name: Type.String(), email: Type.Optional(Type.String()) });
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("optional field in input that is absent in output is compatible", () => {
|
||||
const output = Type.Object({ name: Type.String() });
|
||||
const input = Type.Object({ name: Type.String(), email: Type.Optional(Type.String()) });
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("required field in input that is absent in output is not compatible", () => {
|
||||
const output = Type.Object({ name: Type.String() });
|
||||
const input = Type.Object({ name: Type.String(), age: Type.Number() });
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("union types (subtype checking)", () => {
|
||||
it("string output is compatible with string|number input", () => {
|
||||
const result = typeCompat(Type.String(), Type.Union([Type.String(), Type.Number()]));
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("string|number output is NOT compatible with string input", () => {
|
||||
const result = typeCompat(Type.Union([Type.String(), Type.Number()]), Type.String());
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it("number output is compatible with string|number input", () => {
|
||||
const result = typeCompat(Type.Number(), Type.Union([Type.String(), Type.Number()]));
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("literal output is compatible with matching union input", () => {
|
||||
const result = typeCompat(Type.Literal("hello"), Type.Union([Type.Literal("hello"), Type.Literal("world")]));
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("literal output is NOT compatible with non-matching union input", () => {
|
||||
const result = typeCompat(Type.Literal("other"), Type.Union([Type.Literal("hello"), Type.Literal("world")]));
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("literal types", () => {
|
||||
it("identical string literals are compatible", () => {
|
||||
const result = typeCompat(Type.Literal("hello"), Type.Literal("hello"));
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("different string literals are not compatible", () => {
|
||||
const result = typeCompat(Type.Literal("hello"), Type.Literal("world"));
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it("string literal output is compatible with string input", () => {
|
||||
const result = typeCompat(Type.Literal("hello"), Type.String());
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("number literal output is compatible with number input", () => {
|
||||
const result = typeCompat(Type.Literal(42), Type.Number());
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integer and number compatibility", () => {
|
||||
it("integer output is compatible with number input", () => {
|
||||
const result = typeCompat(Type.Integer(), Type.Number());
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("number output is NOT compatible with integer input", () => {
|
||||
const result = typeCompat(Type.Number(), Type.Integer());
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex realistic schemas", () => {
|
||||
it("realistic operation schemas - classify output compatible with enrich input", () => {
|
||||
const classifyOutput = Type.Object({
|
||||
categories: Type.Array(Type.String()),
|
||||
confidence: Type.Number(),
|
||||
metadata: Type.Optional(Type.Object({
|
||||
model: Type.String(),
|
||||
version: Type.String(),
|
||||
})),
|
||||
});
|
||||
const enrichInput = Type.Object({
|
||||
categories: Type.Array(Type.String()),
|
||||
confidence: Type.Number(),
|
||||
});
|
||||
const result = typeCompat(classifyOutput, enrichInput);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("realistic operation schemas - incompatible due to field type", () => {
|
||||
const fetchOutput = Type.Object({
|
||||
items: Type.Array(Type.Object({ id: Type.String(), name: Type.String() })),
|
||||
total: Type.Number(),
|
||||
});
|
||||
const processInput = Type.Object({
|
||||
items: Type.Array(Type.Object({ id: Type.Number(), name: Type.String() })),
|
||||
total: Type.Number(),
|
||||
});
|
||||
const result = typeCompat(fetchOutput, processInput);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
const mismatch = result!.mismatches!.find((m) => m.path.includes("id"));
|
||||
expect(mismatch).toBeDefined();
|
||||
});
|
||||
|
||||
it("multiple mismatches at different depths", () => {
|
||||
const output = Type.Object({
|
||||
name: Type.Number(),
|
||||
address: Type.Object({
|
||||
city: Type.Number(),
|
||||
zip: Type.Boolean(),
|
||||
}),
|
||||
});
|
||||
const input = Type.Object({
|
||||
name: Type.String(),
|
||||
address: Type.Object({
|
||||
city: Type.String(),
|
||||
zip: Type.String(),
|
||||
}),
|
||||
});
|
||||
const result = typeCompat(output, input);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
expect(result!.mismatches!.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TypeCompatResult shape", () => {
|
||||
it("compatible result has no mismatches", () => {
|
||||
const result = typeCompat(Type.String(), Type.String());
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(true);
|
||||
expect(result!.mismatches).toBeUndefined();
|
||||
});
|
||||
|
||||
it("incompatible result has mismatches array", () => {
|
||||
const result = typeCompat(Type.String(), Type.Number());
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
expect(Array.isArray(result!.mismatches)).toBe(true);
|
||||
expect(result!.mismatches!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("TypeMismatch has correct shape", () => {
|
||||
const result = typeCompat(
|
||||
Type.Object({ value: Type.String() }),
|
||||
Type.Object({ value: Type.Number() }),
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.compatible).toBe(false);
|
||||
const mismatch: TypeMismatch = result!.mismatches![0]!;
|
||||
expect(typeof mismatch.path).toBe("string");
|
||||
expect(typeof mismatch.expected).toBe("string");
|
||||
expect(typeof mismatch.actual).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user