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:
parent
8a7c1b3f54
commit
8b9d3a7a4c
2 changed files with 516 additions and 0 deletions
266
packages/chess/src/modifiers/custom/validate.test.ts
Normal file
266
packages/chess/src/modifiers/custom/validate.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
250
packages/chess/src/modifiers/custom/validate.ts
Normal file
250
packages/chess/src/modifiers/custom/validate.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue