motionspec 1.0.3 → 1.0.5

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
@@ -1,6 +1,6 @@
1
1
  # MotionSpec
2
2
 
3
- A formal intermediate language for scroll-driven web motion. A small model translates a request into a **schema-validated JSON spec**; a **deterministic compiler** turns that spec into GSAP/CSS — hallucination-proof by construction, with an enforced `prefers-reduced-motion` fallback and a performance budget.
3
+ A formal intermediate language for scroll-driven web motion. A small model translates a request into a **schema-validated JSON spec**; a **deterministic compiler** turns that spec into GSAP/CSS — injection-proof & catalog-validated by construction, with an enforced `prefers-reduced-motion` fallback and a performance budget.
4
4
 
5
5
  The thesis: **capability lives in the catalog, not the model.** A bigger model can write more elaborate specs, but it can never emit a primitive, parameter, or selector the Trust Boundary hasn't approved. The compiler trusts only what passes.
6
6
 
@@ -20,17 +20,17 @@ request ──> Routing (small model, Stage A) ──> MotionSpec (JSON)
20
20
 
21
21
  | | |
22
22
  |---|---|
23
- | Version | **v1.0.3** · schema frozen at spec v1 (ADR-0001, signed) |
23
+ | Version | **v1.0.4** · 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 | **full suite 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
- | Coverage | **98.4% lines / 95.9% functions** of `src/` + `worker/` (CI gate fails under 90%) |
29
- | Machine audit | 7.2/10 (Production-Ready), independently re-audited |
28
+ | Coverage | **≈98% lines / ≈96% functions / ≈82% branches** of `src/` + `worker/` (CI gate: 90/90/75) |
29
+ | Machine audit | internal re-audits ≈8.7 (engine) see `docs/analysis/` |
30
30
  | First client | CHS Computer — live on Vercel |
31
- | Hosted MCP | **live** — private, secret-gated Cloudflare Worker · per-minute cron canary + external heartbeat (synthetic error → email in <5 min, proven) · gated `/dashboard` |
31
+ | Hosted MCP | **live** — private, per-key gated (KV registry) Cloudflare Worker · per-minute cron canary + external heartbeat (synthetic error → email in <5 min, proven) · gated `/dashboard` |
32
32
 
33
- Schema v1 is frozen: `specVersion "1.0"` is the stable public contract; `"0.1"` is deprecated and accepted until v1.2. The `[MS-XXX]` error-code registry is public API. Phase B (test & security) is closed — CI is green on the x86 runner incl. Playwright `e2e` for every primitive (all jobs pass on every push to `main` — see the repo Actions tab; the x86 runner is the source of truth). **Phase C (observability + hosted MCP) is live and gate-proven**: the MCP server runs as a private, secret-gated Cloudflare Worker; a per-minute cron canary runs `validate→compile` and pings an external heartbeat, so a failure alerts by email within 5 minutes (verified on real infra). A gated `/dashboard` renders live telemetry.
33
+ Schema v1 is frozen: `specVersion "1.0"` is the stable public contract; `"0.1"` is deprecated and accepted until v1.2. The `[MS-XXX]` error-code registry is public API. Phase B (test & security) is closed — CI is green on the x86 runner incl. Playwright `e2e` for every primitive (all jobs pass on every push to `main` — CI status available on request). **Phase C (observability + hosted MCP) is live and gate-proven**: the MCP server runs as a private, per-key gated (KV registry) Cloudflare Worker; a per-minute cron canary runs `validate→compile` and pings an external heartbeat, so a failure alerts by email within 5 minutes (verified on real infra). A gated `/dashboard` renders live telemetry.
34
34
 
35
35
  ## Install
36
36
 
@@ -47,11 +47,15 @@ claude mcp add motionspec -- npx motionspec
47
47
 
48
48
  Listed on the MCP Registry as `io.github.MasterPlayspots/motionspec`.
49
49
 
50
- ## Quickstart (from a clone of the repo)
50
+ > **Source:** The npm package and the hosted MCP are public. The source
51
+ > repository is **private (access by arrangement)**. Sections below that
52
+ > reference a repo clone assume granted source access.
53
+
54
+ ## Quickstart (with source access)
51
55
 
52
56
  ```bash
53
57
  npm ci # install (0 runtime deps beyond MCP SDK + zod)
54
- npm test # 151 tests: validator, compiler-golden, router, fuzz, schema parity, worker contract
58
+ npm test # full suite: validator, compiler-golden, router, fuzz, schema parity, worker contract
55
59
  node bin/motion.js catalog # primitives + catalog version (16-char hash)
56
60
  node bin/motion.js compile examples/hero.motionspec.json
57
61
  node bin/motion.js pipeline "Hero headline fades in, cards staggered" --mock
@@ -87,7 +91,7 @@ Tools: `motion_catalog` (primitives + authoring rules) · `motion_validate` (fai
87
91
 
88
92
  1. **Allow-list** — a primitive not in the catalog never reaches the compiler.
89
93
  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.
94
+ 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
95
  4. **Determinism** — same spec ⇒ identical code (golden-file tests).
92
96
  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
97
  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 +106,7 @@ src/compiler/ catalog.js · catalog-semver.js · validate.js (Trust Boundary)
102
106
  src/router/ prompt.js · clients.js (openai-compat + mock) · route.js · cache.js · telemetry.js
103
107
  src/mcp/ server.mjs (4 tools, fail-closed, input-capped)
104
108
  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)
109
+ test/ test suite incl. injection attacks, fuzz, golden files, schema parity, worker contract; test/e2e (Playwright)
106
110
  docs/ ADR-0001 (schema freeze) · ROADMAP_TO_10 · PHASE_A_REAUDIT · CLAUDE_CODE_HANDOFF
107
111
  ```
