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 +10 -4
- package/bin/motion.js +1 -1
- package/bin/sbom-check.js +90 -0
- package/package.json +12 -6
- package/schema/motionspec.schema.json +2 -2
- package/src/compiler/catalog.js +27 -1
- package/src/compiler/compile.js +31 -5
- package/src/compiler/keyword-map.js +26 -0
- package/src/compiler/validate.js +108 -66
- package/src/discover/discover.js +4 -12
- package/src/mcp/register-tools.js +6 -20
- package/src/router/clients.js +3 -7
- package/src/router/prompt.js +2 -11
- package/src/router/route.js +18 -4
- package/src/router/telemetry.js +13 -4
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 | **
|
|
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 #
|
|
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`
|
|
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/
|
|
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
|
+
"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 --
|
|
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": "
|
|
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",
|
package/src/compiler/catalog.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/src/compiler/compile.js
CHANGED
|
@@ -62,10 +62,35 @@ function withDefaults(schema, given) {
|
|
|
62
62
|
return out;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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 =
|
|
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 };
|
package/src/compiler/validate.js
CHANGED
|
@@ -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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/discover/discover.js
CHANGED
|
@@ -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
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
17
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
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:
|
|
61
|
+
primitives: catalogSummary(getCatalog()),
|
|
76
62
|
exampleSpec: {
|
|
77
63
|
specVersion: "1.0",
|
|
78
64
|
meta: { project: "example", target: "vanilla-gsap", createdWith: "mcp-host" },
|
package/src/router/clients.js
CHANGED
|
@@ -46,13 +46,9 @@ function openAICompatClient(opts) {
|
|
|
46
46
|
|
|
47
47
|
/* ---------------------------------------------------------------- */
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 || {};
|
package/src/router/prompt.js
CHANGED
|
@@ -48,17 +48,8 @@ const FEW_SHOT = [
|
|
|
48
48
|
},
|
|
49
49
|
];
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
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 [
|
package/src/router/route.js
CHANGED
|
@@ -35,10 +35,24 @@ function extractJson(text) {
|
|
|
35
35
|
catch { return null; }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 || {};
|
package/src/router/telemetry.js
CHANGED
|
@@ -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
|
-
|
|
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) +
|
|
70
|
+
escalations[e.outcome] = (escalations[e.outcome] || 0) + n;
|
|
62
71
|
}
|
|
63
|
-
return { total
|
|
72
|
+
return { total, byOutcome, escalations, avgMs, totalAttempts };
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
module.exports = { log, summary, setSink, getSink, NOISE_OUTCOMES };
|