motionspec 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ request ──> Routing (small model, Stage A) ──> MotionSpec (JSON)
22
22
  |---|---|
23
23
  | Version | **v1.0.3** · schema frozen at spec v1 (ADR-0001, signed) |
24
24
  | Published | **npm `motionspec`** · MCP Registry `io.github.MasterPlayspots/motionspec` |
25
- | Tests | **155** green · CI on Node 18/20/22 |
25
+ | Tests | **177** green · CI on Node 18/20/22 |
26
26
  | Catalog | **8** primitives, all device-verified |
27
27
  | Dependencies | **0 vulnerabilities** · SBOM committed · all permissive licenses |
28
28
  | Coverage | **98.4% lines / 95.9% functions** of `src/` + `worker/` (CI gate fails under 90%) |
@@ -51,7 +51,7 @@ Listed on the MCP Registry as `io.github.MasterPlayspots/motionspec`.
51
51
 
52
52
  ```bash
53
53
  npm ci # install (0 runtime deps beyond MCP SDK + zod)
54
- npm test # 151 tests: validator, compiler-golden, router, fuzz, schema parity, worker contract
54
+ npm test # 177 tests: validator, compiler-golden, router, fuzz, schema parity, worker contract
55
55
  node bin/motion.js catalog # primitives + catalog version (16-char hash)
56
56
  node bin/motion.js compile examples/hero.motionspec.json
57
57
  node bin/motion.js pipeline "Hero headline fades in, cards staggered" --mock
@@ -87,7 +87,7 @@ Tools: `motion_catalog` (primitives + authoring rules) · `motion_validate` (fai
87
87
 
88
88
  1. **Allow-list** — a primitive not in the catalog never reaches the compiler.
89
89
  2. **Injection-proof** — ids, selectors, string params and triggers are charset-validated; every interpolation is a JS literal (`JSON.stringify`) or a CSS-screened raw value. Malicious model output is rejected fail-closed (tested + fuzzed over 6000 random specs).
90
- 3. **a11y** — `respectReducedMotion` gates JS and CSS; the model cannot switch it off.
90
+ 3. **a11y** — `respectReducedMotion` is **default-on at the compiler level** (fail-safe): omitting `globals` or the field yields a `prefers-reduced-motion` guard. Setting `globals.respectReducedMotion: false` is accepted but emits a compiler warning (`MS-GLOBALS-RRM-OFF`); a prompt-only instruction is not sufficient to disable the guard.
91
91
  4. **Determinism** — same spec ⇒ identical code (golden-file tests).
92
92
  5. **Versioned** — schema frozen v1; catalog SemVer enforced by a diff-gate; specs may pin `catalogVersion` for reproducibility (`MS-CATALOG-PIN-MISMATCH` fail-closed).
93
93
  6. **Observability** — every request logs `model | model-repaired | cache-hit | escalate-*` to `telemetry/events.jsonl`; escalation clusters are the signal for new primitives (concept §3.3).
@@ -102,7 +102,7 @@ src/compiler/ catalog.js · catalog-semver.js · validate.js (Trust Boundary)
102
102
  src/router/ prompt.js · clients.js (openai-compat + mock) · route.js · cache.js · telemetry.js
103
103
  src/mcp/ server.mjs (4 tools, fail-closed, input-capped)
104
104
  bin/ motion.js (CLI) · catalog-lock.js · license-check.js
105
- test/ 151 tests incl. injection attacks, fuzz, golden files, schema parity, worker contract; test/e2e (Playwright)
105
+ test/ 177 tests incl. injection attacks, fuzz, golden files, schema parity, worker contract; test/e2e (Playwright)
106
106
  docs/ ADR-0001 (schema freeze) · ROADMAP_TO_10 · PHASE_A_REAUDIT · CLAUDE_CODE_HANDOFF
107
107
  ```
108
108
 
@@ -115,6 +115,12 @@ Concept, research, pitch, business docs and the CHS client site live in the sibl
115
115
  - `docs/adr/0001-schema-freeze-v1.md` — the frozen v1 contract and why.
116
116
  - `docs/PHASE_A_REAUDIT_2026-06-15.md` — independent re-audit findings + fixes.
117
117
 
118
+ ## Contributing
119
+
120
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the
121
+ gate-driven PR checklist, commit conventions, and a short architecture tour. Bug
122
+ reports and feature requests have issue templates under `.github/ISSUE_TEMPLATE/`.
123
+
118
124
  ## License
119
125
 
120
126
  MIT.
package/bin/motion.js CHANGED
@@ -79,7 +79,7 @@ async function main() {
79
79
  }
80
80
 
