Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion src/compiler/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ function catalogVersion(catalog) {
* source: the default is all 8 fields; an optional fields subset returns fewer
* (e.g. for a leaner prompt). Primitives sorted by name. */
function catalogSummary(catalog, fields) {
const ALL = ["name", "version", "purpose", "engine", "cost", "paramSchema", "triggerDefaults", "reducedMotionFallback"];
const ALL = ["name", "version", "purpose", "engine", "cost", "paramSchema", "triggerDefaults", "reducedMotionFallback", "persistent"];
const pick = Array.isArray(fields) ? fields : ALL;
return Object.keys(catalog).sort().map((name) => {
const p = catalog[name];
Expand All @@ -109,6 +109,10 @@ function catalogSummary(catalog, fields) {
paramSchema: p.paramSchema || {},
triggerDefaults: p.triggerDefaults || {},
reducedMotionFallback: (p.a11y && p.a11y.reducedMotionFallback) || null,
/* WCAG 2.2.2 decides on THIS field: a persistent primitive keeps moving
* on its own and therefore needs a pause/stop control. Without it in the
* summary a caller cannot tell which primitives trigger the criterion. */
persistent: !!(p.a11y && p.a11y.persistent),
};
if (pick === ALL) return full;
const o = {};
Expand Down
8 changes: 6 additions & 2 deletions src/compiler/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,14 @@ function compileSpec(spec, catalog, opts) {
const o = opts || {};
const budget = typeof o.budget === "number" ? o.budget : BUDGET; /* audit #16: configurable */
const v = validateSpec(spec, catalog);
if (!v.ok) return { ok: false, errors: v.errors };
if (!v.ok) return { ok: false, errors: v.errors, warnings: v.warnings || [] };

/* ADR-0001 D4: deprecation note comes from the validator (single source),
* so validate-only callers and the compile report agree. */
* so validate-only callers and the compile report agree. The same applies to
* warnings[] — the WCAG 2.2.2 signals were computed and then dropped here,
* so a caller that only ever compiles never saw them either. */
const deprecations = v.deprecations || [];
const warnings = v.warnings || [];

const respectRM = !(spec.globals && spec.globals.respectReducedMotion === false);
const js = [], css = [];
Expand Down Expand Up @@ -162,6 +165,7 @@ function compileSpec(spec, catalog, opts) {
ok: true,
js: jsOut,
css: cssOut,
warnings,
report: {
motions: spec.motions.length,
jsCount: nJs,
Expand Down
74 changes: 68 additions & 6 deletions src/compiler/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
* MS-PARAM-UNSAFE string parameter contains a dangerous token (javascript:, expression(, url(, ...)
* MS-PARAM-PATTERN string parameter violates the allowed pattern
* MS-PARAM-PATTERN-DEF paramSchema.pattern is itself invalid
* MS-PARAM-EASE "ease" is not a known GSAP ease (vocabulary gate; the
* catalog pattern can only screen characters, see EASE_RE)
* MS-PARAM-EASE-UNSUPPORTED "ease" is a real GSAP ease but the primitive's
* charset pattern forbids its characters (multi-argument
* config); widening the pattern needs a MAJOR bump
* MS-GLOBALS-RRM-TYPE globals.respectReducedMotion is not a boolean
* MS-TRANSFORM-KEY disallowed transform key
* MS-TRANSFORM-TYPE transform value is not a number
* MS-TRIGGER-OBJ trigger is not an object
Expand Down Expand Up @@ -104,6 +110,39 @@ const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
const SELECTOR_RE = /^[A-Za-z0-9 _\-#.:,()>+~*=[\]"|^$]{1,200}$/;
const STRING_PARAM_RE = /^[^\x00-\x1F\x7F`\\]{0,200}$/;

/* ---- `ease` is a VOCABULARY, not a charset -------------------------------
* The catalog `pattern` for `ease` can only screen characters: catalog.js caps
* every pattern at 100 chars (MAX_PATTERN_CHARS, a ReDoS guard that is itself
* asserted in test/forge-promote-gate.test.js), and a GSAP ease vocabulary does
* not fit in 100 chars. A charset pattern is INVERTED in practice: it accepts
* "banana.out" and rejects "elastic.out(1,0.3)". An invented value passes
* validation, is written verbatim into the emitted file, and gsap.parseEase()
* silently falls back to the default — the shipped motion is not the specified
* one and nothing warns.
* Therefore the vocabulary is enforced HERE, where no length screen applies.
* The primitive files stay untouched, so catalogVersion does NOT change and no
* primitive needs a MAJOR bump. */
const EASE_ALIASES = ["none", "linear"];
const EASE_FAMILIES = [
"power0", "power1", "power2", "power3", "power4",
"back", "bounce", "circ", "cubic", "elastic", "expo",
"quad", "quart", "quint", "sine", "strong",
];
/* <family>.<in|out|inOut> with an optional numeric config of at most three
* numbers, e.g. back.out(1.7) / elastic.out(1, 0.3). All quantifiers are
* bounded — no nested unbounded repetition, so no ReDoS surface. */
const EASE_RE = new RegExp(
"^(?:" + EASE_FAMILIES.join("|") + ")\\.(?:in|out|inOut)" +
"(?:\\(-?\\d{1,6}(?:\\.\\d{1,6})?(?:,\\s?-?\\d{1,6}(?:\\.\\d{1,6})?){0,2}\\))?$"
);
const EASE_STEPS_RE = /^steps\(\d{1,3}\)$/;
function easeAllowed(v) {
return EASE_ALIASES.indexOf(v) !== -1 || EASE_STEPS_RE.test(v) || EASE_RE.test(v);
}
const EASE_HINT =
'Allowed: "none", "linear", "steps(n)", or <family>.<in|out|inOut> where family is one of ' +
EASE_FAMILIES.join(", ") + " — optionally with a numeric config, e.g. back.out(1.7), elastic.out(1, 0.3).";

function safeSelector(s) {
return (
typeof s === "string" &&
Expand Down Expand Up @@ -134,12 +173,28 @@ function validateParams(prim, params, at, push, partial) {
if (typeof v !== "string") push("MS-PARAM-TYPE", at + ': "' + k + '" must be a string.');
else if (!STRING_PARAM_RE.test(v)) push("MS-PARAM-CHARSET", at + ': "' + k + '" contains disallowed characters (control characters, backslash, backtick) or is too long.');
else if (unsafeToken(v)) push("MS-PARAM-UNSAFE", at + ': "' + k + '" contains a disallowed token "' + unsafeToken(v) + '" (e.g. javascript:, expression(, url(). Rejected.');
else if (def.pattern) {
let re = null;
try { re = new RegExp(def.pattern); }
catch { push("MS-PARAM-PATTERN-DEF", at + ': paramSchema.pattern for "' + k + '" is not a valid regular expression.'); }
if (re && !re.test(v))
push("MS-PARAM-PATTERN", at + ': "' + k + '" = "' + v + '" does not match the allowed pattern ' + def.pattern + ".");
else {
let screened = true;
if (def.pattern) {
let re = null;
try { re = new RegExp(def.pattern); }
catch { push("MS-PARAM-PATTERN-DEF", at + ': paramSchema.pattern for "' + k + '" is not a valid regular expression.'); screened = false; }
if (re && !re.test(v)) {
/* Name the real cause: a legitimate GSAP ease can still be blocked
* by the primitive's charset pattern (a comma or space in a
* multi-argument config). Widening that pattern changes the
* catalog and therefore needs a MAJOR bump — until then, say so
* instead of claiming the value is unknown. */
if (k === "ease" && easeAllowed(v))
push("MS-PARAM-EASE-UNSUPPORTED", at + ': "ease" = "' + v + '" is a valid GSAP ease, but the catalog pattern for "' + prim.name + '" (' + def.pattern + ') does not permit its characters — a multi-argument config uses a comma. Use a single-argument form such as elastic.out(1), or omit the config.');
else
push("MS-PARAM-PATTERN", at + ': "' + k + '" = "' + v + '" does not match the allowed pattern ' + def.pattern + ".");
screened = false;
}
}
/* Vocabulary gate on top of the charset screen — see EASE_RE above. */
if (screened && k === "ease" && !easeAllowed(v))
push("MS-PARAM-EASE", at + ': "ease" = "' + v + '" is not a known GSAP ease. ' + EASE_HINT);
}
} else if (def.type === "boolean") {
if (typeof v !== "boolean") push("MS-PARAM-TYPE", at + ': "' + k + '" must be true or false.');
Expand Down Expand Up @@ -209,6 +264,13 @@ function validateGlobals(spec, push, warnings) {
Object.keys(spec.globals).forEach((k) => {
if (GLOBALS_KEYS.indexOf(k) === -1) push("MS-GLOBALS-KEY", 'globals: unknown key "' + k + '" (allowed: ' + GLOBALS_KEYS.join(", ") + ").");
});
/* Fail-closed on the TYPE (mirrors pauseControls). Without this, any truthy
* non-boolean — "nein danke", 0, [] — passes as ok:true AND slips past the
* `=== false` check below, so not even the warning is raised. */
if (spec.globals.respectReducedMotion !== undefined &&
typeof spec.globals.respectReducedMotion !== "boolean")
push("MS-GLOBALS-RRM-TYPE", "globals.respectReducedMotion must be true or false (got " + (Array.isArray(spec.globals.respectReducedMotion) ? "array" : typeof spec.globals.respectReducedMotion) + ").");

if (spec.globals.respectReducedMotion === false)
warnings.push({ code: "MS-GLOBALS-RRM-OFF", message: "globals.respectReducedMotion is explicitly false — the compiler emits no prefers-reduced-motion guard. Recommendation: true." });

Expand Down
16 changes: 11 additions & 5 deletions src/mcp/register-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const AUTHORING_RULES = [
'4. "id" matches [A-Za-z0-9_-]{1,64}, descriptive, unique per motion.',
'5. meta.target is "vanilla-gsap". Set globals.respectReducedMotion: true.',
"6. If no catalog primitive covers the request, do NOT improvise — tell the user which primitive is missing (this is an escalation signal).",
'7. WCAG 2.2.2: a primitive with "persistent": true keeps moving on its own and needs a pause/stop control. globals.pauseControls is "auto" (default, emits the control), "api" (you wire your own) or "off". Setting it to "off" with a persistent motion in the spec is a 2.2.2 violation and is reported in warnings[].',
'8. warnings[] is advisory, not fatal: ok=true with a non-empty warnings[] means the spec compiles but does NOT meet the accessibility recommendation. Read it before you ship.',
].join("\n");

/* Catalog summary from the shared source (TASK-026). */
Expand Down Expand Up @@ -83,20 +85,24 @@ function registerMotionspecTools(server, deps) {
{
title: "Validate a MotionSpec (trust boundary)",
description:
"Checks a MotionSpec against the schema, the primitive allow-list, parameter bounds and injection rules. Fail-closed: returns ok=false with precise errors. Use to pre-check a spec before compiling.",
"Checks a MotionSpec against the schema, the primitive allow-list, parameter bounds and injection rules. Fail-closed: returns ok=false with precise errors. Returns {ok, errors, warnings, deprecations, catalogVersion}. IMPORTANT: warnings[] carries the WCAG 2.2.2 / reduced-motion findings and can be non-empty while ok=true — a spec that compiles is not automatically accessible. Use to pre-check a spec before compiling.",
inputSchema: { spec: z.record(z.string(), z.any()).describe("The MotionSpec JSON object") },
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ spec }) => {
const catVer = getCatVer();
const big = oversizeError(spec);
if (big) {
const out = { ok: false, errors: ["[" + big.code + "] " + big.message], deprecations: [], catalogVersion: catVer };
const out = { ok: false, errors: ["[" + big.code + "] " + big.message], warnings: [], deprecations: [], catalogVersion: catVer };
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out, isError: true };
}
const v = validateSpec(spec, getCatalog());
telemetry.log({ outcome: v.ok ? "mcp-validate-ok" : "mcp-validate-fail", model: "mcp-host", attempts: 1, errors: v.ok ? undefined : v.errors });
const out = { ok: v.ok, errors: v.errors || [], deprecations: v.deprecations || [], catalogVersion: catVer };
/* warnings[] carries the two WCAG 2.2.2 signals (MS-GLOBALS-RRM-OFF,
* MS-GLOBALS-PAUSE-OFF). validate.js computes them; dropping the field
* here made the only publicly reachable checker answer ok:true for a spec
* with reduced-motion off, pause off and a 120 s marquee. */
const out = { ok: v.ok, errors: v.errors || [], warnings: v.warnings || [], deprecations: v.deprecations || [], catalogVersion: catVer };
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out };
}
);
Expand All @@ -123,8 +129,8 @@ function registerMotionspecTools(server, deps) {
const res = compileSpec(spec, getCatalog(), { specName: specName || "mcp-spec" });
telemetry.log({ outcome: res.ok ? "mcp-compile-ok" : "mcp-compile-fail", model: "mcp-host", attempts: 1, errors: res.ok ? undefined : res.errors });
const out = res.ok
? { ok: true, js: res.js, css: res.css, report: res.report, catalogVersion: catVer }
: { ok: false, errors: res.errors, hint: "Fix the listed errors. Call motion_catalog to re-check allowed primitives and parameter bounds.", catalogVersion: catVer };
? { ok: true, js: res.js, css: res.css, warnings: res.warnings || [], report: res.report, catalogVersion: catVer }
: { ok: false, errors: res.errors, warnings: res.warnings || [], hint: "Fix the listed errors. Call motion_catalog to re-check allowed primitives and parameter bounds.", catalogVersion: catVer };
return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out, isError: !res.ok };
}
);
Expand Down
42 changes: 40 additions & 2 deletions test/mcp.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,17 @@ test("MCP: motion_catalog returns primitives + rules", async () => {
/* TASK-026 (Finding #23): shared catalogSummary returns all 8 fields per
* primitive (was split across two divergent local copies). */
assert.equal(out.primitives.length, 40);
const FIELDS = ["name", "version", "purpose", "engine", "cost", "paramSchema", "triggerDefaults", "reducedMotionFallback"];
const FIELDS = ["name", "version", "purpose", "engine", "cost", "paramSchema", "triggerDefaults", "reducedMotionFallback", "persistent"];
for (const p of out.primitives) {
assert.deepEqual(Object.keys(p).sort(), [...FIELDS].sort(), "each primitive carries the 8 summary fields");
assert.deepEqual(Object.keys(p).sort(), [...FIELDS].sort(), "each primitive carries the 9 summary fields");
}
/* WCAG 2.2.2 is decidable from the catalog alone: without `persistent` a
* caller cannot tell which primitives need a pause control. */
const persistent = out.primitives.filter((p) => p.persistent);
assert.equal(persistent.length, 18, "18 of the 40 primitives are persistent (WCAG 2.2.2 applies)");
assert.ok(persistent.some((p) => p.name === "marquee"), "marquee must be flagged persistent");
assert.ok(out.primitives.some((p) => p.name === "hoverLift" && p.persistent === false), "a hover primitive is not persistent");
assert.ok(out.authoringRules.includes("pauseControls"), "authoring rules must teach pauseControls (2.2.2)");
assert.ok(out.authoringRules.includes("Never invent"));
assert.ok(out.catalogVersion.length === 16);
/* Re-audit 2026-06-15: the MCP layer is the contract surface the model
Expand Down Expand Up @@ -124,6 +131,37 @@ test("MCP: motion_validate reports precise errors", async () => {
});
});

/* Regression for the 2026-08-03 finding: validate.js computed both WCAG 2.2.2
* warnings and register-tools.js built the response without the field, so the
* only publicly reachable checker answered ok:true for a spec with
* reduced-motion off, pause off, a 120 s marquee and a 60 s spin. */
test("MCP: motion_validate surfaces the WCAG 2.2.2 warnings (they are not dropped)", async () => {
await withClient(async (c) => {
const spec = {
specVersion: "1.0",
meta: { project: "guard-regression", target: "vanilla-gsap", createdWith: "mcp-host" },
globals: { respectReducedMotion: false, pauseControls: "off" },
motions: [
{ id: "ticker", primitive: "marquee", target: ".ticker", params: { duration: 120 } },
{ id: "spinner", primitive: "spinLoop", target: ".logo", params: { duration: 60 } },
],
};
const out = (await c.callTool({ name: "motion_validate", arguments: { spec } })).structuredContent;
assert.ok(Array.isArray(out.warnings), "the response must carry a warnings array");
const codes = out.warnings.map((w) => w.code);
assert.ok(codes.includes("MS-GLOBALS-RRM-OFF"), "reduced-motion off must be reported");
assert.ok(codes.includes("MS-GLOBALS-PAUSE-OFF"), "pause path off + persistent motion must be reported");
});
});

test("MCP: a clean spec reports no warnings", async () => {
await withClient(async (c) => {
const out = (await c.callTool({ name: "motion_validate", arguments: { spec: validSpec } })).structuredContent;
assert.equal(out.ok, true);
assert.deepEqual(out.warnings, [], "a clean spec must not raise warnings");
});
});

test("MCP: oversize spec is rejected before processing (DoS cap)", async () => {
await withClient(async (c) => {
const huge = JSON.parse(JSON.stringify(validSpec));
Expand Down
Loading