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