81
81
  if (cmd === "stats") {
82
- const s = telemetry.summary();
82
+ const s = await telemetry.summary();
83
83
  console.log("Telemetrie: " + s.total + " Ereignisse");
84
84
  Object.keys(s.byOutcome).forEach((k) => console.log(" " + k + ": " + s.byOutcome[k]));
85
85
  return;
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /*
4
+ * SBOM-Werkzeug (TASK-021, Audit-Befund #13). Zero-dep, CJS.
5
+ *
6
+ * Hintergrund: `npm sbom --omit dev` laesst hier 9 Laufzeit-Pakete aus
7
+ * (cross-spawn, debug, ms, which, isexe, path-key, shebang-command,
8
+ * shebang-regex, fast-deep-equal) — sie haengen am Prod-Baum von
9
+ * @modelcontextprotocol/sdk, werden aber, weil sie ZUSAETZLICH ueber einen
10
+ * dev-Pfad (eslint) erreichbar sind, von npm faelschlich weggeschnitten. Das
11
+ * Lizenz-Gate war dadurch lueckenhaft. Gleichzeitig darf die SBOM keine reinen
12
+ * dev-Pakete (wrangler/eslint/…) enthalten, weil deren Lizenzen das Gate sonst
13
+ * (zu Recht) rot faerben und nicht ausgeliefert werden.
14
+ *
15
+ * Loesung: die VOLLE SBOM erzeugen und auf die LAUFZEIT-Pakete filtern
16
+ * (Quelle der Wahrheit: package-lock.json, top-level node_modules/* mit
17
+ * dev!==true). Das ergibt eine vollstaendige UND ausschliesslich-Laufzeit-SBOM.
18
+ *
19
+ * Modi:
20
+ * --filter liest eine volle CycloneDX-SBOM von stdin, schreibt die
21
+ * Laufzeit-gefilterte SBOM nach stdout (genutzt vom `sbom`-Script).
22
+ * (default) prueft sbom.cdx.json: jedes Laufzeit-Paket MUSS als Komponente
23
+ * vorhanden sein; fehlt eines -> Liste + exit 1, sonst exit 0.
24
+ */
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+
28
+ const ROOT = path.join(__dirname, "..");
29
+ const LOCK = path.join(ROOT, "package-lock.json");
30
+ const SBOM = path.join(ROOT, "sbom.cdx.json");
31
+
32
+ /* Laufzeit-Pakete laut Lockfile: top-level node_modules/<name>, dev !== true.
33
+ * Liefert ein Set voller Namen (inkl. @scope). */
34
+ function runtimeNames() {
35
+ const lock = JSON.parse(fs.readFileSync(LOCK, "utf8"));
36
+ const names = new Set();
37
+ for (const key of Object.keys(lock.packages || {})) {
38
+ const m = /^node_modules\/(@[^/]+\/[^/]+|[^/]+)$/.exec(key);
39
+ if (!m) continue; // nur top-level (keine verschachtelten Dubletten)
40
+ if (lock.packages[key].dev === true) continue; // reine dev-Pakete weglassen
41
+ names.add(m[1]);
42
+ }
43
+ return names;
44
+ }
45
+
46
+ /* Voller Name einer CycloneDX-Komponente (npm legt @scope direkt in name, aber
47
+ * group wird defensiv mitgenommen, falls ein Generator es doch trennt). */
48
+ function compName(c) {
49
+ return c.group ? c.group + "/" + c.name : c.name;
50
+ }
51
+
52
+ function readStdin() {
53
+ return fs.readFileSync(0, "utf8");
54
+ }
55
+
56
+ function filterMode() {
57
+ const full = JSON.parse(readStdin());
58
+ const runtime = runtimeNames();
59
+ const keep = (full.components || []).filter((c) => runtime.has(compName(c)));
60
+ const keptRefs = new Set(keep.map((c) => c["bom-ref"]).filter(Boolean));
61
+ full.components = keep;
62
+ /* Abhaengigkeits-Graph auf behaltene Komponenten beschneiden, damit keine
63
+ * baumelnden Referenzen zurueckbleiben (die Wurzel-Komponente bleibt). */
64
+ if (Array.isArray(full.dependencies)) {
65
+ const rootRef = full.metadata && full.metadata.component && full.metadata.component["bom-ref"];
66
+ full.dependencies = full.dependencies
67
+ .filter((d) => d.ref === rootRef || keptRefs.has(d.ref))
68
+ .map((d) => Array.isArray(d.dependsOn)
69
+ ? Object.assign({}, d, { dependsOn: d.dependsOn.filter((r) => r === rootRef || keptRefs.has(r)) })
70
+ : d);
71
+ }
72
+ process.stdout.write(JSON.stringify(full, null, 2) + "\n");
73
+ }
74
+
75
+ function checkMode() {
76
+ if (!fs.existsSync(SBOM)) { console.error("Keine sbom.cdx.json — erst `npm run sbom`."); process.exit(1); }
77
+ const sbom = JSON.parse(fs.readFileSync(SBOM, "utf8"));
78
+ const present = new Set((sbom.components || []).map(compName));
79
+ const missing = [...runtimeNames()].filter((n) => !present.has(n)).sort();
80
+ if (missing.length) {
81
+ console.error("SBOM unvollstaendig — " + missing.length + " Laufzeit-Paket(e) fehlen:");
82
+ missing.forEach((n) => console.error(" - " + n));
83
+ console.error("Fix: `npm run sbom` neu erzeugen (vollstaendige Laufzeit-SBOM).");
84
+ process.exit(1);
85
+ }
86
+ console.error("SBOM OK — alle " + present.size + " Komponenten decken die Laufzeit-Pakete ab.");
87
+ }
88
+
89
+ if (process.argv.includes("--filter")) filterMode();
90
+ else checkMode();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "motionspec",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "mcpName": "io.github.MasterPlayspots/motionspec",
5
5
  "description": "MotionSpec — a formal intermediate language for scroll-driven web motion. A small model writes schema-validated specs; a deterministic compiler emits GSAP/CSS, hallucination-proof by construction with enforced reduced-motion fallbacks.",
6
6
  "license": "MIT",
@@ -41,7 +41,8 @@
41
41
  "test": "node --test test/validate.test.js test/compile.test.js test/specversion-v1.test.js test/route.test.js test/route-live.test.js test/catalog-wave1.test.js test/catalog-semver.test.js test/schema-parity.test.js test/catalog-pin.test.js test/css-safety.test.js test/fuzz.test.js test/hardening.test.js test/cache.test.js test/errorcodes.test.js test/catalog-integrity.test.js test/discover.test.js test/telemetry-sink.test.js test/worker-contract.test.mjs test/analytics-engine-sink.test.mjs test/canary.test.mjs test/dashboard.test.mjs test/mcp.test.mjs test/cli-output.test.mjs",
42
42
  "coverage": "node --test --experimental-test-coverage --test-coverage-exclude=test/** --test-coverage-exclude=bin/** --test-coverage-lines=90 --test-coverage-functions=90 --test-coverage-branches=75 test/*.test.js test/*.test.mjs",
43
43
  "e2e": "playwright test",
44
- "sbom": "npm sbom --sbom-format cyclonedx --omit dev > sbom.cdx.json",
44
+ "sbom": "npm sbom --sbom-format cyclonedx | node bin/sbom-check.js --filter > sbom.cdx.json",
45
+ "sbom:check": "node bin/sbom-check.js",
45
46
  "catalog-lock": "node bin/catalog-lock.js",
46
47
  "catalog-lock:check": "node bin/catalog-lock.js --check",
47
48
  "mcp": "node src/mcp/server.mjs",
@@ -51,20 +52,25 @@
51
52
  "pipeline": "node bin/motion.js pipeline",
52
53
  "catalog": "node bin/motion.js catalog",
53
54
  "stats": "node bin/motion.js stats",
54
- "prepublishOnly": "npm test && npm run catalog-lock:check && npm run sbom && node bin/license-check.js",
55
- "lint": "eslint ."
55
+ "prepublishOnly": "npm test && npm run catalog-lock:check && npm run sbom && npm run sbom:check && node bin/license-check.js && node -e \"if(!require('fs').existsSync('CHANGELOG.md'))throw new Error('CHANGELOG.md fehlt')\"",
56
+ "lint": "eslint .",
57
+ "prepare": "husky"
56
58
  },