108
112
 
@@ -113,7 +117,17 @@ Concept, research, pitch, business docs and the CHS client site live in the sibl
113
117
  - `docs/CLAUDE_CODE_HANDOFF.md` — **start here when continuing in Claude Code (terminal).**
114
118
  - `docs/ROADMAP_TO_10_2026-06-15.md` — the production path to a proven 10/10 (Phases A–G, anti-goals, machine gates).
115
119
  - `docs/adr/0001-schema-freeze-v1.md` — the frozen v1 contract and why.
116
- - `docs/PHASE_A_REAUDIT_2026-06-15.md` — independent re-audit findings + fixes.
120
+ - `docs/PHASE_A_REAUDIT_2026-06-15.md` — internal re-audit findings + fixes.
121
+
122
+ ## Contributing
123
+
124
+ The source repository is currently **private (access by arrangement)**; the
125
+ contributor guide and issue templates referenced below become available once
126
+ source access is granted.
127
+
128
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the
129
+ gate-driven PR checklist, commit conventions, and a short architecture tour. Bug
130
+ reports and feature requests have issue templates under `.github/ISSUE_TEMPLATE/`.
117
131
 
118
132
  ## License
119
133
 
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /*
4
+ * assert-canonical.js — Canonical-Guard (ops/canonical-guard).
5
+ * ------------------------------------------------------------
6
+ * Faellt FAIL-CLOSED (exit 1), wenn aus einem nicht-kanonischen Klon
7
+ * publiziert/deployed werden soll. Adressiert den wiederkehrenden Fehler
8
+ * "aus dem falschen / iCloud-gesyncten Klon veroeffentlicht" (siehe CANONICAL.md).
9
+ *
10
+ * Zwei unabhaengige Pruefungen, OR-verknuepft (eine reicht zum Blockieren):
11
+ * 1) git remote.origin.url zeigt NICHT auf MasterPlayspots/motionspec.
12
+ * 2) Arbeitsverzeichnis (pwd -P / realpath) liegt in einem iCloud-/Sync-Pfad:
13
+ * "Library/Mobile Documents", "/Desktop/" oder "/Documents/".
14
+ *
15
+ * Eingehaengt in `prepublishOnly` (vor allen anderen Checks) und in
16
+ * `predeploy:worker`. Reine Pruef-Logik ist als evaluateCanonical() exportiert,
17
+ * damit sie ohne echtes git/cwd unit-testbar ist (test/canonical-guard.test.js).
18
+ */
19
+ const { execFileSync } = require("node:child_process");
20
+ const fs = require("node:fs");
21
+
22
+ const CANONICAL_SLUG = "MasterPlayspots/motionspec";
23
+ /* iCloud-/Sync-Marker im Pfad. "Library/Mobile Documents" = iCloud Drive;
24
+ * Desktop/Documents werden auf macOS standardmaessig nach iCloud gespiegelt. */
25
+ const ICLOUD_MARKERS = ["Library/Mobile Documents", "/Desktop/", "/Documents/"];
26
+
27
+ /**
28
+ * Reine Entscheidungsfunktion — keine Seiteneffekte.
29
+ * @param {{ cwd: string, remoteUrl: string }} input
30
+ * @returns {{ ok: boolean, reasons: string[] }}
31
+ */
32
+ function evaluateCanonical({ cwd, remoteUrl }) {
33
+ const reasons = [];
34
+
35
+ // (1) Remote pruefen. Akzeptiert https / git+https / git@ host, mit/ohne .git.
36
+ const norm = String(remoteUrl || "").trim().replace(/\/+$/, "");
37
+ const slugRe = new RegExp("[:/]" + CANONICAL_SLUG + "(?:\\.git)?$", "i");
38
+ if (!slugRe.test(norm)) {
39
+ reasons.push(
40
+ "remote.origin.url ist nicht " + CANONICAL_SLUG +
41
+ " (gefunden: " + (norm || "<leer>") + ")"
42
+ );
43
+ }
44
+
45
+ // (2) cwd auf iCloud-/Sync-Marker pruefen. Trailing-Slash anfuegen, damit ein
46
+ // Verzeichnis, das exakt auf "/Desktop" endet, ebenfalls greift.
47
+ const haystack = String(cwd || "") + "/";
48
+ const hit = ICLOUD_MARKERS.find((m) => haystack.includes(m));
49
+ if (hit) {
50
+ reasons.push('Arbeitsverzeichnis liegt in iCloud-/Sync-Pfad ("' + hit + '"): ' + cwd);
51
+ }
52
+
53
+ return { ok: reasons.length === 0, reasons };
54
+ }
55
+
56
+ /* --- CLI-Wrapper: liest echtes cwd + git-Remote, exit 1 bei FAIL ------------ */
57
+ function readRemoteUrl() {
58
+ try {
59
+ return execFileSync("git", ["config", "--get", "remote.origin.url"], {
60
+ encoding: "utf8",
61
+ }).trim();
62
+ } catch {
63
+ return ""; // kein Remote => faellt unter (1) durch
64
+ }
65
+ }
66
+
67
+ function main() {
68
+ // pwd -P : Symlinks aufloesen, damit ein iCloud-Pfad nicht hinter einem
69
+ // Symlink versteckt werden kann.
70
+ let cwd;
71
+ try { cwd = fs.realpathSync(process.cwd()); } catch { cwd = process.cwd(); }
72
+ const remoteUrl = readRemoteUrl();
73
+
74
+ const { ok, reasons } = evaluateCanonical({ cwd, remoteUrl });
75
+ if (!ok) {
76
+ console.error("Canonical-Guard FAIL — nicht aus diesem Klon publishen/deployen:");
77
+ reasons.forEach((r) => console.error(" - " + r));
78
+ console.error("Kanonisch ist " + CANONICAL_SLUG + " unter ~/dev/* (siehe CANONICAL.md).");
79
+ process.exit(1);
80
+ }
81
+ console.error("Canonical-Guard OK — " + CANONICAL_SLUG + " @ " + cwd);
82
+ }
83
+
84
+ if (require.main === module) main();
85
+
86
+ module.exports = { evaluateCanonical, CANONICAL_SLUG, ICLOUD_MARKERS };
package/bin/forge.js ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /*
4
+ * Forge-CLI (Baustein 4) — Subcommands wie bin/motion.js.
5
+ * ----------------------------------------------------------------------
6
+ * node bin/forge.js queue Priorisierte Bau-Queue (aus forge.gaps.json)
7
+ * node bin/forge.js next Top-Luecke
8
+ * node bin/forge.js generate "<gapKey>" [--mock] [--emit-name]
9
+ * Kandidat aus einer Luecke erzeugen
10
+ * node bin/forge.js verify <name> promote-gate verify-only
11
+ *
12
+ * Luecken-Quelle: forge.gaps.json (optional) — entweder ein Array von discover-Gaps
13
+ * [{ what, target }] ODER { gaps:[...], telemetry:[{pattern,count}] }.
14
+ * Ohne --mock braucht generate ein Modell (MOTION_API_KEY / MOTION_MODEL).
15
+ *
16
+ * ANTI-GOALS: erzeugt NUR Kandidaten + PR-Material; promotet/published/deployt NIE.
17
+ */
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+
21
+ const ROOT = path.join(__dirname, "..");
22
+ const { prioritize } = require("../src/forge/prioritize.js");
23
+ const { generate } = require("../src/forge/generate.js");
24
+ const { gate } = require("./promote-gate.js");
25
+ const { mockForgeClient, openAICompatClient } = require("../src/router/clients.js");
26
+
27
+ function loadGaps() {
28
+ const f = path.join(ROOT, "forge.gaps.json");
29
+ if (!fs.existsSync(f)) return { gaps: [], telemetry: [] };
30
+ let data;
31
+ try { data = JSON.parse(fs.readFileSync(f, "utf8")); }
32
+ catch (e) { console.error("forge.gaps.json kein gueltiges JSON: " + e.message); return { gaps: [], telemetry: [] }; }
33
+ if (Array.isArray(data)) return { gaps: data, telemetry: [] };
34
+ return { gaps: data.gaps || [], telemetry: data.telemetry || [] };
35
+ }
36
+
37
+ function queue() {
38
+ const { gaps, telemetry } = loadGaps();
39
+ return prioritize(gaps, telemetry);
40
+ }
41
+
42
+ async function main() {
43
+ const argv = process.argv.slice(2);
44
+ const cmd = argv[0];
45
+ const flags = new Set(argv.filter((a) => a.startsWith("--")));
46
+ const arg = argv.slice(1).find((a) => !a.startsWith("--"));
47
+ const emitName = flags.has("--emit-name");
48
+
49
+ if (cmd === "queue") {
50
+ const q = queue();
51
+ if (!q.length) { console.log("Queue leer (forge.gaps.json fehlt oder keine Luecken)."); return; }
52
+ console.log("\n Forge-Queue (" + q.length + " Muster, score-absteigend):\n");
53
+ q.forEach((e, i) => console.log(" " + (i + 1) + ". " + e.pattern + " [score " + e.score + ", exemplars: " + (e.exemplars.join(", ") || "—") + "]"));
54
+ console.log("");
55
+ return;
56
+ }
57
+
58
+ if (cmd === "next") {
59
+ const q = queue();
60
+ if (!q.length) { if (!emitName) console.log("Queue leer."); process.exitCode = 1; return; }
61
+ if (emitName) console.log(q[0].gapKey);
62
+ else console.log("\n Naechste Luecke: " + q[0].pattern + " [score " + q[0].score + "]\n");
63
+ return;
64
+ }
65
+
66
+ if (cmd === "verify") {
67
+ if (!arg) { console.error("Aufruf: node bin/forge.js verify <name>"); process.exit(2); }
68
+ const r = gate(arg, { write: !flags.has("--no-report") });
69
+ if (!emitName) {
70
+ console.log("\n Verify — " + arg + "\n");
71
+ for (const c of r.checks) console.log(" " + (c.info ? (c.ok ? "i" : "!") : (c.ok ? "OK" : "x ")) + " " + c.label + (c.detail ? " — " + c.detail : ""));
72
+ console.log("\n " + (r.pass ? "PASS" : "FAIL") + "\n");
73
+ }
74
+ if (!r.pass) process.exitCode = 1;
75
+ return;
76
+ }
77
+
78
+ if (cmd === "generate") {
79
+ // Luecke bestimmen: expliziter gapKey ODER Top der Queue.
80
+ let gap = arg;
81
+ if (!gap) {
82
+ const q = queue();
83
+ if (!q.length) { if (!emitName) console.error("Keine Luecke angegeben und Queue leer."); process.exitCode = 1; return; }
84
+ gap = q[0]; // prioritize-Eintrag (hat what/pattern/exemplars)
85
+ }
86
+ const client = flags.has("--mock") ? mockForgeClient() : openAICompatClient();
87
+ let res;
88
+ try { res = await generate(gap, { client }); }
89
+ catch (e) { if (!emitName) console.error("generate fehlgeschlagen: " + e.message); process.exitCode = 1; return; }
90
+
91
+ if (res.ok) {
92
+ if (emitName) console.log(res.name);
93
+ else console.log("\n Kandidat erzeugt: " + path.relative(process.cwd(), res.candidatePath) + " (Gauntlet GRUEN — Taste-Review offen)\n");
94
+ return;
95
+ }
96
+ // Eskalation: kein PR-faehiger Kandidat.
97
+ if (!emitName) console.error("\n ESKALIERT" + (res.name ? " (" + res.name + ")" : "") + ": " + (res.reason || "—") + "\n");
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+
102
+ console.error("Befehle: queue | next | generate <gapKey> [--mock] [--emit-name] | verify <name>");
103
+ process.exit(2);
104
+ }
105
+
106
+ main().catch((e) => { console.error("Fehler: " + e.message); process.exit(1); });
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,341 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /*
4
+ * Promote-Gate (Baustein 2) — die Gauntlet um die bestehenden Gates.
5
+ * ----------------------------------------------------------------------
6
+ * node bin/promote-gate.js <name> [--prepare] [--no-report] [--budget=N]
7
+ *
8
+ * Liest candidates/<name>/<name>.json + candidates/<name>/example.motionspec.json,
9
+ * faehrt alle Gates (jede Stufe fail-closed) und liefert einen PASS/FAIL-Report
10
+ * (stdout + candidates/<name>/GATE_REPORT.md).
11
+ *
12
+ * verify-only (default) : prueft, aendert NICHTS an primitives/.
13
+ * --prepare : bei PASS schreibt es die Promotion in den Arbeitsbaum
14
+ * (kopiert nach primitives/, ergaenzt optional die
15
+ * Keyword-Regel) — committet/merged NICHTS (Tor 1 = Mensch).
16
+ *
17
+ * ANTI-GOALS (verbindlich):
18
+ * A2 promotet nie auf main / merged nie — schreibt hoechstens den Arbeitsbaum.
19
+ * A4 KEIN Versions-Bump, KEIN Relock im Auto-Pfad. Die additive-minor-Eigenschaft
20
+ * wird von catalog-lock ERZWUNGEN (Stufe 7 verifiziert sie), nicht hier gesetzt.
21
+ * A6 Determinismus ist heilig — Stufe 6 verwirft nicht-deterministischen Output.
22
+ *
23
+ * Die testbare Logik lebt in diesem Modul (`gate`); der CLI-Wrapper ruft sie nur.
24
+ *
25
+ * Stufen (in dieser Reihenfolge, alle fail-closed ausser den als INFO markierten):
26
+ * 1 Primitiv gegen Meta-Schema (catalog.checkPrimitive) + reducedMotionFallback vorhanden
27
+ * 2 validateSpec(example) gegen den Katalog INKL. Kandidat
28
+ * 3 compileSpec(example) -> ok
29
+ * 4 report.budgetOk === true
30
+ * 5 reduced-motion-Guard im Artefakt (wenn respectReducedMotion an)
31
+ * 6 Determinismus: zweimal kompilieren -> byte-identisch; keine nicht-det. Tokens;
32
+ * Golden stabil (candidates/<name>/<name>.golden.{js,css}) — fehlt -> nur mit --prepare anlegen
33
+ * 7 catalog-lock-Klassifikation: hypothetisch hinzugefuegt -> `added`/minor, KEINE Violations
34
+ * 8 INFO: WAAPI-Lowering-Bereitschaft (neues Primitiv ohne Lowering -> Gate-1-Mensch)
35
+ */
36
+ const fs = require("fs");
37
+ const path = require("path");
38
+
39
+ const ROOT = path.join(__dirname, "..");
40
+ const { loadCatalog, checkPrimitive, screenPattern } = require("../src/compiler/catalog.js");
41
+ const { validateSpec } = require("../src/compiler/validate.js");
42
+ const { compileSpec } = require("../src/compiler/compile.js");
43
+ const { snapshotCatalog, diffCatalog } = require("../src/compiler/catalog-semver.js");
44
+ const { SUPPORTED, lowerWaapi } = require("../src/compiler/lower-waapi.js");
45
+
46
+ /* Gueltiger Katalog-/Kandidatenname (identisch zu generate.slugName + catalog NAME_RE).
47
+ * Wird VOR jedem Dateizugriff geprueft -> kein Path-Traversal ueber `name`. */
48
+ const NAME_RE = /^[A-Za-z][A-Za-z0-9]{1,40}$/;
49
+
50
+ /* Versions-Banner normalisieren — Golden duerfen nicht bei jedem Versions-Bump brechen. */
51
+ const normVer = (s) => String(s == null ? "" : s).replace(/MotionSpec-Compiler v[0-9]+\.[0-9]+\.[0-9]+/g, "MotionSpec-Compiler vX");
52
+
53
+ /* Tokens, die nicht-deterministischen Laufzeit-Output verraten (Anti-Goal A6).
54
+ * WICHTIG: eine Denylist kann NIE vollstaendig sein — sie ist ein starkes Netz,
55
+ * NICHT der alleinige Beweis. Determinismus ist auch Gate-1-Menschen-Pflicht.
56
+ * Deckt gaengige Formen ab inkl. Klammerzugriff (Math[...]) und blanker Date(). */
57
+ const NONDETERMINISTIC_RE = new RegExp([
58
+ "Math\\s*\\.\\s*random", "Math\\s*\\[",
59
+ "Date\\s*\\.\\s*(now|parse|UTC)", "new\\s+Date", "\\bDate\\s*\\(",
60
+ "performance\\s*\\.\\s*(now|timeOrigin)",
61
+ "crypto\\s*\\.\\s*(randomUUID|getRandomValues)", "getRandomValues",
62
+ "hrtime", "process\\s*\\.\\s*hrtime",
63
+ ].join("|"), "i");
64
+
65
+ function readJson(file) {
66
+ return JSON.parse(fs.readFileSync(file, "utf8"));
67
+ }
68
+
69
+ function reducedMotionFallbackOf(prim) {
70
+ if (!prim || typeof prim !== "object") return null;
71
+ if (prim.a11y && prim.a11y.reducedMotionFallback) return prim.a11y.reducedMotionFallback;
72
+ if (prim.reducedMotionFallback) return prim.reducedMotionFallback; // Skelett-Form (top-level) ebenfalls akzeptieren
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * @typedef {Object} GateCheck
78
+ * @property {string} label
79
+ * @property {boolean} ok
80
+ * @property {string} [detail]
81
+ * @property {boolean} [info] - true: Befund, der den PASS NICHT blockiert (Gate-1-Mensch)
82
+ */
83
+
84
+ /**
85
+ * Faehrt die Gauntlet fuer einen Kandidaten. Aendert nichts ausser optional
86
+ * GATE_REPORT.md und (nur mit prepare) dem Arbeitsbaum.
87
+ * @param {string} name
88
+ * @param {{root?:string,candidatesDir?:string,primitivesDir?:string,lockPath?:string,prepare?:boolean,write?:boolean,budget?:number}} [opts]
89
+ * @returns {{pass:boolean, checks:GateCheck[], reportPath:string|null}}
90
+ */
91
+ function gate(name, opts = {}) {
92
+ const root = opts.root || ROOT;
93
+ const candidatesDir = opts.candidatesDir || path.join(root, "candidates");
94
+ const primitivesDir = opts.primitivesDir || path.join(root, "primitives");
95
+ const lockPath = opts.lockPath || path.join(root, "catalog.lock.json");
96
+ const write = opts.write !== false;
97
+ const prepare = !!opts.prepare;
98
+ const budget = typeof opts.budget === "number" ? opts.budget : 10;
99
+
100
+ const checks = [];
101
+ const must = (label, ok, detail) => { checks.push({ label, ok: !!ok, detail: detail || "" }); return !!ok; };
102
+ const info = (label, ok, detail) => { checks.push({ label, ok: !!ok, detail: detail || "", info: true }); };
103
+
104
+ /* --- Stufe 0: Name-Format (fail-closed VOR jedem fs-Zugriff -> kein Path-Traversal) --- */
105
+ if (typeof name !== "string" || !NAME_RE.test(name)) {
106
+ checks.push({ label: "0 Name-Format gueltig", ok: false, detail: 'name "' + String(name).slice(0, 80) + '" verletzt ' + NAME_RE });
107
+ return { pass: false, checks, reportPath: null }; // NICHT finalize() -> kein mkdir/Write ausserhalb candidates/
108
+ }
109
+ const dir = path.join(candidatesDir, name);
110
+
111
+ /* --- Eingaben laden (fail-closed) --- */
112
+ const primFile = path.join(dir, name + ".json");
113
+ const exFile = path.join(dir, "example.motionspec.json");
114
+ let prim, example;
115
+ try { prim = readJson(primFile); }
116
+ catch (e) {
117
+ must("Kandidat-Primitiv lesbar", false, primFile + ": " + e.message);
118
+ return finalize(dir, checks, false, write, false, null, null, primitivesDir);
119
+ }
120
+ try { example = readJson(exFile); }
121
+ catch (e) {
122
+ must("Beispiel-Spec lesbar", false, exFile + ": " + e.message);
123
+ return finalize(dir, checks, false, write, false, null, null, primitivesDir);
124
+ }
125
+
126
+ /* Katalog INKL. Kandidat (in-memory Overlay; primitives/ wird NICHT angefasst). */
127
+ let base;
128
+ try { base = loadCatalog(primitivesDir); }
129
+ catch (e) {
130
+ must("Basis-Katalog laedt", false, e.message);
131
+ return finalize(dir, checks, false, write, false, prim, example, primitivesDir);
132
+ }
133
+ const overlaid = Object.assign({}, base, { [prim.name]: prim });
134
+ const isNewName = !Object.prototype.hasOwnProperty.call(base, prim.name);
135
+
136
+ /* --- Stufe 1: Meta-Schema + reducedMotionFallback --- */
137
+ let metaOk = true;
138
+ try { checkPrimitive(prim, name); }
139
+ catch (e) { metaOk = false; must("1 Meta-Schema (checkPrimitive)", false, e.message); }
140
+ if (metaOk) must("1 Meta-Schema (checkPrimitive)", true, "name/version/output/template/paramSchema gueltig");
141
+ must("1 reducedMotionFallback vorhanden", !!reducedMotionFallbackOf(prim),
142
+ reducedMotionFallbackOf(prim) || "fehlt — a11y.reducedMotionFallback ist Pflicht");
143
+ must("1 Name == prim.name", prim.name === name, prim.name === name ? "" : 'Dateibasis "' + name + '" != prim.name "' + prim.name + '"');
144
+ /* Determinismus-Screen schon auf dem ROHEN Template (faengt Entropie, die durch
145
+ * Interpolation verschleiert werden koennte — Compile bildet das Template 1:1 ab). */
146
+ const tplND = NONDETERMINISTIC_RE.exec(String(prim.template || ""));
147
+ must("1 keine nicht-det. Tokens im Template", !tplND, tplND ? ('template enthaelt "' + tplND[0] + '" (Anti-Goal A6)') : "");
148
+
149
+ /* --- Stufe 2: validateSpec --- */
150
+ const v = validateSpec(example, overlaid);
151
+ must("2 validateSpec(example)", v.ok, (v.errors || []).join(" | "));
152
+
153
+ /* --- Stufe 3 + 4: compile + Budget --- */
154
+ const c = v.ok ? compileSpec(example, overlaid, { specName: name, budget }) : { ok: false, errors: ["uebersprungen: validate fehlgeschlagen"] };
155
+ must("3 compileSpec(example) ok", c.ok, c.ok ? "" : (c.errors || []).join(" | "));
156
+ must("4 report.budgetOk", c.ok && c.report && c.report.budgetOk === true,
157
+ c.ok && c.report ? ("cost " + c.report.cost + " / " + c.report.budget) : "kein Report");
158
+
159
+ /* --- Stufe 5: reduced-motion-Guard im Artefakt — RRM ERZWUNGEN, vom Beispiel UNABHAENGIG ---
160
+ * Das Beispiel darf nicht steuern, ob sein eigenes a11y-Gate feuert: wir kompilieren
161
+ * eine RRM-on-Variante und verlangen den Guard. Das Beispiel-Opt-out bleibt nur INFO. */
162
+ const artifact = c.ok ? ((c.js || "") + "\n" + (c.css || "")) : "";
163
+ const forcedExample = Object.assign({}, example, { globals: Object.assign({}, example.globals, { respectReducedMotion: true }) });
164
+ const cForced = compileSpec(forcedExample, overlaid, { specName: name, budget });
165
+ const forcedArtifact = cForced.ok ? ((cForced.js || "") + "\n" + (cForced.css || "")) : "";
166
+ must("5 reduced-motion-Guard im Artefakt (RRM erzwungen)",
167
+ cForced.ok && forcedArtifact.includes("prefers-reduced-motion"),
168
+ cForced.ok ? "" : "kein Compile-Output bei erzwungenem respectReducedMotion");
169
+ if (example.globals && example.globals.respectReducedMotion === false)
170
+ info("5 example schaltet RRM aus", true, "Beispiel-Opt-out — Gate verifiziert den Guard unabhaengig (RRM erzwungen); Absicht prueft Gate-1");
171
+
172
+ /* --- Stufe 6: Determinismus + Golden --- */
173
+ if (c.ok) {
174
+ const c2 = compileSpec(example, overlaid, { specName: name, budget });
175
+ const deterministic = c2.ok && c2.js === c.js && c2.css === c.css;
176
+ /* HINWEIS: der Compiler ist eine reine Funktion -> diese Pruefung ist eine
177
+ * REGRESSIONSWACHE (faengt einen kuenftig unreinen Compiler), KEIN Laufzeit-
178
+ * Determinismus-Beweis. Letzteren leisten der Token-Screen (Stufe 1+6) + Golden. */
179
+ must("6 Compiler-Determinismus (Regressionswache)", deterministic, deterministic ? "" : "Output variiert zwischen zwei Laeufen — Compiler nicht mehr rein");
180
+ const nd = NONDETERMINISTIC_RE.exec(artifact);
181
+ must("6 keine nicht-det. Tokens im Output", !nd, nd ? ('Output enthaelt "' + nd[0] + '" (Anti-Goal A6)') : "");
182
+
183
+ const goldenJs = path.join(dir, name + ".golden.js");
184
+ const goldenCss = path.join(dir, name + ".golden.css");
185
+ const haveJs = fs.existsSync(goldenJs);
186
+ const haveCss = fs.existsSync(goldenCss);
187
+ if (!haveJs && !haveCss) {
188
+ if (prepare) {
189
+ if (c.js != null) fs.writeFileSync(goldenJs, normVer(c.js));
190
+ if (c.css != null) fs.writeFileSync(goldenCss, normVer(c.css));
191
+ info("6 Golden angelegt", true, "candidates/" + name + "/" + name + ".golden.* (--prepare)");
192
+ } else {
193
+ info("6 Golden fehlt", true, "wird bei --prepare angelegt; jetzt nur Determinismus geprueft");
194
+ }
195
+ } else {
196
+ let stable = true; const why = [];
197
+ if (haveJs) { const g = fs.readFileSync(goldenJs, "utf8"); if (normVer(c.js) !== g) { stable = false; why.push("js weicht ab"); } }
198
+ if (haveCss) { const g = fs.readFileSync(goldenCss, "utf8"); if (normVer(c.css) !== g) { stable = false; why.push("css weicht ab"); } }
199
+ must("6 Golden byte-stabil", stable, why.join("; "));
200
+ }
201
+ } else {
202
+ must("6 Determinismus", false, "kein Compile-Output zum Pruefen");
203
+ }
204
+
205
+ /* --- Stufe 7: catalog-lock-Klassifikation (additive minor) --- */
206
+ try {
207
+ const lock = readJson(lockPath).primitives;
208
+ const current = snapshotCatalog(overlaid);
209
+ const { violations, added } = diffCatalog(lock, current);
210
+ const noViolations = violations.length === 0;
211
+ const classedMinor = isNewName ? added.includes(prim.name) : true; // bereits gelockt -> kein neuer add, aber keine Violation = ok
212
+ must("7 catalog-lock: keine SemVer-Violations", noViolations, violations.map((x) => x.msg).join(" | "));
213
+ must("7 catalog-lock: additive minor", classedMinor,
214
+ isNewName ? ("erwartet in added[], war: " + JSON.stringify(added)) : "bereits im Lock (Re-Promotion) — keine Violation erforderlich");
215
+ } catch (e) {
216
+ must("7 catalog-lock-Klassifikation", false, e.message);
217
+ }
218
+
219
+ /* --- Stufe 8: INFO — WAAPI-Lowering-Bereitschaft --- */
220
+ if (Array.isArray(SUPPORTED) && SUPPORTED.includes(prim.name)) {
221
+ let lowered = false; let detail;
222
+ try {
223
+ const r1 = lowerWaapi(example, overlaid, { specName: name });
224
+ const r2 = lowerWaapi(example, overlaid, { specName: name });
225
+ lowered = r1.ok && r2.ok && (r1.js || r1.css) === (r2.js || r2.css);
226
+ detail = r1.ok ? "lowering deterministisch" : (r1.errors || []).join(" | ");
227
+ } catch (e) { detail = e.message; }
228
+ info("8 WAAPI-Lowering vorhanden", lowered, detail);
229
+ } else {
230
+ info("8 WAAPI-Lowering", true,
231
+ "neues Primitiv ohne Lowering — waapi-lowering.test.js wird in CI rot (SUPPORTED != Katalog). " +
232
+ "Das ist die Gate-1-Eskalation: ein Mensch ergaenzt Lowering+Golden, nicht die Forge (Anti-Goal A5).");
233
+ }
234
+
235
+ const pass = checks.filter((x) => !x.info).every((x) => x.ok);
236
+ return finalize(dir, checks, pass, write, prepare, prim, example, primitivesDir, isNewName);
237
+ }
238
+
239
+ /* Schreibt den Report und fuehrt (nur bei PASS+prepare) die Promotion durch. */
240
+ function finalize(dir, checks, pass, write, prepare, prim, example, primitivesDir) {
241
+ const name = path.basename(dir);
242
+ let reportPath = null;
243
+ if (write) {
244
+ try {
245
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
246
+ reportPath = path.join(dir, "GATE_REPORT.md");
247
+ fs.writeFileSync(reportPath, renderReport(name, checks, pass, prepare && pass));
248
+ } catch { /* Report-Schreiben darf das Verdikt nicht kippen */ }
249
+ }
250
+ if (pass && prepare && prim) {
251
+ preparePromotion(name, prim, dir, primitivesDir);
252
+ }
253
+ return { pass, checks, reportPath };
254
+ }
255
+
256
+ /* preparePromotion: schreibt NUR in den Arbeitsbaum. KEIN git, KEIN Relock, KEIN
257
+ * Versions-Bump (Anti-Goal A4 — catalog-lock erzwingt die minor-Eigenschaft). */
258
+ function preparePromotion(name, prim, dir, primitivesDir) {
259
+ const dest = path.join(primitivesDir, prim.name + ".json");
260
+ fs.writeFileSync(dest, JSON.stringify(prim, null, 2) + "\n");
261
+
262
+ // Optionale Keyword-Regel: candidates/<name>/keyword-rule.json oder prim.keywordRule.
263
+ let rule = null;
264
+ const ruleFile = path.join(dir, "keyword-rule.json");
265
+ if (fs.existsSync(ruleFile)) { try { rule = readJson(ruleFile); } catch { rule = null; } }
266
+ if (!rule && prim.keywordRule) rule = prim.keywordRule;
267
+ if (rule && rule.source && rule.target) {
268
+ try { appendKeywordRule(prim.name, rule, path.join(primitivesDir, "..", "src", "compiler", "keyword-map.js")); }
269
+ catch { /* best-effort: Keyword-Regel ist Gate-1-Komfort, kein hartes Gate */ }
270
+ }
271
+ }
272
+
273
+ /* Haengt eine Keyword->Primitiv-Regel an KEYWORD_MAP an (vor dem schliessenden `];`).
274
+ * Reihenfolge ist API, daher NEU = niedrigere Praezedenz als die spezifischen
275
+ * Bestandsregeln, was korrekt ist. Nutzt die new RegExp(...)-Form, um
276
+ * Literal-Escaping zu vermeiden. */
277
+ function appendKeywordRule(primitiveName, rule, keywordMapFile) {
278
+ const flags = typeof rule.flags === "string" ? rule.flags : "i";
279
+ // Gleiche ReDoS-/Laengen-Schranke wie der Katalog (catalog.screenPattern):
280
+ // ein modell-kontrolliertes Muster darf NIE ungescreent in Laufzeit-Quellcode
281
+ // landen (die Tabelle wird auf untrusted Client-Briefs ausgefuehrt). Wirft bei
282
+ // Verstoss -> der best-effort try/catch in preparePromotion faengt es -> Regel
283
+ // wird einfach NICHT angehaengt (fail-closed).
284
+ screenPattern(rule.source, "keywordRule.source", (msg) => { throw new Error(msg); });
285
+ // Flags-Gueltigkeit zusaetzlich pruefen (screenPattern testet nur das Muster):
286
+ RegExp(rule.source, flags);
287
+ const src = fs.readFileSync(keywordMapFile, "utf8");
288
+ const entry =
289
+ " /* forge: " + primitiveName + " (Taste-Review — Mensch prueft Reihenfolge/Praezedenz) */\n" +
290
+ " { re: new RegExp(" + JSON.stringify(rule.source) + ", " + JSON.stringify(flags) + "), primitive: " +
291
+ JSON.stringify(primitiveName) + ", target: " + JSON.stringify(rule.target) +
292
+ ", params: " + JSON.stringify(rule.params || {}) + " },\n";
293
+ const marker = "];";
294
+ const idx = src.lastIndexOf(marker);
295
+ if (idx === -1) throw new Error("KEYWORD_MAP-Ende nicht gefunden");
296
+ const out = src.slice(0, idx) + entry + src.slice(idx);
297
+ fs.writeFileSync(keywordMapFile, out);
298
+ }
299
+
300
+ function renderReport(name, checks, pass, promoted) {
301
+ const lines = [];
302
+ lines.push("# Gate-Report — `" + name + "`");
303
+ lines.push("");
304
+ lines.push("**Verdikt: " + (pass ? "PASS" : "FAIL") + "**" + (promoted ? " · Promotion in den Arbeitsbaum vorbereitet (`--prepare`)" : ""));
305
+ lines.push("");
306
+ lines.push("> MASCHINELL GEPRUEFT. PASS heisst „alle Gates gruen“, NICHT „freigegeben“. Tor 1 = ein Mensch merged den PR (Taste-Review).");
307
+ lines.push("");
308
+ lines.push("| Stufe | OK | Detail |");
309
+ lines.push("| --- | :-: | --- |");
310
+ for (const c of checks) {
311
+ const mark = c.info ? (c.ok ? "i" : "!") : (c.ok ? "PASS" : "FAIL");
312
+ const detail = String(c.detail || "").replace(/\|/g, "\\|").slice(0, 300);
313
+ lines.push("| " + c.label + " | " + mark + " | " + detail + " |");
314
+ }
315
+ lines.push("");
316
+ lines.push("Legende: PASS/FAIL = fail-closed-Gate · i/! = INFO (blockiert PASS nicht; Hinweis fuer Gate-1).");
317
+ lines.push("");
318
+ return lines.join("\n");
319
+ }
320
+
321
+ module.exports = { gate, preparePromotion, appendKeywordRule, renderReport, reducedMotionFallbackOf };
322
+
323
+ if (require.main === module) {
324
+ const argv = process.argv.slice(2);
325
+ const flags = new Set(argv.filter((a) => a.startsWith("--")));
326
+ const name = argv.find((a) => !a.startsWith("--"));
327
+ if (!name) { console.error("Aufruf: node bin/promote-gate.js <name> [--prepare] [--no-report]"); process.exit(2); }
328
+ const budgetArg = argv.find((a) => a.startsWith("--budget="));
329
+ const r = gate(name, {
330
+ prepare: flags.has("--prepare"),
331
+ write: !flags.has("--no-report"),
332
+ budget: budgetArg ? Number(budgetArg.split("=")[1]) : undefined,
333
+ });
334
+ console.log("\n Promote-Gate — " + name + "\n");
335
+ for (const c of r.checks) {
336
+ const mark = c.info ? (c.ok ? "i" : "!") : (c.ok ? "OK" : "x ");
337
+ console.log(" " + mark + " " + c.label + (c.detail ? " — " + c.detail : ""));
338
+ }
339
+ console.log("\n " + (r.pass ? "PASS" : "FAIL") + (r.reportPath ? " (" + path.relative(process.cwd(), r.reportPath) + ")" : "") + "\n");
340
+ if (!r.pass) process.exitCode = 1;
341
+ }