diff --git a/package-lock.json b/package-lock.json index c7eabb8..0888285 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2147,9 +2147,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/src/compiler/catalog.js b/src/compiler/catalog.js index 736b6d7..1a06ba1 100644 --- a/src/compiler/catalog.js +++ b/src/compiler/catalog.js @@ -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]; @@ -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 = {}; diff --git a/src/compiler/compile.js b/src/compiler/compile.js index 9b6e998..7a76d6b 100644 --- a/src/compiler/compile.js +++ b/src/compiler/compile.js @@ -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 = []; @@ -162,6 +165,7 @@ function compileSpec(spec, catalog, opts) { ok: true, js: jsOut, css: cssOut, + warnings, report: { motions: spec.motions.length, jsCount: nJs, diff --git a/src/compiler/validate.js b/src/compiler/validate.js index 1be21d7..10b5a09 100644 --- a/src/compiler/validate.js +++ b/src/compiler/validate.js @@ -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 @@ -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", +]; +/* . 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 . 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" && @@ -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.'); @@ -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." }); diff --git a/src/mcp/register-tools.js b/src/mcp/register-tools.js index 23fea0d..7aa935d 100644 --- a/src/mcp/register-tools.js +++ b/src/mcp/register-tools.js @@ -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). */ @@ -83,7 +85,7 @@ 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 }, }, @@ -91,12 +93,16 @@ function registerMotionspecTools(server, deps) { 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 }; } ); @@ -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 }; } ); diff --git a/test/mcp.test.mjs b/test/mcp.test.mjs index c0a44bd..7264471 100644 --- a/test/mcp.test.mjs +++ b/test/mcp.test.mjs @@ -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 @@ -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)); diff --git a/test/validate.test.js b/test/validate.test.js index 464cc26..33fdf26 100644 --- a/test/validate.test.js +++ b/test/validate.test.js @@ -123,3 +123,79 @@ test("no globals -> r.warnings contains no MS-GLOBALS-RRM-OFF", () => { "warnings must not contain MS-GLOBALS-RRM-OFF when globals is missing" ); }); + +/* ---- 2026-08-04: `ease` is a vocabulary, not a charset --------------------- + * The catalog pattern ^[A-Za-z0-9.()]{1,40}$ was INVERTED in practice: it let + * every invented value through and rejected real GSAP eases that contain a + * comma. An invented value reached the emitted file verbatim and GSAP silently + * fell back to its default. A vocabulary regex does not fit the 100-char + * catalog screen, so the gate lives in the validator. */ +const easeMotion = (ease) => ({ + id: "m1", + primitive: "scrollReveal", + target: ".hero h1", + params: { from: { opacity: 0, y: 48 }, ease }, +}); +const easeOk = (e) => validateSpec(baseSpec([easeMotion(e)]), catalog).ok; + +test("ease: real GSAP eases are accepted", () => { + for (const e of ["none", "linear", "power0.in", "power1.in", "power2.out", "power3.out", + "power4.inOut", "back.out(1.7)", "back.in(2)", "elastic.out(1)", + "bounce.inOut", "sine.in", "circ.out", "expo.inOut", + "quad.out", "cubic.in", "quart.out", "quint.inOut", + "strong.out", "steps(5)", "steps(100)"]) { + assert.equal(easeOk(e), true, "must accept the real ease " + JSON.stringify(e)); + } +}); + +/* Known, deliberate residue: a multi-argument config is a valid GSAP ease but + * the primitive's charset pattern forbids the comma. Widening that pattern + * changes the catalog and needs a MAJOR bump per primitive, so it is out of + * scope here — the error must at least name the real cause instead of + * claiming the ease is unknown. */ +test("ease: a multi-argument config is refused with the honest reason", () => { + for (const e of ["elastic.out(1,0.3)", "elastic.out(1, 0.3)"]) { + const r = validateSpec(baseSpec([easeMotion(e)]), catalog); + assert.equal(r.ok, false, e + " is still blocked by the catalog charset pattern"); + assert.ok( + r.errors.some((m) => m.includes("MS-PARAM-EASE-UNSUPPORTED")), + "must be reported as UNSUPPORTED, not as an unknown ease: " + JSON.stringify(r.errors) + ); + } +}); + +test("ease: invented values are rejected (MS-PARAM-EASE)", () => { + for (const e of ["quantumBounce9000", "powr3.out", "banana.out", "x", "ZZZ", "1", + "power9.out", "back.sideways", "steps(1234)"]) { + const r = validateSpec(baseSpec([easeMotion(e)]), catalog); + assert.equal(r.ok, false, "must reject " + JSON.stringify(e)); + assert.ok( + r.errors.some((m) => m.includes("MS-PARAM-EASE") || m.includes("MS-PARAM-PATTERN")), + "rejection of " + JSON.stringify(e) + " must name the ease rule" + ); + } +}); + +test("ease: exactly one error per invented value (no double report)", () => { + const r = validateSpec(baseSpec([easeMotion("banana.out")]), catalog); + assert.equal(r.errors.length, 1, "charset screen and vocabulary gate must not both fire"); + assert.ok(r.errors[0].includes("MS-PARAM-EASE")); +}); + +/* ---- 2026-08-04: respectReducedMotion is type-checked --------------------- + * Any truthy non-boolean used to pass as ok:true AND slip past the `=== false` + * comparison, so not even the warning was raised. */ +test("globals.respectReducedMotion must be a boolean (MS-GLOBALS-RRM-TYPE)", () => { + for (const v of ["nein danke", "false", 0, 1, [], {}]) { + const spec = { + specVersion: "1.0", + meta: { project: "t", target: "vanilla-gsap" }, + globals: { respectReducedMotion: v }, + motions: [okMotion()], + }; + const r = validateSpec(spec, catalog); + assert.equal(r.ok, false, "must reject respectReducedMotion=" + JSON.stringify(v)); + assert.ok(r.errors.some((m) => m.includes("MS-GLOBALS-RRM-TYPE"))); + } + assert.equal(validateSpec(baseSpec([okMotion()]), catalog).ok, true, "true stays valid"); +});