57
59
  "engines": {
58
60
  "node": ">=18"
59
61
  },
60
62
  "dependencies": {
61
63
  "@modelcontextprotocol/sdk": "1.29.0",
62
- "zod": "^4.4.3"
64
+ "zod": "4.4.3"
63
65
  },
64
66
  "devDependencies": {
67
+ "@commitlint/cli": "^21.0.2",
68
+ "@commitlint/config-conventional": "^21.0.2",
65
69
  "@eslint/js": "^10.0.1",
66
70
  "@playwright/test": "^1.50.0",
67
71
  "eslint": "^10.5.0",
68
- "globals": "^17.6.0"
72
+ "globals": "^17.6.0",
73
+ "husky": "^9.1.7",
74
+ "wrangler": "4.103.0"
69
75
  }
70
76
  }
@@ -35,9 +35,9 @@
35
35
  "additionalProperties": false,
36
36
  "required": ["id", "primitive", "target"],
37
37
  "properties": {
38
- "id": { "type": "string", "minLength": 1 },
38
+ "id": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z0-9_-]{1,64}$" },
39
39
  "primitive": { "type": "string", "description": "Muss ein Name aus dem Primitive-Katalog sein. Allow-List, zur Laufzeit geprueft." },
40
- "target": { "type": "string", "minLength": 1 },
40
+ "target": { "type": "string", "minLength": 1, "maxLength": 200 },
41
41
  "params": { "type": "object" },
