feat(engine): custom modifier descriptor validator

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).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 17:56:09 -06:00
commit 8b9d3a7a4c
No known key found for this signature in database
2 changed files with 516 additions and 0 deletions

View file

@ -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);
}
});
});

View file

@ -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<object>(),
seen: new Set<object>(),
});
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<object>;
seen: Set<object>;
}): 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);
}