From 8b9d3a7a4c6043604ce931e117a319b0c57a8c91 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 17:56:09 -0600 Subject: [PATCH] feat(engine): custom modifier descriptor validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3 Wave 3 (T19). Walks a CustomModifierDescriptor and emits a list of ValidationErrors covering: - descriptor.id non-empty - descriptor.name length 1-40, description length 0-200 - descriptor.version === 1, type === 'data' - every primitive node's kind is in PRIMITIVE_REGISTRY - every node's params satisfies its primitive's paramsSchema (Zod safeParse, all errors collected — never bails on first) - recursion depth <= 3 across nodes that declare childPrimitives() - total primitive count <= 50 (single error emitted, walk continues) - structural circularity guard (visited-set + active-stack tracking) for any future params shape that could embed object references - self-reference guard (params containing 'id' === descriptor.id) Returns { ok: true } on clean walk, { ok: false, errors: [] } otherwise. Each error carries a stable code, JSON-pointer-style path, and a human message — designed for editor inline feedback (T25). --- .../src/modifiers/custom/validate.test.ts | 266 ++++++++++++++++++ .../chess/src/modifiers/custom/validate.ts | 250 ++++++++++++++++ 2 files changed, 516 insertions(+) create mode 100644 packages/chess/src/modifiers/custom/validate.test.ts create mode 100644 packages/chess/src/modifiers/custom/validate.ts diff --git a/packages/chess/src/modifiers/custom/validate.test.ts b/packages/chess/src/modifiers/custom/validate.test.ts new file mode 100644 index 0000000..8b2145c --- /dev/null +++ b/packages/chess/src/modifiers/custom/validate.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "vitest"; +import "../primitives/index.js"; +import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js"; +import { validateCustomDescriptor } from "./validate.js"; + +function makeDescriptor(): CustomModifierDescriptor { + return { + type: "data", + id: asCustomModifierId("custom:validated"), + name: "Validated modifier", + description: "A descriptor used for validator tests.", + version: 1, + primitives: [ + { + kind: "add-to-attribute", + params: { + attr: "HpBonus", + delta: 2, + }, + }, + ], + targetAttrs: ["HpBonus"], + uiForm: "primitive-composer", + source: "custom", + }; +} + +describe("validateCustomDescriptor", () => { + it("accepts a valid descriptor", () => { + const result = validateCustomDescriptor(makeDescriptor()); + expect(result).toEqual({ ok: true }); + }); + + it("rejects empty descriptor id", () => { + const descriptor = { + ...makeDescriptor(), + id: asCustomModifierId(""), + }; + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "descriptor.id.empty")).toBe(true); + } + }); + + it("rejects empty descriptor name", () => { + const descriptor = { + ...makeDescriptor(), + name: "", + }; + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "descriptor.name.length")).toBe(true); + } + }); + + it("rejects descriptor name longer than 40 characters", () => { + const descriptor = { + ...makeDescriptor(), + name: "x".repeat(41), + }; + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "descriptor.name.length")).toBe(true); + } + }); + + it("rejects descriptor description longer than 200 characters", () => { + const descriptor = { + ...makeDescriptor(), + description: "x".repeat(201), + }; + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "descriptor.description.length")).toBe( + true, + ); + } + }); + + it("rejects non-v1 version descriptors", () => { + const descriptor = makeDescriptor(); + Object.defineProperty(descriptor, "version", { value: 2 }); + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "descriptor.version.unsupported")).toBe( + true, + ); + } + }); + + it("rejects non-data descriptor type", () => { + const descriptor = makeDescriptor(); + Object.defineProperty(descriptor, "type", { value: "scripted" }); + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "descriptor.type.invalid")).toBe(true); + } + }); + + it("rejects unknown primitive kinds", () => { + const descriptor = makeDescriptor(); + Object.defineProperty(descriptor, "primitives", { + value: [{ kind: "not-real", params: {} }], + }); + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "primitive.kind.unknown")).toBe(true); + } + }); + + it("collects paramsSchema validation errors", () => { + const descriptor = makeDescriptor(); + Object.defineProperty(descriptor, "primitives", { + value: [ + { + kind: "add-to-attribute", + params: { + attr: "HpBonus", + delta: "wrong-type", + }, + }, + ], + }); + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "primitive.params.invalid")).toBe(true); + } + }); + + it("enforces max nesting depth of 3 container levels", () => { + const deeplyNested = { + kind: "on-turn-start", + params: { + primitives: [ + { + kind: "conditional", + params: { + condition: { type: "always" }, + then: [ + { + kind: "on-capture", + params: { + primitives: [ + { + kind: "on-damaged", + params: { + primitives: [ + { + kind: "add-to-attribute", + params: { attr: "HpBonus", delta: 1 }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }; + + const descriptor = makeDescriptor(); + Object.defineProperty(descriptor, "primitives", { + value: [deeplyNested], + }); + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "descriptor.primitives.depth.exceeded")).toBe( + true, + ); + } + }); + + it("enforces max primitive count of 50", () => { + const descriptor = makeDescriptor(); + Object.defineProperty(descriptor, "primitives", { + value: Array.from({ length: 51 }, () => ({ + kind: "seed-attribute", + params: { attr: "HpBonus", value: 1 }, + })), + }); + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "descriptor.primitives.count.exceeded")).toBe( + true, + ); + } + }); + + it("rejects nested params that reference the parent descriptor id", () => { + const descriptor = makeDescriptor(); + Object.defineProperty(descriptor, "id", { + value: asCustomModifierId("custom:self-ref"), + }); + Object.defineProperty(descriptor, "primitives", { + value: [ + { + kind: "seed-attribute", + params: { + attr: "Hp", + value: { + nested: { + id: "custom:self-ref", + }, + }, + }, + }, + ], + }); + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "primitive.params.self-reference")).toBe( + true, + ); + } + }); + + it("rejects structural circular params", () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + + const descriptor = makeDescriptor(); + Object.defineProperty(descriptor, "primitives", { + value: [ + { + kind: "seed-attribute", + params: { + attr: "Hp", + value: circular, + }, + }, + ], + }); + + const result = validateCustomDescriptor(descriptor); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.some((e) => e.code === "primitive.params.circular")).toBe(true); + } + }); +}); diff --git a/packages/chess/src/modifiers/custom/validate.ts b/packages/chess/src/modifiers/custom/validate.ts new file mode 100644 index 0000000..20b955d --- /dev/null +++ b/packages/chess/src/modifiers/custom/validate.ts @@ -0,0 +1,250 @@ +import { PRIMITIVE_REGISTRY } from "../primitives/registry.js"; +import type { EffectPrimitiveNode } from "../primitives/types.js"; +import "../primitives/index.js"; +import type { CustomModifierDescriptor } from "./types.js"; + +export type ValidationError = { + code: string; + path: (string | number)[]; + message: string; +}; + +export type ValidationResult = { ok: true } | { ok: false; errors: ValidationError[] }; + +const MAX_DESCRIPTOR_NAME_LENGTH = 40; +const MAX_DESCRIPTOR_DESCRIPTION_LENGTH = 200; +const MAX_RECURSION_DEPTH = 3; +const MAX_PRIMITIVE_COUNT = 50; + +export function validateCustomDescriptor( + descriptor: CustomModifierDescriptor, +): ValidationResult { + const errors: ValidationError[] = []; + const descriptorId = String(descriptor.id); + + if (descriptorId.length === 0) { + errors.push({ + code: "descriptor.id.empty", + path: ["id"], + message: "descriptor.id must be non-empty", + }); + } + + if (descriptor.name.length < 1 || descriptor.name.length > MAX_DESCRIPTOR_NAME_LENGTH) { + errors.push({ + code: "descriptor.name.length", + path: ["name"], + message: "descriptor.name must be between 1 and 40 characters", + }); + } + + if (descriptor.description.length > MAX_DESCRIPTOR_DESCRIPTION_LENGTH) { + errors.push({ + code: "descriptor.description.length", + path: ["description"], + message: "descriptor.description must be between 0 and 200 characters", + }); + } + + if (descriptor.version !== 1) { + errors.push({ + code: "descriptor.version.unsupported", + path: ["version"], + message: "descriptor.version must be 1", + }); + } + + if (descriptor.type !== "data") { + errors.push({ + code: "descriptor.type.invalid", + path: ["type"], + message: 'descriptor.type must be "data"', + }); + } + + const walkState = { + totalPrimitiveCount: 0, + emittedPrimitiveCountError: false, + }; + + walkPrimitiveNodes({ + nodes: descriptor.primitives, + errors, + descriptorId, + containerDepth: 0, + basePath: ["primitives"], + walkState, + }); + + if (errors.length === 0) { + return { ok: true }; + } + + return { ok: false, errors }; +} + +function walkPrimitiveNodes(input: { + nodes: readonly EffectPrimitiveNode[]; + errors: ValidationError[]; + descriptorId: string; + containerDepth: number; + basePath: (string | number)[]; + walkState: { + totalPrimitiveCount: number; + emittedPrimitiveCountError: boolean; + }; +}): void { + const { nodes, errors, descriptorId, containerDepth, basePath, walkState } = input; + + for (let index = 0; index < nodes.length; index += 1) { + const node = nodes[index]; + if (node === undefined) continue; + const nodePath = [...basePath, index] as (string | number)[]; + const paramsPath = [...nodePath, "params"] as (string | number)[]; + + walkState.totalPrimitiveCount += 1; + if ( + walkState.totalPrimitiveCount > MAX_PRIMITIVE_COUNT && + !walkState.emittedPrimitiveCountError + ) { + errors.push({ + code: "descriptor.primitives.count.exceeded", + path: ["primitives"], + message: `descriptor primitives cannot exceed ${MAX_PRIMITIVE_COUNT} nodes`, + }); + walkState.emittedPrimitiveCountError = true; + } + + scanParamsForCyclesAndSelfReference({ + value: node.params, + descriptorId, + errors, + basePath: paramsPath, + stack: new Set(), + seen: new Set(), + }); + + const primitiveDescriptor = PRIMITIVE_REGISTRY.get(node.kind); + if (primitiveDescriptor === undefined) { + errors.push({ + code: "primitive.kind.unknown", + path: [...nodePath, "kind"], + message: `Unknown primitive kind: ${node.kind}`, + }); + continue; + } + + const parsedParams = primitiveDescriptor.paramsSchema.safeParse(node.params); + if (!parsedParams.success) { + for (const issue of parsedParams.error.issues) { + errors.push({ + code: "primitive.params.invalid", + path: [...paramsPath, ...normalizeIssuePath(issue.path)], + message: issue.message, + }); + } + } + + if (primitiveDescriptor.childPrimitives === undefined) { + continue; + } + + const nextDepth = containerDepth + 1; + if (nextDepth > MAX_RECURSION_DEPTH) { + errors.push({ + code: "descriptor.primitives.depth.exceeded", + path: nodePath, + message: `primitive nesting depth cannot exceed ${MAX_RECURSION_DEPTH}`, + }); + continue; + } + + let children: readonly EffectPrimitiveNode[] = []; + try { + const paramsForChildren = parsedParams.success ? parsedParams.data : node.params; + children = primitiveDescriptor.childPrimitives(paramsForChildren); + } catch { + children = []; + } + + walkPrimitiveNodes({ + nodes: children, + errors, + descriptorId, + containerDepth: nextDepth, + basePath: [...nodePath, "children"], + walkState, + }); + } +} + +function normalizeIssuePath(path: readonly (string | number | symbol)[]): (string | number)[] { + return path.map((part) => (typeof part === "number" ? part : String(part))); +} + +function scanParamsForCyclesAndSelfReference(input: { + value: unknown; + descriptorId: string; + errors: ValidationError[]; + basePath: (string | number)[]; + stack: Set; + seen: Set; +}): void { + const { value, descriptorId, errors, basePath, stack, seen } = input; + + if (value === null || typeof value !== "object") { + return; + } + + if (stack.has(value)) { + errors.push({ + code: "primitive.params.circular", + path: basePath, + message: "primitive params contain a structural circular reference", + }); + return; + } + + if (seen.has(value)) { + return; + } + + seen.add(value); + stack.add(value); + + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + scanParamsForCyclesAndSelfReference({ + value: value[index], + descriptorId, + errors, + basePath: [...basePath, index], + stack, + seen, + }); + } + stack.delete(value); + return; + } + + for (const [key, nested] of Object.entries(value)) { + if (key === "id" && typeof nested === "string" && nested === descriptorId) { + errors.push({ + code: "primitive.params.self-reference", + path: [...basePath, key], + message: "primitive params must not reference the parent descriptor id", + }); + } + + scanParamsForCyclesAndSelfReference({ + value: nested, + descriptorId, + errors, + basePath: [...basePath, key], + stack, + seen, + }); + } + + stack.delete(value); +}