@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
JavaScript
;
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