42
42
  "trigger": {
43
43
  "type": "object",
@@ -91,4 +91,30 @@ function catalogVersion(catalog) {
91
91
  return crypto.createHash("sha256").update(stable).digest("hex").slice(0, 16);
92
92
  }
93
93
 
94
- module.exports = { loadCatalog, buildCatalog, catalogVersion, checkPrimitive };
94
+ /* Gemeinsame Katalog-Zusammenfassung (TASK-026, Audit #23). Vorher gab es zwei
95
+ * divergierende Implementierungen (prompt.js 4 Felder, register-tools.js 8). Eine
96
+ * Quelle: Default sind alle 8 Felder; ein optionales fields-Subset liefert weniger
97
+ * (z.B. fuer einen schlankeren Prompt). Primitive nach Namen sortiert. */
98
+ function catalogSummary(catalog, fields) {
99
+ const ALL = ["name", "version", "purpose", "engine", "cost", "paramSchema", "triggerDefaults", "reducedMotionFallback"];
100
+ const pick = Array.isArray(fields) ? fields : ALL;
101
+ return Object.keys(catalog).sort().map((name) => {
102
+ const p = catalog[name];
103
+ const full = {
104
+ name,
105
+ version: p.version,
106
+ purpose: p.purpose,
107
+ engine: p.engine,
108
+ cost: (p.performance && p.performance.cost) || 0,
109
+ paramSchema: p.paramSchema || {},
110
+ triggerDefaults: p.triggerDefaults || {},
111
+ reducedMotionFallback: (p.a11y && p.a11y.reducedMotionFallback) || null,
112
+ };
113
+ if (pick === ALL) return full;
114
+ const o = {};
115
+ pick.forEach((f) => { o[f] = full[f]; });
116
+ return o;
117
+ });
118
+ }
119
+
120
+ module.exports = { loadCatalog, buildCatalog, catalogVersion, checkPrimitive, catalogSummary };
@@ -62,10 +62,35 @@ function withDefaults(schema, given) {
62
62
  return out;
63
63
  }
64
64
 
65
- /*
66
- * compileSpec(spec, catalog, opts) -> {
67
- * ok, errors?, js?, css?, report: { motions, jsCount, cssCount,
68
- * cost, budget, budgetOk, reducedMotion } }
65
+ /**
66
+ * @typedef {Object} CompileReport
67
+ * @property {number} motions - Anzahl kompilierter Motions
68
+ * @property {number} jsCount - Anzahl JS-Ausgaben
69
+ * @property {number} cssCount - Anzahl CSS-Ausgaben
70
+ * @property {number} cost - Summe der Primitiv-Kosten
71
+ * @property {number} budget - Performance-Budget (Default BUDGET)
72
+ * @property {boolean} budgetOk - true wenn cost <= budget
73
+ * @property {boolean} reducedMotion - ob ein prefers-reduced-motion-Guard emittiert wird
74
+ * @property {boolean} reducedMotionOverriddenOff - true wenn die Spec den Guard explizit abschaltet
75
+ * @property {string} specVersion - specVersion der Quelle
76
+ * @property {Array<{code:string,message:string}>} deprecations - Deprecation-Hinweise (ADR-0001 D4)
77
+ */
78
+ /**
79
+ * @typedef {Object} CompileResult
80
+ * @property {boolean} ok - true bei Erfolg, false fail-closed (keine Teil-Ausgabe)
81
+ * @property {string[]} [errors] - Fehler mit [MS-XXX]-Praefix, nur wenn ok=false
82
+ * @property {string|null} [js] - generiertes JS (null wenn keine JS-Motions)
83
+ * @property {string|null} [css] - generiertes CSS (null wenn keine CSS-Motions)
84
+ * @property {CompileReport} [report] - Report, nur wenn ok=true
85
+ */
86
+ /**
87
+ * Validiert (fail-closed) und kompiliert eine MotionSpec deterministisch zu GSAP/CSS.
88
+ * Dieselbe Spec ergibt immer denselben Code; bei Validierungs- oder CSS-Fehlern
89
+ * wird nichts emittiert.
90
+ * @param {Object} spec - die MotionSpec
91
+ * @param {Object} catalog - geladener Primitiv-Katalog
92
+ * @param {{specName?: string, budget?: number}} [opts] - optionaler Artefakt-Name + Budget
93
+ * @returns {CompileResult}
69
94
  */
70
95
  function compileSpec(spec, catalog, opts) {
71
96
  const o = opts || {};
@@ -77,7 +102,7 @@ function compileSpec(spec, catalog, opts) {
77
102
  * so validate-only callers and the compile report agree. */
78
103
  const deprecations = v.deprecations || [];
79
104
 
80
- const respectRM = !!(spec.globals && spec.globals.respectReducedMotion);
105
+ const respectRM = !(spec.globals && spec.globals.respectReducedMotion === false);
81
106
  const js = [], css = [];
82
107
  let cost = 0, nJs = 0, nCss = 0;
83
108
 
@@ -140,6 +165,7 @@ function compileSpec(spec, catalog, opts) {
140
165
  budget,
141
166
  budgetOk: cost <= budget,
142
167
  reducedMotion: respectRM,
168
+ reducedMotionOverriddenOff: !!(spec.globals && spec.globals.respectReducedMotion === false),
143
169
  specVersion: spec.specVersion,
144
170
  deprecations,
145
171
  },
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ /*
3
+ * Gemeinsame Keyword->Primitiv-Heuristik (TASK-025, Audit #22).
4
+ *
5
+ * Vorher gab es ZWEI divergierende Tabellen: clients.js (mockClient, 5 Eintraege,
6
+ * mit target/params) und discover.js (Gap-Report, 8 Eintraege, ohne target). Dadurch
7
+ * fehlten counterUp/marquee/scaleOnScroll im Mock — der Repair-Loop lief gegen ein
8
+ * kuenstlich unvollstaendiges Modell. Eine Quelle der Wahrheit schliesst die Luecke.
9
+ *
10
+ * Quelle der Regex/Reihenfolge ist discover.js (der vollstaendigere 8er-Satz);
11
+ * target/params der fuenf bekannten Primitive stammen aus clients.js, fuer die drei
12
+ * neuen sind sinnvolle Default-Selektoren gesetzt. WICHTIG: Reihenfolge ist API —
13
+ * discover.mapIntent() liefert den ERSTEN Treffer, daher Eintraege nicht umsortieren.
14
+ */
15
+ const KEYWORD_MAP = [
16
+ { re: /(stagger|nacheinander|gestaffelt|one after|cards?|karten|liste)/i, primitive: "staggerReveal", target: ".features .card", params: { from: { opacity: 0, y: 32 } } },
17
+ { re: /(z(ä|ae)hl|counter|hochz(ä|ae)hl|count up|nummer|zahl)/i, primitive: "counterUp", target: ".stats .num", params: {} },
18
+ { re: /(parallax|tiefe|depth|layer|ebene)/i, primitive: "parallaxLayer", target: ".hero .bg", params: { yPercent: -25 } },
19
+ { re: /(pin|fixier|sticky|festhalten|haften)/i, primitive: "pinnedSection", target: ".showcase", params: { distance: "+=100%" } },
20
+ { re: /(marquee|laufband|logo.?band|ticker|endlos)/i, primitive: "marquee", target: ".marquee-track", params: {} },
21
+ { re: /(skalier|scale|zoom|gr(ö|oe)(ß|ss)er werden)/i, primitive: "scaleOnScroll", target: ".section", params: {} },
22
+ { re: /(hover|maus|cursor|button)/i, primitive: "cssTransition", target: ".cta-button", params: { hoverValue: "translateY(-4px)" } },
23
+ { re: /(reveal|einblend|fade|erscheinen|appear|headline|(ü|ue)berschrift|gleitet|scroll)/i, primitive: "scrollReveal", target: ".hero h1", params: { from: { opacity: 0, y: 48 } } },
24
+ ];
25
+
26
+ module.exports = { KEYWORD_MAP };
@@ -49,6 +49,10 @@
49
49
  * MS-DEPRECATED-VERSION specVersion ist deprecated. KEIN harter Fehler:
50
50
  * wird in validateSpec().deprecations[] UND im Compile-Report
51
51
  * gemeldet (gemeinsame Quelle deprecationsFor; ADR-0001 D4).
52
+ * MS-GLOBALS-RRM-OFF globals.respectReducedMotion ist explizit false — Compiler
53
+ * emittiert keinen prefers-reduced-motion-Guard. Kein harter
54
+ * Fehler, aber Warnung (in validateSpec().warnings[]).
55
+ * MS-INPUT-TOO-LARGE Spec-Payload > MAX_SPEC_BYTES (64KB), MCP-Layer.
52
56
  */
53
57
 
54
58
  /* ADR-0001 (signed 2026-06-15): v1 carries "1.0". "0.1" stays accepted for
@@ -71,6 +75,7 @@ const TOP_KEYS = ["specVersion", "catalogVersion", "meta", "globals", "motions"]
71
75
  const CATALOG_PIN_RE = /^[0-9a-f]{16}$/;
72
76
  const META_KEYS = ["project", "target", "createdWith"];
73
77
  const GLOBALS_KEYS = ["respectReducedMotion", "defaultEase"];
78
+ // defaultEase: allowlisted, aber vom Compiler aktuell nicht ausgewertet — belassen um Specs nicht zu brechen (TASK-017)
74
79
  const MOTION_KEYS = ["id", "primitive", "target", "params", "trigger", "responsive"];
75
80
 
76
81
  const { catalogVersion } = require("./catalog.js");
@@ -165,14 +170,103 @@ function validateTrigger(trigger, at, push) {
165
170
  push("MS-TRIGGER-VAL", at + ": trigger.once muss true oder false sein.");
166
171
  }
167
172
 
173
+ /* TASK-027: kohaerente Teil-Validatoren aus validateSpec extrahiert (Verhalten
174
+ * identisch) — so bleibt validateSpec flach und jeder Teil ist einzeln testbar.
175
+ * Keiner davon wird exportiert; das oeffentliche Interface bleibt validateSpec. */
176
+ function validateCatalogPin(spec, catalog, push) {
177
+ /* ADR-0001 D2: optionaler catalogVersion-Pin fuer Reproduzierbarkeit. Pinnt
178
+ * eine Spec einen Hash, MUSS er zum geladenen Katalog passen — fail-closed. */
179
+ if (spec.catalogVersion === undefined) return;
180
+ if (typeof spec.catalogVersion !== "string" || !CATALOG_PIN_RE.test(spec.catalogVersion)) {
181
+ push("MS-CATALOG-PIN", "catalogVersion muss ein 16-stelliger Hex-Hash sein (oder weglassen).");
182
+ return;
183
+ }
184
+ const actual = catalogVersion(catalog);
185
+ if (spec.catalogVersion !== actual)
186
+ push("MS-CATALOG-PIN-MISMATCH", "catalogVersion-Pin (" + spec.catalogVersion + ") passt nicht zum geladenen Katalog (" + actual + "). Spec wurde gegen einen anderen Katalog geschrieben.");
187
+ }
188
+
189
+ function validateMeta(spec, push) {
190
+ if (!spec.meta || typeof spec.meta !== "object") { push("MS-META-MISSING", "meta fehlt."); return; }
191
+ Object.keys(spec.meta).forEach((k) => {
192
+ if (META_KEYS.indexOf(k) === -1) push("MS-META-KEY", 'meta: unbekannter Schluessel "' + k + '".');
193
+ });
194
+ if (TARGETS.indexOf(spec.meta.target) === -1)
195
+ push("MS-META-TARGET", "meta.target fehlt oder wird nicht unterstuetzt (erlaubt: " + TARGETS.join(", ") + ").");
196
+ }
197
+
198
+ function validateGlobals(spec, push, warnings) {
199
+ if (spec.globals === undefined) return;
200
+ if (typeof spec.globals !== "object" || spec.globals === null || Array.isArray(spec.globals)) {
201
+ push("MS-GLOBALS-OBJ", "globals muss ein Objekt sein.");
202
+ return;
203
+ }
204
+ Object.keys(spec.globals).forEach((k) => {
205
+ if (GLOBALS_KEYS.indexOf(k) === -1) push("MS-GLOBALS-KEY", 'globals: unbekannter Schluessel "' + k + '" (erlaubt: ' + GLOBALS_KEYS.join(", ") + ").");
206
+ });
207
+ if (spec.globals.respectReducedMotion === false)
208
+ warnings.push({ code: "MS-GLOBALS-RRM-OFF", message: "globals.respectReducedMotion ist explizit false — Compiler emittiert keinen prefers-reduced-motion-Guard. Empfehlung: true." });
209
+ }
210
+
211
+ /* Validiert EINE Motion (TASK-027: aus validateSpec extrahiert, damit validateSpec
212
+ * flach und gut testbar bleibt — Verhalten identisch). `seen` wird geteilt, damit
213
+ * die id-Eindeutigkeit ueber alle Motions hinweg greift. Bewusst NICHT exportiert. */
214
+ function validateMotion(m, i, catalog, push, seen) {
215
+ const at = "motions[" + i + "]";
216
+ if (typeof m !== "object" || m === null) { push("MS-MOTION-OBJ", at + " ist kein Objekt."); return; }
217
+
218
+ Object.keys(m).forEach((k) => {
219
+ if (MOTION_KEYS.indexOf(k) === -1) push("MS-MOTION-KEY", at + ': unbekannter Schluessel "' + k + '".');
220
+ });
221
+
222
+ if (typeof m.id !== "string" || !ID_RE.test(m.id))
223
+ push("MS-ID-FORMAT", at + ": id fehlt oder verletzt das Format [A-Za-z0-9_-]{1,64}.");
224
+ else if (seen[m.id]) push("MS-ID-DUP", at + ': id "' + m.id + '" ist nicht eindeutig.');
225
+ else seen[m.id] = true;
226
+
227
+ if (!safeSelector(m.target))
228
+ push("MS-TARGET-UNSAFE", at + ": target fehlt oder ist kein sicherer CSS-Selektor (verboten: Quotes, Backslash, {, }, ;, @, Steuerzeichen; max. 200 Zeichen).");
229
+
230
+ /* --- Kern-Schutz: Primitive-Allow-List --- */
231
+ if (typeof m.primitive !== "string" || !m.primitive) {
232
+ push("MS-PRIM-MISSING", at + ": primitive fehlt.");
233
+ } else if (!catalog[m.primitive]) {
234
+ push("MS-PRIM-UNKNOWN", at + ': primitive "' + m.primitive + '" existiert nicht im Katalog. '
235
+ + "Erlaubt sind ausschliesslich: " + Object.keys(catalog).sort().join(", ") + ".");
236
+ } else {
237
+ const prim = catalog[m.primitive];
238
+ validateParams(prim, m.params || {}, at, push, false);
239
+ if (m.trigger !== undefined) validateTrigger(m.trigger, at, push);
240
+ if (m.responsive !== undefined) {
241
+ push("MS-RESP-UNSUPPORTED", at + ": responsive wird in dieser specVersion noch nicht unterstuetzt (geplant fuer 0.2). Feld entfernen.");
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * @typedef {Object} ValidationResult
248
+ * @property {boolean} ok - true wenn die Spec jede Pruefung besteht (fail-closed)
249
+ * @property {string[]} errors - menschenlesbare Fehler, jeweils mit [MS-XXX]-Praefix
250
+ * @property {string[]} errorCodes - die reinen Codes, index-parallel zu errors
251
+ * @property {Array<{code:string,message:string}>} deprecations - Deprecation-Hinweise (kein harter Fehler)
252
+ * @property {Array<{code:string,message:string}>} warnings - Warnungen (kein harter Fehler)
253
+ */
254
+ /**
255
+ * Trust Boundary: prueft eine MotionSpec gegen Schema-Form, Primitiv-Allow-List,
256
+ * Parameter-Grenzen und Injection-Regeln. Erzeugt nie eine Teil-Ausgabe.
257
+ * @param {Object} spec - die zu pruefende MotionSpec
258
+ * @param {Object} catalog - geladener Primitiv-Katalog (Allow-List)
259
+ * @returns {ValidationResult}
260
+ */
168
261
  function validateSpec(spec, catalog) {
169
262
  const errors = [];
170
263
  const errorCodes = [];
171
264
  /* push(code, msg) -> errors bekommt "[code] msg", errorCodes den Code. */
172
265
  const push = (code, m) => { errors.push("[" + code + "] " + m); errorCodes.push(code); };
266
+ const warnings = [];
173
267
 
174
268
  if (typeof spec !== "object" || spec === null || Array.isArray(spec))
175
- return { ok: false, errors: ["[MS-SPEC-OBJ] Spec ist kein Objekt."], errorCodes: ["MS-SPEC-OBJ"], deprecations: [] };
269
+ return { ok: false, errors: ["[MS-SPEC-OBJ] Spec ist kein Objekt."], errorCodes: ["MS-SPEC-OBJ"], deprecations: [], warnings: [] };
176
270
 
177
271
  const deprecations = deprecationsFor(spec.specVersion);
178
272
 
@@ -183,82 +277,30 @@ function validateSpec(spec, catalog) {
183
277
  if (SPEC_VERSIONS.indexOf(spec.specVersion) === -1)
184
278
  push("MS-SPEC-VER", "specVersion fehlt oder ist unbekannt (erlaubt: " + SPEC_VERSIONS.join(", ") + ").");
185
279
 
186
- /* ADR-0001 D2: optional catalogVersion pin for reproducibility. If a spec
187
- * pins a catalog hash, it MUST match the loaded catalog — fail-closed, so a
188
- * spec written against a different catalog cannot silently compile differently. */
189
- if (spec.catalogVersion !== undefined) {
190
- if (typeof spec.catalogVersion !== "string" || !CATALOG_PIN_RE.test(spec.catalogVersion)) {
191
- push("MS-CATALOG-PIN", "catalogVersion muss ein 16-stelliger Hex-Hash sein (oder weglassen).");
192
- } else {
193
- const actual = catalogVersion(catalog);
194
- if (spec.catalogVersion !== actual)
195
- push("MS-CATALOG-PIN-MISMATCH", "catalogVersion-Pin (" + spec.catalogVersion + ") passt nicht zum geladenen Katalog (" + actual + "). Spec wurde gegen einen anderen Katalog geschrieben.");
196
- }
197
- }
280
+ validateCatalogPin(spec, catalog, push);
281
+ validateMeta(spec, push);
198
282
 
199
- if (!spec.meta || typeof spec.meta !== "object") {
200
- push("MS-META-MISSING", "meta fehlt.");
201
- } else {
202
- Object.keys(spec.meta).forEach((k) => {
203
- if (META_KEYS.indexOf(k) === -1) push("MS-META-KEY", 'meta: unbekannter Schluessel "' + k + '".');
204
- });
205
- if (TARGETS.indexOf(spec.meta.target) === -1)
206
- push("MS-META-TARGET", "meta.target fehlt oder wird nicht unterstuetzt (erlaubt: " + TARGETS.join(", ") + ").");
207
- }
208
-
209
- if (spec.globals !== undefined) {
210
- if (typeof spec.globals !== "object" || spec.globals === null || Array.isArray(spec.globals)) {
211
- push("MS-GLOBALS-OBJ", "globals muss ein Objekt sein.");
212
- } else {
213
- Object.keys(spec.globals).forEach((k) => {
214
- if (GLOBALS_KEYS.indexOf(k) === -1) push("MS-GLOBALS-KEY", 'globals: unbekannter Schluessel "' + k + '" (erlaubt: ' + GLOBALS_KEYS.join(", ") + ").");
215
- });
216
- }
217
- }
283
+ validateGlobals(spec, push, warnings);
218
284
 
219
285
  if (!Array.isArray(spec.motions) || spec.motions.length === 0) {
220
286
  push("MS-SPEC-MOTIONS", "motions fehlt oder ist leer.");
221
- return { ok: errors.length === 0, errors, errorCodes, deprecations };
287
+ return { ok: errors.length === 0, errors, errorCodes, deprecations, warnings };
222
288
  }
223
289
 
224
290
  const seen = {};
225
- spec.motions.forEach((m, i) => {
226
- const at = "motions[" + i + "]";
227
- if (typeof m !== "object" || m === null) { push("MS-MOTION-OBJ", at + " ist kein Objekt."); return; }
228
-
229
- Object.keys(m).forEach((k) => {
230
- if (MOTION_KEYS.indexOf(k) === -1) push("MS-MOTION-KEY", at + ': unbekannter Schluessel "' + k + '".');
231
- });
232
-
233
- if (typeof m.id !== "string" || !ID_RE.test(m.id))
234
- push("MS-ID-FORMAT", at + ": id fehlt oder verletzt das Format [A-Za-z0-9_-]{1,64}.");
235
- else if (seen[m.id]) push("MS-ID-DUP", at + ': id "' + m.id + '" ist nicht eindeutig.');
236
- else seen[m.id] = true;
291
+ spec.motions.forEach((m, i) => validateMotion(m, i, catalog, push, seen));
237
292
 
238
- if (!safeSelector(m.target))
239
- push("MS-TARGET-UNSAFE", at + ": target fehlt oder ist kein sicherer CSS-Selektor (verboten: Quotes, Backslash, {, }, ;, @, Steuerzeichen; max. 200 Zeichen).");
240
-
241
- /* --- Kern-Schutz: Primitive-Allow-List --- */
242
- if (typeof m.primitive !== "string" || !m.primitive) {
243
- push("MS-PRIM-MISSING", at + ": primitive fehlt.");
244
- } else if (!catalog[m.primitive]) {
245
- push("MS-PRIM-UNKNOWN", at + ': primitive "' + m.primitive + '" existiert nicht im Katalog. '
246
- + "Erlaubt sind ausschliesslich: " + Object.keys(catalog).sort().join(", ") + ".");
247
- } else {
248
- const prim = catalog[m.primitive];
249
- validateParams(prim, m.params || {}, at, push, false);
250
- if (m.trigger !== undefined) validateTrigger(m.trigger, at, push);
251
- if (m.responsive !== undefined) {
252
- push("MS-RESP-UNSUPPORTED", at + ": responsive wird in dieser specVersion noch nicht unterstuetzt (geplant fuer 0.2). Feld entfernen.");
253
- }
254
- }
255
- });
256
-
257
- return { ok: errors.length === 0, errors, errorCodes, deprecations };
293
+ return { ok: errors.length === 0, errors, errorCodes, deprecations, warnings };
258
294
  }
259
295
 
296
+ /* Phase B security: cap spec payload BEFORE any work (MS-INPUT-TOO-LARGE). A
297
+ * spec is small by nature (a few motions); anything larger is abuse/DoS. 64 KB
298
+ * is generous. Single source of truth — the MCP layer (register-tools.js)
299
+ * imports this instead of redefining it, so the cap can never drift. */
300
+ const MAX_SPEC_BYTES = 64 * 1024;
301
+
260
302
  module.exports = {
261
- validateSpec, safeSelector, ID_RE, deprecationsFor, unsafeToken,
303
+ validateSpec, safeSelector, ID_RE, deprecationsFor, unsafeToken, MAX_SPEC_BYTES,
262
304
  SPEC_VERSIONS, DEPRECATED_VERSIONS, TARGETS,
263
305
  TOP_KEYS, META_KEYS, GLOBALS_KEYS, MOTION_KEYS,
264
306
  };
@@ -22,18 +22,10 @@
22
22
  const { loadCatalog } = require("../compiler/catalog.js");
23
23
  const { compileSpec } = require("../compiler/compile.js");
24
24
 
25
- /* Heuristisches Intent->Primitiv-Mapping (bewusst konservativ; ein echtes
26
- * Modell kann das spaeter ersetzen hier geht es nur um Gap-Findung). */
27
- const RULES = [
28
- { re: /(stagger|nacheinander|gestaffelt|one after|cards?|karten|liste)/i, primitive: "staggerReveal", params: { from: { opacity: 0, y: 32 } } },
29
- { re: /(z(ä|ae)hl|counter|hochz(ä|ae)hl|count up|nummer|zahl)/i, primitive: "counterUp", params: {} },
30
- { re: /(parallax|tiefe|depth|layer|ebene)/i, primitive: "parallaxLayer", params: {} },
31
- { re: /(pin|fixier|sticky|festhalten|haften)/i, primitive: "pinnedSection", params: {} },
32
- { re: /(marquee|laufband|logo.?band|ticker|endlos)/i, primitive: "marquee", params: {} },
33
- { re: /(skalier|scale|zoom|gr(ö|oe)(ß|ss)er werden)/i, primitive: "scaleOnScroll", params: {} },
34
- { re: /(hover|maus|cursor|button)/i, primitive: "cssTransition", params: {} },
35
- { re: /(reveal|einblend|fade|erscheinen|appear|headline|(ü|ue)berschrift|gleitet|scroll)/i, primitive: "scrollReveal", params: { from: { opacity: 0, y: 48 } } },
36
- ];
25
+ /* Heuristisches Intent->Primitiv-Mapping aus der gemeinsamen Quelle (TASK-025).
26
+ * Reihenfolge = API: mapIntent liefert den ERSTEN Treffer. discover ignoriert das
27
+ * mitgelieferte target (es nimmt intent.target); params werden genutzt. */
28
+ const { KEYWORD_MAP: RULES } = require("../compiler/keyword-map.js");
37
29
 
38
30
  function mapIntent(what) {
39
31
  for (const r of RULES) if (r.re.test(what)) return r;
@@ -9,13 +9,12 @@
9
9
  * Tool-Logik — eine Quelle der Wahrheit (ADR-0001 Trust Boundary).
10
10
  */
11
11
  const { z } = require("zod");
12
- const { validateSpec } = require("../compiler/validate.js");
12
+ const { validateSpec, MAX_SPEC_BYTES } = require("../compiler/validate.js");
13
13
  const { compileSpec } = require("../compiler/compile.js");
14
14
  const telemetry = require("../router/telemetry.js");
15
15
 
16
- /* Phase B security: cap MCP input size BEFORE any work. A spec is small by
17
- * nature (a few motions); anything larger is abuse/DoS. 64 KB is generous. */
18
- const MAX_SPEC_BYTES = 64 * 1024;
16
+ /* Phase B security: cap MCP input size BEFORE any work. MAX_SPEC_BYTES is the
17
+ * single source in validate.js (MS-INPUT-TOO-LARGE) imported, never redefined. */
19
18
  function oversizeError(spec) {
20
19
  let bytes = Infinity;
21
20
  try { bytes = Buffer.byteLength(JSON.stringify(spec) || "", "utf8"); } catch { /* circular/garbage */ }
@@ -35,21 +34,8 @@ const AUTHORING_RULES = [
35
34
  "6. If no catalog primitive covers the request, do NOT improvise — tell the user which primitive is missing (this is an escalation signal).",
36
35
  ].join("\n");
37
36
 
38
- function catalogSummaryOf(catalog) {
39
- return Object.keys(catalog).sort().map((name) => {
40
- const p = catalog[name];
41
- return {
42
- name,
43
- version: p.version,
44
- purpose: p.purpose,
45
- engine: p.engine,
46
- cost: (p.performance && p.performance.cost) || 0,
47
- paramSchema: p.paramSchema || {},
48
- triggerDefaults: p.triggerDefaults || {},
49
- reducedMotionFallback: p.a11y && p.a11y.reducedMotionFallback,
50
- };
51
- });
52
- }
37
+ /* Katalog-Zusammenfassung aus der gemeinsamen Quelle (TASK-026). */
38
+ const { catalogSummary } = require("../compiler/catalog.js");
53
39
 
54
40
  /* Registriert die vier Tools. deps.getCatalog()/getCatVer() liefern stets den
55
41
  * AKTUELLEN Katalog (stdio kann via SIGHUP neu laden; der Worker liefert den
@@ -72,7 +58,7 @@ function registerMotionspecTools(server, deps) {
72
58
  catalogVersion: getCatVer(),
73
59
  specVersion: "1.0",
74
60
  authoringRules: AUTHORING_RULES,
75
- primitives: catalogSummaryOf(getCatalog()),
61
+ primitives: catalogSummary(getCatalog()),
76
62
  exampleSpec: {
77
63
  specVersion: "1.0",
78
64
  meta: { project: "example", target: "vanilla-gsap", createdWith: "mcp-host" },
@@ -46,13 +46,9 @@ function openAICompatClient(opts) {
46
46
 
47
47
  /* ---------------------------------------------------------------- */
48
48
 
49
- const KEYWORDS = [
50
- { re: /(stagger|nacheinander|gestaffelt|one after|cards?|karten)/i, primitive: "staggerReveal", target: ".features .card", params: { from: { opacity: 0, y: 32 } } },
51
- { re: /(parallax|tiefe|depth|layers?|ebenen)/i, primitive: "parallaxLayer", target: ".hero .bg", params: { yPercent: -25 } },
52
- { re: /(pin|fixier|sticky|fest)/i, primitive: "pinnedSection", target: ".showcase", params: { distance: "+=100%" } },
53
- { re: /(hover|button|maus|cursor)/i, primitive: "cssTransition", target: ".cta-button", params: { hoverValue: "translateY(-4px)" } },
54
- { re: /(reveal|einblend|fade|erscheinen|appear|headline|ueberschrift|überschrift|scroll)/i, primitive: "scrollReveal", target: ".hero h1", params: { from: { opacity: 0, y: 48 } } },
55
- ];
49
+ /* Keyword-Heuristik aus der gemeinsamen Quelle (TASK-025) — identisch zu
50
+ * discover.js, damit der Mock denselben Primitiv-Satz kennt wie der Gap-Report. */
51
+ const { KEYWORD_MAP: KEYWORDS } = require("../compiler/keyword-map.js");
56
52
 
57
53
  function mockClient(opts) {
58
54
  const o = opts || {};
@@ -48,17 +48,8 @@ const FEW_SHOT = [
48
48
  },
49
49
  ];
50
50
 
51
- function catalogSummary(catalog) {
52
- return Object.keys(catalog).sort().map((name) => {
53
- const p = catalog[name];
54
- return {
55
- name,
56
- purpose: p.purpose,
57
- params: p.paramSchema || {},
58
- triggerDefaults: p.triggerDefaults || {},
59
- };
60
- });
61
- }
51
+ /* Gemeinsame Katalog-Zusammenfassung (TASK-026) — eine Quelle in catalog.js. */
52
+ const { catalogSummary } = require("../compiler/catalog.js");
62
53
 
63
54
  function buildSystemPrompt(catalog) {
64
55
  return [
@@ -35,10 +35,24 @@ function extractJson(text) {
35
35
  catch { return null; }
36
36
  }
37
37
 
38
- /*
39
- * route(request, { client, catalog, target, noCache }) ->
40
- * { ok: true, spec, source: "cache"|"model"|"model-repaired", attempts, ms }
41
- * | { ok: false, escalate: true, reason, errors?, attempts, ms }
38
+ /**
39
+ * @typedef {Object} RouteResult
40
+ * @property {boolean} ok - true wenn eine gueltige Spec erzeugt wurde
41
+ * @property {Object} [spec] - die validierte MotionSpec, nur wenn ok=true
42
+ * @property {"cache"|"model"|"model-repaired"} [source] - Herkunft der Spec, nur wenn ok=true
43
+ * @property {boolean} [escalate] - true wenn kein Primitiv passt oder die Spec ungueltig blieb (ok=false)
44
+ * @property {string} [reason] - Eskalationsgrund, nur wenn ok=false
45
+ * @property {string[]} [errors] - Validierungsfehler des letzten Versuchs, falls vorhanden
46
+ * @property {number} attempts - Anzahl Modell-Versuche (0 bei Cache-Treffer)
47
+ * @property {number} ms - Dauer in Millisekunden
48
+ */
49
+ /**
50
+ * Routet eine natuerlichsprachliche Anfrage ueber Cache -> Modell -> Trust Boundary
51
+ * (mit genau einem Reparatur-Versuch) zu einer validierten MotionSpec. Jede Anfrage
52
+ * erzeugt einen Telemetrie-Messpunkt.
53
+ * @param {string} request - die Anfrage in natuerlicher Sprache
54
+ * @param {{client: {name: string, complete: Function}, catalog?: Object, target?: string, noCache?: boolean}} opts - client ist Pflicht
55
+ * @returns {Promise<RouteResult>}
42
56
  */
43
57
  async function route(request, opts) {
44
58
  const o = opts || {};
@@ -47,20 +47,29 @@ function log(event) {
47
47
  const safe = {};
48
48
  for (const k of Object.keys(event)) safe[k] = clamp(event[k]);
49
49
  safe.ts = new Date().toISOString();
50
- activeSink().append(safe);
50
+ try { activeSink().append(safe); } catch { /* Sink-Fehler nie an Caller propagieren */ }
51
51
  }
52
52
 
53
53
  async function summary() {
54
54
  const records = await activeSink().readAll();
55
55
  const byOutcome = {};
56
56
  const escalations = {};
57
+ const avgMs = {};
58
+ const totalAttempts = {};
59
+ let total = 0;
57
60
  for (const e of records) {
58
- byOutcome[e.outcome] = (byOutcome[e.outcome] || 0) + 1;
61
+ /* TASK-020: der AE-Sink liefert pro outcome EINE aggregierte Zeile mit count;
62
+ * File-/MemorySink liefern Einzel-Records ohne count -> jeweils 1 zaehlen. */
63
+ const n = e.count || 1;
64
+ total += n;
65
+ byOutcome[e.outcome] = (byOutcome[e.outcome] || 0) + n;
66
+ if (e.avg_ms !== undefined) avgMs[e.outcome] = Math.round(e.avg_ms || 0);
67
+ totalAttempts[e.outcome] = (totalAttempts[e.outcome] || 0) + (e.attempts || 0);
59
68
  /* #17: nur echte Eskalationen zaehlen fuer das Katalog-Wachstums-Signal */
60
69
  if (typeof e.outcome === "string" && e.outcome.startsWith("escalate") && NOISE_OUTCOMES.indexOf(e.outcome) === -1)
61
- escalations[e.outcome] = (escalations[e.outcome] || 0) + 1;
70
+ escalations[e.outcome] = (escalations[e.outcome] || 0) + n;
62
71
  }
63
- return { total: records.length, byOutcome, escalations };
72
+ return { total, byOutcome, escalations, avgMs, totalAttempts };
64
73
  }
65
74
 
66
75
  module.exports = { log, summary, setSink, getSink, NOISE_OUTCOMES };