motionspec 1.0.0
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/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/catalog-lock.js +46 -0
- package/bin/license-check.js +32 -0
- package/bin/motion.js +136 -0
- package/catalog.lock.json +243 -0
- package/package.json +68 -0
- package/primitives/counterUp.json +54 -0
- package/primitives/cssTransition.json +38 -0
- package/primitives/marquee.json +44 -0
- package/primitives/parallaxLayer.json +35 -0
- package/primitives/pinnedSection.json +30 -0
- package/primitives/scaleOnScroll.json +55 -0
- package/primitives/scrollReveal.json +43 -0
- package/primitives/staggerReveal.json +43 -0
- package/schema/motionspec.schema.json +56 -0
- package/src/compiler/catalog-semver.js +133 -0
- package/src/compiler/catalog.js +94 -0
- package/src/compiler/compile.js +149 -0
- package/src/compiler/validate.js +264 -0
- package/src/demo/build-demo.js +150 -0
- package/src/discover/discover.js +92 -0
- package/src/mcp/register-tools.js +163 -0
- package/src/mcp/server.mjs +46 -0
- package/src/router/cache.js +81 -0
- package/src/router/clients.js +98 -0
- package/src/router/prompt.js +99 -0
- package/src/router/route.js +108 -0
- package/src/router/telemetry-sink.js +53 -0
- package/src/router/telemetry.js +68 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "counterUp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"purpose": "Zahl zaehlt beim Einblenden in den Viewport von 0 bis zum Zielwert hoch.",
|
|
5
|
+
"output": "js",
|
|
6
|
+
"engine": "gsap.ScrollTrigger",
|
|
7
|
+
"paramSchema": {
|
|
8
|
+
"duration": {
|
|
9
|
+
"type": "number",
|
|
10
|
+
"default": 1.6,
|
|
11
|
+
"min": 0.2,
|
|
12
|
+
"max": 5
|
|
13
|
+
},
|
|
14
|
+
"ease": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"default": "power1.out",
|
|
17
|
+
"pattern": "^[A-Za-z0-9.()]{1,40}$"
|
|
18
|
+
},
|
|
19
|
+
"step": {
|
|
20
|
+
"type": "number",
|
|
21
|
+
"default": 1,
|
|
22
|
+
"min": 0.01,
|
|
23
|
+
"max": 1000
|
|
24
|
+
},
|
|
25
|
+
"locale": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"default": "de-DE",
|
|
28
|
+
"pattern": "^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"triggerDefaults": {
|
|
32
|
+
"start": "top 85%",
|
|
33
|
+
"once": true
|
|
34
|
+
},
|
|
35
|
+
"performance": {
|
|
36
|
+
"verified": true,
|
|
37
|
+
"lcpSafe": true,
|
|
38
|
+
"cost": 1,
|
|
39
|
+
"verifiedAt": "2026-06-12"
|
|
40
|
+
},
|
|
41
|
+
"a11y": {
|
|
42
|
+
"reducedMotionFallback": "instant-visible"
|
|
43
|
+
},
|
|
44
|
+
"template": "document.querySelectorAll({{target}}).forEach(function(el){ var end=parseFloat(el.getAttribute('data-count')||el.textContent); if(!isFinite(end)) end=0; var o={v:0}; gsap.to(o,{v:end,duration:{{params.duration}},ease:{{params.ease}},snap:{v:{{params.step}}},onUpdate:function(){el.textContent=o.v.toLocaleString({{params.locale}})},scrollTrigger:{trigger:el,start:{{trigger.start}},once:{{trigger.once}}}}); });",
|
|
45
|
+
"demo": {
|
|
46
|
+
"html": "<p class=\"counter d-counterUp\" data-count=\"12847\">12.847</p>\n<p class=\"counter d-counterUp\" data-count=\"99\">99</p>",
|
|
47
|
+
"params": {
|
|
48
|
+
"duration": 2,
|
|
49
|
+
"ease": "power2.out",
|
|
50
|
+
"step": 1,
|
|
51
|
+
"locale": "de-DE"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cssTransition",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"purpose": "Einfache Hover-Mikrointeraktion ganz ohne JavaScript.",
|
|
5
|
+
"output": "css",
|
|
6
|
+
"engine": "native-css",
|
|
7
|
+
"paramSchema": {
|
|
8
|
+
"property": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"default": "transform"
|
|
11
|
+
},
|
|
12
|
+
"hoverValue": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"default": "translateY(-4px)"
|
|
15
|
+
},
|
|
16
|
+
"duration": {
|
|
17
|
+
"type": "number",
|
|
18
|
+
"default": 0.25,
|
|
19
|
+
"min": 0.05,
|
|
20
|
+
"max": 1
|
|
21
|
+
},
|
|
22
|
+
"easing": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"default": "ease-out"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"triggerDefaults": {},
|
|
28
|
+
"performance": {
|
|
29
|
+
"verified": true,
|
|
30
|
+
"lcpSafe": true,
|
|
31
|
+
"cost": 0,
|
|
32
|
+
"verifiedAt": "2026-06-12"
|
|
33
|
+
},
|
|
34
|
+
"a11y": {
|
|
35
|
+
"reducedMotionFallback": "none-needed"
|
|
36
|
+
},
|
|
37
|
+
"template": "{{css target}} { transition: {{css params.property}} {{css params.duration}}s {{css params.easing}}; }\n{{css target}}:hover { {{css params.property}}: {{css params.hoverValue}}; }"
|
|
38
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "marquee",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"purpose": "Endlos laufendes horizontales Laufband — Inhalt als ZWEI identische Gruppen anlegen (nahtlose Schleife).",
|
|
5
|
+
"output": "css",
|
|
6
|
+
"engine": "native-css",
|
|
7
|
+
"paramSchema": {
|
|
8
|
+
"duration": {
|
|
9
|
+
"type": "number",
|
|
10
|
+
"default": 24,
|
|
11
|
+
"min": 4,
|
|
12
|
+
"max": 120
|
|
13
|
+
},
|
|
14
|
+
"gap": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"default": "3rem",
|
|
17
|
+
"pattern": "^[0-9]*\\.?[0-9]+(px|rem|em|vw|ch|%)$"
|
|
18
|
+
},
|
|
19
|
+
"direction": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"default": "normal",
|
|
22
|
+
"pattern": "^(normal|reverse)$"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"triggerDefaults": {},
|
|
26
|
+
"performance": {
|
|
27
|
+
"verified": true,
|
|
28
|
+
"lcpSafe": true,
|
|
29
|
+
"cost": 1,
|
|
30
|
+
"verifiedAt": "2026-06-12"
|
|
31
|
+
},
|
|
32
|
+
"a11y": {
|
|
33
|
+
"reducedMotionFallback": "static"
|
|
34
|
+
},
|
|
35
|
+
"template": "{{css target}} { display: flex; gap: {{css params.gap}}; overflow: hidden; }\n{{css target}} > * { flex-shrink: 0; animation: motion-marquee-{{css id}} {{css params.duration}}s linear infinite {{css params.direction}}; }\n@keyframes motion-marquee-{{css id}} { from { transform: translateX(0) } to { transform: translateX(-100%) } }",
|
|
36
|
+
"demo": {
|
|
37
|
+
"html": "<div class=\"marquee-track\"><div class=\"d-marquee\"><div><span style=\"padding:0 2rem\">ALPHA</span><span style=\"padding:0 2rem\">BETA</span><span style=\"padding:0 2rem\">GAMMA</span><span style=\"padding:0 2rem\">DELTA</span></div><div><span style=\"padding:0 2rem\">ALPHA</span><span style=\"padding:0 2rem\">BETA</span><span style=\"padding:0 2rem\">GAMMA</span><span style=\"padding:0 2rem\">DELTA</span></div></div></div>",
|
|
38
|
+
"params": {
|
|
39
|
+
"duration": 24,
|
|
40
|
+
"gap": "3rem",
|
|
41
|
+
"direction": "normal"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "parallaxLayer",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"purpose": "Tiefenversatz: Ebene bewegt sich beim Scrollen langsamer oder schneller als der Rest.",
|
|
5
|
+
"output": "js",
|
|
6
|
+
"engine": "gsap.ScrollTrigger",
|
|
7
|
+
"paramSchema": {
|
|
8
|
+
"yPercent": {
|
|
9
|
+
"type": "number",
|
|
10
|
+
"default": -20,
|
|
11
|
+
"min": -100,
|
|
12
|
+
"max": 100
|
|
13
|
+
},
|
|
14
|
+
"scrub": {
|
|
15
|
+
"type": "number",
|
|
16
|
+
"default": 1,
|
|
17
|
+
"min": 0,
|
|
18
|
+
"max": 4
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"triggerDefaults": {
|
|
22
|
+
"start": "top bottom",
|
|
23
|
+
"end": "bottom top"
|
|
24
|
+
},
|
|
25
|
+
"performance": {
|
|
26
|
+
"verified": true,
|
|
27
|
+
"lcpSafe": true,
|
|
28
|
+
"cost": 2,
|
|
29
|
+
"verifiedAt": "2026-06-12"
|
|
30
|
+
},
|
|
31
|
+
"a11y": {
|
|
32
|
+
"reducedMotionFallback": "instant-visible"
|
|
33
|
+
},
|
|
34
|
+
"template": "gsap.to({{target}}, { yPercent: {{params.yPercent}}, ease: 'none', scrollTrigger: { trigger: {{target}}, start: {{trigger.start}}, end: {{trigger.end}}, scrub: {{params.scrub}} } });"
|
|
35
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pinnedSection",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"purpose": "Sektion fixieren, waehrend der Inhalt hindurchscrollt.",
|
|
5
|
+
"output": "js",
|
|
6
|
+
"engine": "gsap.ScrollTrigger",
|
|
7
|
+
"paramSchema": {
|
|
8
|
+
"distance": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"default": "+=100%"
|
|
11
|
+
},
|
|
12
|
+
"pinSpacing": {
|
|
13
|
+
"type": "boolean",
|
|
14
|
+
"default": true
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"triggerDefaults": {
|
|
18
|
+
"start": "top top"
|
|
19
|
+
},
|
|
20
|
+
"performance": {
|
|
21
|
+
"verified": true,
|
|
22
|
+
"lcpSafe": true,
|
|
23
|
+
"cost": 2,
|
|
24
|
+
"verifiedAt": "2026-06-12"
|
|
25
|
+
},
|
|
26
|
+
"a11y": {
|
|
27
|
+
"reducedMotionFallback": "instant-visible"
|
|
28
|
+
},
|
|
29
|
+
"template": "ScrollTrigger.create({ trigger: {{target}}, start: {{trigger.start}}, end: {{params.distance}}, pin: true, pinSpacing: {{params.pinSpacing}} });"
|
|
30
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scaleOnScroll",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"purpose": "Element skaliert sich proportional zum Scroll-Fortschritt (GSAP ScrollTrigger scrub).",
|
|
5
|
+
"output": "js",
|
|
6
|
+
"engine": "gsap.ScrollTrigger",
|
|
7
|
+
"paramSchema": {
|
|
8
|
+
"fromScale": {
|
|
9
|
+
"type": "number",
|
|
10
|
+
"default": 0.8,
|
|
11
|
+
"min": 0.2,
|
|
12
|
+
"max": 1
|
|
13
|
+
},
|
|
14
|
+
"toScale": {
|
|
15
|
+
"type": "number",
|
|
16
|
+
"default": 1,
|
|
17
|
+
"min": 0.5,
|
|
18
|
+
"max": 2
|
|
19
|
+
},
|
|
20
|
+
"transformOrigin": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"default": "center center",
|
|
23
|
+
"pattern": "^[a-zA-Z0-9% ]{2,40}$"
|
|
24
|
+
},
|
|
25
|
+
"scrub": {
|
|
26
|
+
"type": "number",
|
|
27
|
+
"default": 1,
|
|
28
|
+
"min": 0,
|
|
29
|
+
"max": 4
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"triggerDefaults": {
|
|
33
|
+
"start": "top bottom",
|
|
34
|
+
"end": "center center"
|
|
35
|
+
},
|
|
36
|
+
"performance": {
|
|
37
|
+
"verified": true,
|
|
38
|
+
"lcpSafe": true,
|
|
39
|
+
"cost": 2,
|
|
40
|
+
"verifiedAt": "2026-06-12"
|
|
41
|
+
},
|
|
42
|
+
"a11y": {
|
|
43
|
+
"reducedMotionFallback": "instant-visible"
|
|
44
|
+
},
|
|
45
|
+
"template": "gsap.fromTo({{target}}, { scale: {{params.fromScale}}, transformOrigin: {{params.transformOrigin}} }, { scale: {{params.toScale}}, transformOrigin: {{params.transformOrigin}}, ease: \"none\", scrollTrigger: { trigger: {{target}}, start: {{trigger.start}}, end: {{trigger.end}}, scrub: {{params.scrub}} } });",
|
|
46
|
+
"demo": {
|
|
47
|
+
"html": "<div class=\"d-scaleOnScroll\" style=\"width:200px;height:200px;background:#6366f1;border-radius:12px;margin:60vh auto;\">Scale me</div>",
|
|
48
|
+
"params": {
|
|
49
|
+
"fromScale": 0.6,
|
|
50
|
+
"toScale": 1,
|
|
51
|
+
"transformOrigin": "center center",
|
|
52
|
+
"scrub": 1.5
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scrollReveal",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"purpose": "Element blendet beim Eintritt in den Viewport ein.",
|
|
5
|
+
"output": "js",
|
|
6
|
+
"engine": "gsap.ScrollTrigger",
|
|
7
|
+
"paramSchema": {
|
|
8
|
+
"from": {
|
|
9
|
+
"type": "transform",
|
|
10
|
+
"required": true
|
|
11
|
+
},
|
|
12
|
+
"duration": {
|
|
13
|
+
"type": "number",
|
|
14
|
+
"default": 0.8,
|
|
15
|
+
"min": 0.1,
|
|
16
|
+
"max": 3
|
|
17
|
+
},
|
|
18
|
+
"ease": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"default": "power3.out"
|
|
21
|
+
},
|
|
22
|
+
"stagger": {
|
|
23
|
+
"type": "number",
|
|
24
|
+
"default": 0,
|
|
25
|
+
"min": 0,
|
|
26
|
+
"max": 0.5
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"triggerDefaults": {
|
|
30
|
+
"start": "top 80%",
|
|
31
|
+
"once": true
|
|
32
|
+
},
|
|
33
|
+
"performance": {
|
|
34
|
+
"verified": true,
|
|
35
|
+
"lcpSafe": true,
|
|
36
|
+
"cost": 1,
|
|
37
|
+
"verifiedAt": "2026-06-12"
|
|
38
|
+
},
|
|
39
|
+
"a11y": {
|
|
40
|
+
"reducedMotionFallback": "instant-visible"
|
|
41
|
+
},
|
|
42
|
+
"template": "gsap.from({{target}}, Object.assign({{params.from}}, { duration: {{params.duration}}, ease: {{params.ease}}, stagger: {{params.stagger}}, scrollTrigger: { trigger: {{target}}, start: {{trigger.start}}, once: {{trigger.once}} } }));"
|
|
43
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "staggerReveal",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"purpose": "Mehrere Kind-Elemente gestaffelt nacheinander einblenden.",
|
|
5
|
+
"output": "js",
|
|
6
|
+
"engine": "gsap.ScrollTrigger",
|
|
7
|
+
"paramSchema": {
|
|
8
|
+
"from": {
|
|
9
|
+
"type": "transform",
|
|
10
|
+
"required": true
|
|
11
|
+
},
|
|
12
|
+
"duration": {
|
|
13
|
+
"type": "number",
|
|
14
|
+
"default": 0.6,
|
|
15
|
+
"min": 0.1,
|
|
16
|
+
"max": 3
|
|
17
|
+
},
|
|
18
|
+
"ease": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"default": "power2.out"
|
|
21
|
+
},
|
|
22
|
+
"stagger": {
|
|
23
|
+
"type": "number",
|
|
24
|
+
"default": 0.12,
|
|
25
|
+
"min": 0.02,
|
|
26
|
+
"max": 0.6
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"triggerDefaults": {
|
|
30
|
+
"start": "top 85%",
|
|
31
|
+
"once": true
|
|
32
|
+
},
|
|
33
|
+
"performance": {
|
|
34
|
+
"verified": true,
|
|
35
|
+
"lcpSafe": true,
|
|
36
|
+
"cost": 1,
|
|
37
|
+
"verifiedAt": "2026-06-12"
|
|
38
|
+
},
|
|
39
|
+
"a11y": {
|
|
40
|
+
"reducedMotionFallback": "instant-visible"
|
|
41
|
+
},
|
|
42
|
+
"template": "gsap.from({{target}}, Object.assign({{params.from}}, { duration: {{params.duration}}, ease: {{params.ease}}, stagger: {{params.stagger}}, scrollTrigger: { trigger: {{target}}, start: {{trigger.start}}, once: {{trigger.once}} } }));"
|
|
43
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://motionspec.dev/schema/1.0/motionspec.schema.json",
|
|
4
|
+
"title": "MotionSpec",
|
|
5
|
+
"description": "Formale Zwischensprache fuer Web-Motion. FROZEN v1 (ADR-0001, 2026-06-15). Field shape is the published, stable contract. specVersion \"1.0\" is stable; \"0.1\" deprecated, accepted until v1.2. Static contract; the dynamic check (Primitive-Allow-List, per-primitive params, deprecation note) is the Trust-Boundary-Validator in compiler/validate.js. responsive is a known-but-deferred key (rejected at runtime until 0.2).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["specVersion", "meta", "motions"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"specVersion": { "type": "string", "enum": ["1.0", "0.1"] },
|
|
11
|
+
"catalogVersion": { "type": "string", "pattern": "^[0-9a-f]{16}$", "description": "Optional reproducibility pin (ADR-0001 D2): the 16-char catalog hash this spec was authored against. Rejected at runtime if it does not match the loaded catalog." },
|
|
12
|
+
"meta": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"required": ["target"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"project": { "type": "string" },
|
|
18
|
+
"target": { "type": "string", "enum": ["vanilla-gsap"] },
|
|
19
|
+
"createdWith": { "type": "string" }
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"globals": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"additionalProperties": false,
|
|
25
|
+
"properties": {
|
|
26
|
+
"respectReducedMotion": { "type": "boolean" },
|
|
27
|
+
"defaultEase": { "type": "string" }
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"motions": {
|
|
31
|
+
"type": "array",
|
|
32
|
+
"minItems": 1,
|
|
33
|
+
"items": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"additionalProperties": false,
|
|
36
|
+
"required": ["id", "primitive", "target"],
|
|
37
|
+
"properties": {
|
|
38
|
+
"id": { "type": "string", "minLength": 1 },
|
|
39
|
+
"primitive": { "type": "string", "description": "Muss ein Name aus dem Primitive-Katalog sein. Allow-List, zur Laufzeit geprueft." },
|
|
40
|
+
"target": { "type": "string", "minLength": 1 },
|
|
41
|
+
"params": { "type": "object" },
|
|
42
|
+
"trigger": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"properties": {
|
|
46
|
+
"start": { "type": "string" },
|
|
47
|
+
"end": { "type": "string" },
|
|
48
|
+
"once": { "type": "boolean" }
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"responsive": { "type": "object" }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Catalog SemVer diff-gate — makes ADR-0001 D2 machine-checkable.
|
|
4
|
+
* ----------------------------------------------------------------------
|
|
5
|
+
* D2 says catalog changes follow SemVer:
|
|
6
|
+
* patch = template/purpose fix (no contract change)
|
|
7
|
+
* minor = new OPTIONAL param, or a new primitive
|
|
8
|
+
* major = removed/renamed primitive, removed param, type change,
|
|
9
|
+
* a param made required, or TIGHTENED bounds (min raised / max
|
|
10
|
+
* lowered / pattern added or changed)
|
|
11
|
+
*
|
|
12
|
+
* The meta-schema only checks the version FORMAT. This module checks the
|
|
13
|
+
* version DIRECTION: given the released baseline (catalog.lock.json) and the
|
|
14
|
+
* current catalog, it classifies each primitive's change and asserts the
|
|
15
|
+
* version bump is at least the required class. A tightened bound shipped as a
|
|
16
|
+
* "patch" is a violation the test will catch.
|
|
17
|
+
*
|
|
18
|
+
* Baseline semantics: the lockfile is the LAST RELEASED state. You bump a
|
|
19
|
+
* primitive's version in its .json when you change it; the lock stays put
|
|
20
|
+
* until you deliberately relock at release time (`npm run catalog-lock`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
function snapshotPrimitive(p) {
|
|
24
|
+
const params = {};
|
|
25
|
+
const ps = p.paramSchema || {};
|
|
26
|
+
Object.keys(ps).sort().forEach((k) => {
|
|
27
|
+
const d = ps[k] || {};
|
|
28
|
+
params[k] = {
|
|
29
|
+
type: d.type,
|
|
30
|
+
required: !!d.required,
|
|
31
|
+
min: typeof d.min === "number" ? d.min : null,
|
|
32
|
+
max: typeof d.max === "number" ? d.max : null,
|
|
33
|
+
pattern: typeof d.pattern === "string" ? d.pattern : null,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
return { version: p.version, output: p.output, params };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function snapshotCatalog(catalog) {
|
|
40
|
+
const out = {};
|
|
41
|
+
Object.keys(catalog).sort().forEach((name) => { out[name] = snapshotPrimitive(catalog[name]); });
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseSemver(v) {
|
|
46
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v));
|
|
47
|
+
if (!m) return null;
|
|
48
|
+
return { major: +m[1], minor: +m[2], patch: +m[3] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cmpSemver(a, b) {
|
|
52
|
+
const x = parseSemver(a), y = parseSemver(b);
|
|
53
|
+
if (!x || !y) return null;
|
|
54
|
+
if (x.major !== y.major) return x.major - y.major;
|
|
55
|
+
if (x.minor !== y.minor) return x.minor - y.minor;
|
|
56
|
+
return x.patch - y.patch;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Classify the contract change of ONE primitive between baseline and current.
|
|
60
|
+
* Returns { class: "major"|"minor"|"patch"|"none", reasons: [] }. */
|
|
61
|
+
function classifyChange(prev, curr) {
|
|
62
|
+
const reasons = [];
|
|
63
|
+
let cls = "none";
|
|
64
|
+
const bump = (level, why) => {
|
|
65
|
+
reasons.push(why);
|
|
66
|
+
const rank = { none: 0, patch: 1, minor: 2, major: 3 };
|
|
67
|
+
if (rank[level] > rank[cls]) cls = level;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (prev.output !== curr.output) bump("major", "output changed " + prev.output + "->" + curr.output);
|
|
71
|
+
|
|
72
|
+
const prevKeys = Object.keys(prev.params);
|
|
73
|
+
const currKeys = Object.keys(curr.params);
|
|
74
|
+
|
|
75
|
+
prevKeys.forEach((k) => {
|
|
76
|
+
if (!(k in curr.params)) { bump("major", "param removed: " + k); return; }
|
|
77
|
+
const a = prev.params[k], b = curr.params[k];
|
|
78
|
+
if (a.type !== b.type) bump("major", "param type changed: " + k + " " + a.type + "->" + b.type);
|
|
79
|
+
if (!a.required && b.required) bump("major", "param made required: " + k);
|
|
80
|
+
if (a.min !== null && (b.min === null || b.min > a.min)) bump("major", "min raised: " + k + " " + a.min + "->" + b.min);
|
|
81
|
+
if (a.max !== null && (b.max === null || b.max < a.max)) bump("major", "max lowered: " + k + " " + a.max + "->" + b.max);
|
|
82
|
+
if (a.pattern === null && b.pattern !== null) bump("major", "pattern added: " + k);
|
|
83
|
+
else if (a.pattern !== null && b.pattern !== a.pattern) bump("major", "pattern changed: " + k);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
currKeys.forEach((k) => {
|
|
87
|
+
if (!(k in prev.params)) {
|
|
88
|
+
if (curr.params[k].required) bump("major", "new REQUIRED param: " + k);
|
|
89
|
+
else bump("minor", "new optional param: " + k);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Any non-structural diff (template/purpose) is detected by the caller via
|
|
94
|
+
// the catalogVersion hash; here, if nothing above fired, it's at most patch.
|
|
95
|
+
return { class: cls, reasons };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Diff baseline lock vs current catalog snapshot.
|
|
99
|
+
* Returns { violations: [{name, msg}], added: [], removed: [] }. */
|
|
100
|
+
function diffCatalog(lock, current) {
|
|
101
|
+
const violations = [];
|
|
102
|
+
const added = [];
|
|
103
|
+
const removed = [];
|
|
104
|
+
|
|
105
|
+
Object.keys(lock).forEach((name) => {
|
|
106
|
+
if (!(name in current)) {
|
|
107
|
+
removed.push(name);
|
|
108
|
+
violations.push({ name, msg: 'primitive "' + name + '" REMOVED — breaking (major) catalog change. Acknowledge: update catalog.lock.json and note it as breaking.' });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
Object.keys(current).forEach((name) => {
|
|
113
|
+
if (!(name in lock)) { added.push(name); return; } // new primitive = minor, allowed
|
|
114
|
+
const prev = lock[name], curr = current[name];
|
|
115
|
+
const { class: cls, reasons } = classifyChange(prev, curr);
|
|
116
|
+
const c = cmpSemver(curr.version, prev.version);
|
|
117
|
+
|
|
118
|
+
if (cls === "major" && !(parseSemver(curr.version).major > parseSemver(prev.version).major)) {
|
|
119
|
+
violations.push({ name, msg: '"' + name + '" needs a MAJOR bump (was ' + prev.version + ", now " + curr.version + "). Reason(s): " + reasons.join("; ") });
|
|
120
|
+
} else if (cls === "minor") {
|
|
121
|
+
const pv = parseSemver(prev.version), cv = parseSemver(curr.version);
|
|
122
|
+
const okMinor = cv.major > pv.major || (cv.major === pv.major && cv.minor > pv.minor);
|
|
123
|
+
if (!okMinor) violations.push({ name, msg: '"' + name + '" needs at least a MINOR bump (was ' + prev.version + ", now " + curr.version + "). Reason(s): " + reasons.join("; ") });
|
|
124
|
+
} else if (cls === "patch") {
|
|
125
|
+
if (c !== null && c <= 0) violations.push({ name, msg: '"' + name + '" changed but version not bumped (still ' + curr.version + ")." });
|
|
126
|
+
}
|
|
127
|
+
// class "none": no contract change; version may stay or move, no constraint.
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return { violations, added, removed };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { snapshotPrimitive, snapshotCatalog, classifyChange, diffCatalog, parseSemver, cmpSemver };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Primitive-Katalog — Laden + Meta-Schema + Versionierung
|
|
4
|
+
*
|
|
5
|
+
* Audit-Befund #8 (2026-06-12): Primitive-Dateien sind selbst eine
|
|
6
|
+
* Vertrauensgrenze. Jede Datei wird beim Laden gegen ein Meta-Schema
|
|
7
|
+
* geprueft (fail-closed): Groessen-Limits, Pflichtfelder, erlaubte
|
|
8
|
+
* paramSchema-Typen und ReDoS-Screening fuer pattern-Felder.
|
|
9
|
+
*
|
|
10
|
+
* catalogVersion = Hash ueber alle Primitiv-Definitionen -> Cache-Schluessel
|
|
11
|
+
* invalidiert automatisch bei jeder Katalog-Aenderung (auch Patches).
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const crypto = require("crypto");
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
const MAX_FILE_BYTES = 64 * 1024;
|
|
19
|
+
const MAX_TEMPLATE_CHARS = 4096;
|
|
20
|
+
const MAX_PATTERN_CHARS = 100;
|
|
21
|
+
const NAME_RE = /^[A-Za-z][A-Za-z0-9]{1,40}$/;
|
|
22
|
+
const PARAM_TYPES = ["number", "string", "boolean", "transform"];
|
|
23
|
+
const OUTPUTS = ["js", "css"];
|
|
24
|
+
/* ReDoS-Heuristik: verschachtelte Quantifizierer wie (a+)+ oder (a*)* */
|
|
25
|
+
const NESTED_QUANTIFIER_RE = /\([^)]*[+*][^)]*\)\s*[+*{]/;
|
|
26
|
+
|
|
27
|
+
function screenPattern(pat, where, fail) {
|
|
28
|
+
if (typeof pat !== "string") fail(where + ": pattern muss ein String sein.");
|
|
29
|
+
if (pat.length > MAX_PATTERN_CHARS) fail(where + ": pattern zu lang (>" + MAX_PATTERN_CHARS + ").");
|
|
30
|
+
if (NESTED_QUANTIFIER_RE.test(pat)) fail(where + ": pattern enthaelt verschachtelte Quantifizierer (ReDoS-Risiko).");
|
|
31
|
+
try { new RegExp(pat); } catch (e) { fail(where + ": pattern ist kein gueltiger regulaerer Ausdruck (" + e.message + ")."); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function checkPrimitive(p, file) {
|
|
35
|
+
const fail = (msg) => { throw new Error("Katalog abgelehnt (" + file + "): " + msg); };
|
|
36
|
+
if (typeof p !== "object" || p === null || Array.isArray(p)) fail("kein Objekt");
|
|
37
|
+
if (!NAME_RE.test(p.name || "")) fail("name fehlt oder verletzt " + NAME_RE);
|
|
38
|
+
if (typeof p.version !== "string" || !/^\d+\.\d+\.\d+$/.test(p.version)) fail("version fehlt oder ist kein SemVer.");
|
|
39
|
+
if (OUTPUTS.indexOf(p.output) === -1) fail('output muss "js" oder "css" sein.');
|
|
40
|
+
if (typeof p.template !== "string" || !p.template) fail("template fehlt.");
|
|
41
|
+
if (p.template.length > MAX_TEMPLATE_CHARS) fail("template zu lang (>" + MAX_TEMPLATE_CHARS + ").");
|
|
42
|
+
const ps = p.paramSchema || {};
|
|
43
|
+
if (typeof ps !== "object" || ps === null) fail("paramSchema muss ein Objekt sein.");
|
|
44
|
+
Object.keys(ps).forEach((k) => {
|
|
45
|
+
const def = ps[k];
|
|
46
|
+
if (typeof def !== "object" || def === null) fail('paramSchema.' + k + " muss ein Objekt sein.");
|
|
47
|
+
if (PARAM_TYPES.indexOf(def.type) === -1) fail('paramSchema.' + k + ".type unzulaessig (erlaubt: " + PARAM_TYPES.join(", ") + ").");
|
|
48
|
+
if (def.pattern !== undefined) screenPattern(def.pattern, "paramSchema." + k, fail);
|
|
49
|
+
});
|
|
50
|
+
return p;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Phase C / C3: reiner Katalog-Bau aus bereits geparsten Primitiv-Objekten
|
|
54
|
+
* (kein fs) — damit der Cloudflare Worker den gebundelten Katalog mit exakt
|
|
55
|
+
* derselben Validierung + demselben Pin erzeugt wie loadCatalog(). Reihenfolge
|
|
56
|
+
* egal: catalogVersion() sortiert nach Namen. */
|
|
57
|
+
function buildCatalog(primitives) {
|
|
58
|
+
const cat = {};
|
|
59
|
+
primitives.forEach((p, i) => {
|
|
60
|
+
checkPrimitive(p, (p && p.name) ? p.name : ("#" + i));
|
|
61
|
+
if (cat[p.name]) throw new Error("Doppeltes Primitiv: " + p.name);
|
|
62
|
+
cat[p.name] = p;
|
|
63
|
+
});
|
|
64
|
+
return cat;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadCatalog(dir) {
|
|
68
|
+
const d = dir || path.join(__dirname, "..", "..", "primitives");
|
|
69
|
+
const cat = {};
|
|
70
|
+
fs.readdirSync(d)
|
|
71
|
+
.filter((f) => f.endsWith(".json"))
|
|
72
|
+
.sort()
|
|
73
|
+
.forEach((f) => {
|
|
74
|
+
const full = path.join(d, f);
|
|
75
|
+
const stat = fs.statSync(full);
|
|
76
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error("Katalog abgelehnt (" + f + "): Datei zu gross (>" + MAX_FILE_BYTES + " Bytes).");
|
|
77
|
+
let p;
|
|
78
|
+
try { p = JSON.parse(fs.readFileSync(full, "utf8")); }
|
|
79
|
+
catch (e) { throw new Error("Katalog abgelehnt (" + f + "): kein gueltiges JSON (" + e.message + ").", { cause: e }); }
|
|
80
|
+
checkPrimitive(p, f);
|
|
81
|
+
if (cat[p.name]) throw new Error("Doppeltes Primitiv: " + p.name);
|
|
82
|
+
cat[p.name] = p;
|
|
83
|
+
});
|
|
84
|
+
return cat;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function catalogVersion(catalog) {
|
|
88
|
+
const stable = JSON.stringify(
|
|
89
|
+
Object.keys(catalog).sort().map((k) => catalog[k])
|
|
90
|
+
);
|
|
91
|
+
return crypto.createHash("sha256").update(stable).digest("hex").slice(0, 16);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { loadCatalog, buildCatalog, catalogVersion, checkPrimitive };
|