UNPKG

@on-the-ground/quackquack

Version:

Message-based duck typing system focused on behavioral roles instead of structural types.

239 lines (233 loc) 8.51 kB
'use strict'; var mini = require('@zod/mini'); /** * Internal symbol used to store the set of roles attached to an object. * This property is non-enumerable and hidden from standard inspection. */ const QUACK_KEY = Symbol("@on-the-ground/quackquack"); const quackParser = require("./quack-parser"); /** * Parses a string representation of a function signature into a QuackAst (intermediate representation). * * @param fnSig - A string in the format of a function signature, e.g. "(x: number, y: string) => boolean" * @returns A QuackAst object representing the parsed function signature. */ function quackAstOf(fnSig) { return quackParser.parse(fnSig); } /** * Retrieves the QuackAst metadata from a previously marked function. * * @param fn - A function which may have been decorated or marked with `quackable`. * @returns The QuackAst if present, otherwise undefined. */ function quackAstFrom(fn) { try { return fn[QUACK_KEY]; } catch { return null; } } /** * Checks if a function has been marked as quackable (i.e., has QuackAst metadata). * * @param fn - A function to check. * @returns True if the function has a QuackAst attached, false otherwise. */ function isQuackable(fn) { return !!quackAstFrom(fn); } function quackable(sig) { const qast = quackAstOf(sig); return function (...args) { if (isFunction(args)) { const fn = args[0]; fn[QUACK_KEY] = qast; return fn; } if (isLegacyMethodDecorator(args)) { const fn = args[2].value; fn[QUACK_KEY] = qast; return; } if (isECMAMethodDecorator(args)) { const fn = args[0]; fn[QUACK_KEY] = qast; return; } throw new Error("Invalid usage of quackable"); }; } /** * Checks if the decorator was applied directly to a function (non-method usage). * * @param args - Arguments passed to the decorator. * @returns True if the decorator is used on a function. */ const isFunction = (args) => args.length === 1 && typeof args[0] === "function"; /** * Checks if the decorator was applied as a legacy (TypeScript-style) method decorator. * * @param args - Arguments passed to the decorator. * @returns True if it matches legacy method decorator pattern. */ const isLegacyMethodDecorator = (args) => args.length === 3 && typeof args[2] === "object" && typeof args[2].value === "function"; /** * Checks if the decorator was applied using the newer ECMAScript method decorator pattern. * * @param args - Arguments passed to the decorator. * @returns True if it matches ECMA method decorator pattern. */ const isECMAMethodDecorator = (args) => args.length === 2 && typeof args[0] === "function" && typeof args[1] === "object" && typeof args[1].addInitializer === "function"; // --- 유틸 --- const isNonEmpty = (arr) => arr.length > 0; const sanitizeOptionalParams = (ps) => { let foundOptional = false; for (let i = ps.length - 1; i >= 0; i--) { if (!ps[i].optional && foundOptional) { throw new Error(`Required parameter '${i}' cannot follow optional ones`); } if (ps[i].optional) foundOptional = true; } return ps; }; // --- 파서: Ast → Zod --- function toZodInnerSchema(type) { if (typeof type === "string") { switch (type) { case "number": return mini.z.number(); case "string": return mini.z.string(); case "boolean": return mini.z.boolean(); case "null": return mini.z.null(); case "undefined": return mini.z.undefined(); case "void": return mini.z.void(); case "dontcare": return mini.z.any(); default: throw new Error(`Unknown primitive type: ${type}`); } } switch (type.type) { case "array": return mini.z.array(toZodInnerSchema(type.element)); case "tuple": return isNonEmpty(type.elements) ? mini.z.tuple(type.elements.map(toZodInnerSchema)) : mini.z.tuple([]); case "promise": return mini.z.promise(toZodInnerSchema(type.inner)); case "function": return mini.z.any(); // nested 함수 시그니처는 지원 안 함 } throw new Error(`Unmatched ParsedType: ${JSON.stringify(type)}`); } // --- param schema --- const paramSchemaOf = (paramAst) => { const base = toZodInnerSchema(paramAst.type); return paramAst.optional ? mini.z.optional(base) : base; }; const paramSchemasOf = (paramAsts) => { const safeParms = sanitizeOptionalParams(paramAsts); return mini.z.tuple(safeParms.map(paramSchemaOf)); }; const returnSchemaOf = toZodInnerSchema; // --- 최종 함수 schema 생성 --- function toZodSchema(quackAst) { return new mini.core.$ZodFunction({ type: "function", input: paramSchemasOf(quackAst.parameters), output: returnSchemaOf(quackAst.return), }); } const fastDeepEqual = require("fast-deep-equal"); /** * Wraps a function with runtime validation based on a given Quack signature. * * This allows you to enforce that the function matches a specific structure * (both syntactically and semantically) based on the Quack DSL. If the function * was already annotated via `quackable()`, the signature is checked for compatibility. * * - If the function is not annotated, the signature is enforced via `zod` wrapper. * - If the function is annotated and matches the expected signature, it's either: * - used as-is (`strict = false`) * - or rewrapped with validation (`strict = true`) * - If the function is annotated but does not match, an error is thrown. * * @param expected - A Quack DSL string describing the expected function signature. * @param strict - If `true`, wrap the function with runtime validation even if it already matches. * @returns A higher-order function that validates or wraps the target function. * * @example * ```ts * const greet = expectQuack("(name: string) => void")((name) => console.log(name)); * greet("Alice"); // valid * greet(123); // throws Zod validation error * ``` */ const expectQuack = (expected, strict = false) => (fn) => { const expectedAst = quackAstOf(expected); const actualAst = quackAstFrom(fn); if (!actualAst) { return withSchemaValidation(fn, expectedAst); } if (fastDeepEqual(actualAst, expectedAst)) { return strict ? withSchemaValidation(fn, expectedAst) : fn; } throw new Error(`Function does not match expected quack.\nExpected: ${expectedAst}\nGot: ${actualAst}`); }; const withSchemaValidation = (fn, expectedAst) => expectedAst.async ? toZodSchema(expectedAst).implementAsync(fn) : toZodSchema(expectedAst).implement(fn); /** * Validates or wraps an entire object to ensure that each method matches a declared Quack signature. * * This is effectively a "duck typing contract enforcement" tool. Each method in the object * is validated against its corresponding expected Quack DSL. You can choose whether to rewrap * matched functions with runtime validation using the `strict` flag. * * @param expected - A mapping of method names to expected Quack DSL signatures. * @param strict - If `true`, wrap even matching methods with Zod validation. * @returns A higher-order function that returns a cloned object with validated/wrapped methods. * * @example * ```ts * const duck = expectDuck({ * greet: "(name: string) => void", * add: "(x: number, y: number) => number" * })({ * greet(name) { console.log(name); }, * add(x, y) { return x + y; } * }); * ``` */ const expectDuck = (expected, strict = false) => (obj) => { const clone = Object.create(obj); const keysOfDuck = Object.keys(expected); for (const key of keysOfDuck) { if (!(key in obj) || typeof obj[key] !== "function") { throw new Error(`Method ${String(key)} is missing from the object.`); } clone[key] = expectQuack(expected[key], strict)(obj[key]); } return clone; }; exports.expectDuck = expectDuck; exports.expectQuack = expectQuack; exports.isQuackable = isQuackable; exports.quackAstFrom = quackAstFrom; exports.quackAstOf = quackAstOf; exports.quackable = quackable; //# sourceMappingURL=index.cjs.map