@design-twin/dtwin 0.0.0-stage → 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
@@ -0,0 +1,156 @@
1
+ // settings.ts — what a bridge process is configured with, resolved ONCE at its entry (the dtwin CLI, the MCP
2
+ // server, doctor) and passed down: into the bridge holder (bridge-holder.ts), the bridge itself (server-core.ts
3
+ // createBridge), the daemon socket it serves or looks up (daemon.ts), and nothing else reads the environment for
4
+ // these again. No load-time effects, and nothing here exits: a bad value is a result the entry decides about.
5
+ //
6
+ // port FIGMA_BRIDGE_PORT — one of the three the plugin can reach (ports.ts), else a problem.
7
+ // token the effective bridge token and its source, by token-store.ts's precedence (--token-file >
8
+ // FIGMA_BRIDGE_TOKEN > the saved file > mint). Resolved without persisting: the bridge holder commits
9
+ // it (commitToken) once, before it first binds — a minted one is saved then, and the notice printed.
10
+ // A resolution that FAILS (an unreadable --token-file) is kept as that failure, not thrown: commands
11
+ // that never open a bridge (--token-status) still run, which is exactly when you want to diagnose
12
+ // it; the bridge or daemon that needs the token reports it.
13
+ // maxPayload FIGMA_BRIDGE_MAX_PAYLOAD_MB — the per-frame cap (maxPayloadFromEnv).
14
+ // idleMs FIGMA_DAEMON_IDLE_MIN — how long a shared bridge stays up with nothing using it (idleMsFromEnv).
15
+ // ephemeralPort DT_TEST_EPHEMERAL_PORT=1 — the TEST SEAM: bind an ephemeral port instead of `port` (server-core).
16
+ // runtimeDir XDG_RUNTIME_DIR — where the daemon socket lives when private (daemonAddressOf; daemon.ts never reads env).
17
+ //
18
+ // --token-file used to reach server-core by being written into process.env before a dynamic import, because the
19
+ // token was resolved at that module's load. It is now an argument here.
20
+ import * as tokenStore from "./token-store.ts";
21
+ import type { ResolvedToken } from "./token-store.ts";
22
+ import { parsePortEnv, portProblem } from "./ports.ts";
23
+ import os from "node:os";
24
+ import { daemonAddress } from "./daemon.ts";
25
+ import type { DaemonAddress } from "./daemon.ts";
26
+
27
+ /** The effective token, or why none could be resolved. */
28
+ export type TokenSetting = { ok: true; resolved: ResolvedToken } | { ok: false; error: unknown };
29
+
30
+ export interface MaxPayload { mb: number; bytes: number; warning?: string }
31
+
32
+ export interface BridgeSettings {
33
+ readonly port: number;
34
+ readonly token: TokenSetting;
35
+ readonly maxPayload: MaxPayload;
36
+ readonly idleMs: number;
37
+ /** FIGMA_DAEMON_IDLE_MIN as given (undefined when unset), for a message that says why the window is off */
38
+ readonly idleRaw: string | undefined;
39
+ readonly ephemeralPort: boolean;
40
+ /** XDG_RUNTIME_DIR — where the daemon socket lives when it is private to this user (daemon.ts daemonAddress) */
41
+ readonly runtimeDir?: string | undefined;
42
+ }
43
+
44
+ export interface SettingsInput {
45
+ /** the environment to read (defaults to process.env). token-store itself reads FIGMA_BRIDGE_TOKEN and the saved
46
+ * file's location from process.env, so only FIGMA_BRIDGE_TOKEN_FILE of the token's inputs comes from here. */
47
+ env?: NodeJS.ProcessEnv;
48
+ /** `--token-file` (wins over FIGMA_BRIDGE_TOKEN_FILE in `env`) */
49
+ tokenFile?: string | null;
50
+ }
51
+
52
+ export type SettingsResult = { ok: true; settings: BridgeSettings } | { ok: false; problem: string };
53
+
54
+ /** Every setting, or the one problem that leaves a bridge here nothing to do (a port no plugin can reach). */
55
+ export function readSettings({ env = process.env, tokenFile }: SettingsInput = {}): SettingsResult {
56
+ const p = parsePortEnv(env.FIGMA_BRIDGE_PORT);
57
+ if (p.port === undefined) return { ok: false, problem: portProblem(p.invalid) };
58
+ return {
59
+ ok: true,
60
+ settings: {
61
+ port: p.port,
62
+ token: tokenSetting({ env, ...(tokenFile !== undefined ? { tokenFile } : {}) }),
63
+ maxPayload: maxPayloadFromEnv(env.FIGMA_BRIDGE_MAX_PAYLOAD_MB),
64
+ idleMs: idleMsFromEnv(env),
65
+ idleRaw: env.FIGMA_DAEMON_IDLE_MIN,
66
+ ephemeralPort: env.DT_TEST_EPHEMERAL_PORT === "1",
67
+ runtimeDir: env.XDG_RUNTIME_DIR,
68
+ },
69
+ };
70
+ }
71
+
72
+ /** THE token resolution every caller goes through: `tokenFile` (the flag) or FIGMA_BRIDGE_TOKEN_FILE, then
73
+ * token-store's precedence. Never persists, never throws. */
74
+ export function tokenSetting({ env = process.env, tokenFile }: SettingsInput = {}): TokenSetting {
75
+ const file = tokenFile ?? (env.FIGMA_BRIDGE_TOKEN_FILE || null);
76
+ try {
77
+ return { ok: true, resolved: tokenStore.resolve({ tokenFile: file, persist: false }) };
78
+ } catch (e) {
79
+ return { ok: false, error: e };
80
+ }
81
+ }
82
+
83
+ /** What committing the settings' token on first use did (token-store.ts commit): the settings as every later bridge
84
+ * and daemon lookup of this process should see them, and the lines to tell the user. */
85
+ export interface TokenCommit {
86
+ readonly settings: BridgeSettings;
87
+ readonly minted: boolean;
88
+ readonly saveFailed: boolean;
89
+ readonly notice: readonly string[];
90
+ }
91
+
92
+ /** Commit the token before a bridge first uses it: save a minted one, and say which token is in play (printing it
93
+ * only on the commit that mints it). A resolution that failed commits nothing — the bridge reports it. */
94
+ export function commitToken(s: BridgeSettings): TokenCommit {
95
+ if (!s.token.ok) return { settings: s, minted: false, saveFailed: false, notice: [] };
96
+ const c = tokenStore.commit(s.token.resolved);
97
+ return { settings: { ...s, token: { ok: true, resolved: c.resolved } }, minted: c.minted, saveFailed: c.saveFailed, notice: c.notice };
98
+ }
99
+
100
+ /** The token a daemon's pipe is named from and its handshake keys on: the effective one — except a token nothing
101
+ * saved yet (no daemon anyone could have started under it: a bridge commits the token it mints before it serves) and
102
+ * a resolution that failed, both null. */
103
+ export function daemonTokenOf(t: TokenSetting): string | null {
104
+ return t.ok && t.resolved.source !== "ephemeral" ? t.resolved.token : null;
105
+ }
106
+
107
+ /** What the daemon's address takes from the machine rather than the settings: the platform (win32 is a pipe), the
108
+ * uid its socket directory must belong to, and the tmpdir that directory falls back under. */
109
+ export interface Host {
110
+ readonly platform: NodeJS.Platform;
111
+ readonly uid: number | undefined;
112
+ readonly tmpdir: string;
113
+ }
114
+ export const thisHost = (): Host => ({ platform: process.platform, uid: process.getuid?.(), tmpdir: os.tmpdir() });
115
+
116
+ /** Where this process's daemon is looked up or served (daemon.ts daemonAddress): at `port` (the settings' own unless
117
+ * given — `dtwin --rotate-token` looks at all three), keyed on the token as these settings have it. Resolved once
118
+ * per settings value: a token committed since (commitToken) is a new settings value, and a new address. */
119
+ export function daemonAddressOf(s: BridgeSettings, port: number = s.port, host: Host = thisHost()): DaemonAddress {
120
+ return daemonAddress({ port, token: daemonTokenOf(s.token), ...host, runtimeDir: s.runtimeDir });
121
+ }
122
+
123
+ /** Does something other than the saved token file decide the token (FIGMA_BRIDGE_TOKEN or a token file)? Then
124
+ * replacing or deleting the saved file changes nothing for a running bridge. */
125
+ export function tokenOverridden(t: TokenSetting): boolean {
126
+ return t.ok ? t.resolved.source === "env" || t.resolved.source === "token-file" : true;
127
+ }
128
+
129
+ const DEFAULT_MAX_PAYLOAD_MB = 128;
130
+ const MiB = 1024 * 1024;
131
+ /** The bridge's per-frame cap from FIGMA_BRIDGE_MAX_PAYLOAD_MB. It bounds a single frame so a malformed/huge payload can't
132
+ * OOM the process; 128 MB is ~2x a realistic asset-heavy full export, and an unusually large file can raise it. Bun.serve
133
+ * takes it as `maxPayloadLength`, an unsigned 32-bit byte count, so a value past 4096 MB is clamped to just under 4 GiB
134
+ * and `mb` says so (the too-big diagnosis quotes it — the cap in force, not the number typed).
135
+ * A value that is not a finite number above 0 falls back to the default, with a `warning`: `ws` read 0 (and below) as
136
+ * "unlimited", but Bun.serve has no unlimited — 0 there refuses every non-empty frame — and a typo must not become either one. */
137
+ export function maxPayloadFromEnv(raw: string | undefined): MaxPayload {
138
+ const v = (raw ?? "").trim();
139
+ if (v === "") return { mb: DEFAULT_MAX_PAYLOAD_MB, bytes: DEFAULT_MAX_PAYLOAD_MB * MiB };
140
+ const n = Number(v);
141
+ if (!Number.isFinite(n) || n <= 0) {
142
+ return { mb: DEFAULT_MAX_PAYLOAD_MB, bytes: DEFAULT_MAX_PAYLOAD_MB * MiB, warning: `FIGMA_BRIDGE_MAX_PAYLOAD_MB=${JSON.stringify(raw)} is not a number of megabytes above 0 — using the default ${DEFAULT_MAX_PAYLOAD_MB} MB (there is no "unlimited")` };
143
+ }
144
+ const bytes = Math.max(1, Math.min(Math.floor(n * MiB), 0xffffffff));
145
+ return { mb: Math.round((bytes / MiB) * 100) / 100, bytes };
146
+ }
147
+
148
+ /** The idle window a shared bridge uses: FIGMA_DAEMON_IDLE_MIN minutes (default 120), in ms. 0 — and an unparsable
149
+ * value, as it always was — disables the idle shutdown. */
150
+ export function idleMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
151
+ return minutesToMs(env.FIGMA_DAEMON_IDLE_MIN ?? 120);
152
+ }
153
+ export const minutesToMs = (min: unknown): number => Math.max(0, Number(min)) * 60000 || 0;
154
+
155
+ // The idle window for a log line (human-ms.ts, a leaf the served session imports directly).
156
+ export { humanMs } from "./human-ms.ts";
@@ -0,0 +1,16 @@
1
+ // source-stamp.ts — which connected Figma file answered a read, stamped onto its reply.
2
+ //
3
+ // A screen export result has no field of its own naming its source file (unlike design-system.json / a
4
+ // library catalog, which the plugin itself stamps), so the caller that knows WHICH client answered — the
5
+ // bridge's request, or a daemon relaying it — stamps it here before the reply reaches
6
+ // write-out.ts, which persists it onto the screen JSON and its index row. read-run.ts stamps every read the CLI and the MCP
7
+ // server run, so both paths write the same field. Unstamped (never guessed) when no client was handed back
8
+ // (a daemon too old to relay it) or the client has no file name yet.
9
+ import type { Stamped } from "./write-out.ts";
10
+
11
+ export function stampSource<R extends object>(sent: { reply: R; client: { file: string | null; fileKey: string | null } | null }): Stamped<R> {
12
+ const r: Stamped<R> = sent.reply;
13
+ const src = sent.client;
14
+ if (src && src.file) { r.sourceFile = src.file; if (src.fileKey) r.sourceFileKey = src.fileKey; }
15
+ return r;
16
+ }
@@ -0,0 +1,63 @@
1
+ // staleness.ts — the plugin/daemon version-staleness notes, split out of server-core.ts so the CLI's
2
+ // pure, test-driven helpers (figma-pull.ts formatClients) and doctor.ts use them without loading the bridge
3
+ // itself. This module's only load-time effect is reading bridge/package.json for
4
+ // BRIDGE_VERSION. Every caller, server-core.ts included, imports the three public names from here.
5
+ import fs from "node:fs";
6
+
7
+ // The plugin reports a version so that a stale bundle in Figma (several fixes live only in
8
+ // figma-plugin/code.js — SVG normalisation, case-folded asset names) can be told apart from a fresh
9
+ // one — a plugin instance's startedAt and code.js's own mtime can land in the same minute either way.
10
+ // `BRIDGE_VERSION` is this PACKAGE's version (bridge/package.json — the CLI/MCP the user is actually
11
+ // running); `pluginStalenessNote` compares it against whatever the connected plugin
12
+ // announced in its `hello`. A simple numeric [major,minor,patch] compare, not semver ranges: this is an
13
+ // internal tool with one version number moving in one repo, not a published dependency graph.
14
+ export const BRIDGE_VERSION: string | null = (() => {
15
+ try {
16
+ return (JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
17
+ } catch (e) { return null; }
18
+ })();
19
+ type Version = [number, number, number];
20
+ function parseVersion(v: unknown): Version | null {
21
+ if (typeof v !== "string") return null;
22
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
23
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
24
+ }
25
+ function versionOlder(a: Version, b: Version): boolean {
26
+ const [aMajor, aMinor, aPatch] = a;
27
+ const [bMajor, bMinor, bPatch] = b;
28
+ if (aMajor !== bMajor) return aMajor < bMajor;
29
+ if (aMinor !== bMinor) return aMinor < bMinor;
30
+ if (aPatch !== bPatch) return aPatch < bPatch;
31
+ return false;
32
+ }
33
+ // `null` means "nothing to warn about" — the plugin reported a version, and it isn't older than this
34
+ // CLI/MCP. Everything else IS worth a warn, including a MISSING version: a real daemon + a real Figma plugin
35
+ // both running code from before version reporting would say nothing if the logic only compared two
36
+ // REAL version strings (`list clients --json` would print `pluginVersion: null, pluginStale: null` for
37
+ // both files) and treated "didn't report one" as "nothing to check," which is backwards: no version at
38
+ // all is the ONE case we can be certain predates this feature, so it is unconditionally worth a
39
+ // warning, not a silent pass.
40
+ export function pluginStalenessNote(pluginVersion: unknown): string | null {
41
+ if (typeof pluginVersion !== "string" || !pluginVersion) {
42
+ return "plugin bundle predates version reporting (or the daemon was started before this bridge was updated) — " +
43
+ "re-run Plugins → Development → Design Twin in Figma and restart `dtwin serve`";
44
+ }
45
+ const p = parseVersion(pluginVersion), b = parseVersion(BRIDGE_VERSION);
46
+ if (!p || !b || !versionOlder(p, b)) return null;
47
+ return `plugin v${pluginVersion} is older than this ${BRIDGE_VERSION} bridge — reload the plugin in Figma (Plugins → Development → Design Twin) to pick up recent fixes`;
48
+ }
49
+
50
+ // Distinct from the above: this fires when the CLIENT ROW ITSELF has no `pluginVersion` KEY at all
51
+ // (`"pluginVersion" in row` is false), which is not the same as the key being present and `null`.
52
+ // `describe()` in server-core.ts always sets the key — to a string or explicitly to `null` — for any bridge
53
+ // running this code, so a row with the key entirely absent can only have come from an OLDER daemon's
54
+ // own older `describe()`, read back over its status socket by a NEWER `dtwin` CLI. That is a
55
+ // second, independent kind of staleness this tool can detect: not just "the Figma plugin is old" but
56
+ // "the long-running `dtwin serve` process itself predates this bridge version and needs restarting" —
57
+ // which reloading the Figma plugin alone will not fix, since the daemon in front of it is what's stale.
58
+ export function daemonRowStalenessNote(row: unknown): string | null {
59
+ if (row && typeof row === "object" && !("pluginVersion" in row)) {
60
+ return "the `dtwin serve` daemon in front of this connection predates plugin-version reporting — restart it (`dtwin serve --stop` then `dtwin serve`) to pick up recent fixes";
61
+ }
62
+ return null;
63
+ }
package/src/stdio.ts ADDED
@@ -0,0 +1,156 @@
1
+ // The queued console and the exit helpers every CLI entry point ends through — the dtwin CLI, the MCP server and the
2
+ // daemon here, and every design-to-code CLI (design-to-code/proc.ts re-exports this module; bridge/src never imports
3
+ // design-to-code, and the published `designtwin` package ships bridge/src alone, so it lives on this side).
4
+ //
5
+ // Bun (1.4.2, Linux too): console.* writes straight to the fd and DROPS what meets a full non-blocking pipe (a slow
6
+ // reader: CI log shipper, `| less`, Claude Code reading a hook's output), and exit() drops whatever is still queued on a
7
+ // pipe. So: console goes through the streams' queue, and a CLI ends on process.exitCode (the process exits once the queue
8
+ // has drained) — or, when something still holds the loop, through exitFlushed. Never a bare process.exit after output.
9
+ import { format, inspect } from "node:util";
10
+ import type { InspectOptions } from "node:util";
11
+
12
+ /** console.* through process.stdout/stderr.write: Bun's console writes straight to the fd and DROPS what meets a full
13
+ * non-blocking pipe, while stream.write() queues it in order. End with process.exitCode (the process then exits once the
14
+ * queue has drained), or after flushStdio() — never a bare exit, which drops the queue. Also counts the streams' writes
15
+ * in flight, which flushStdio waits on. Idempotent. */
16
+ export function installQueuedConsole(o: { logTo?: "stdout" | "stderr" } = {}): void {
17
+ trackWrites(process.stdout);
18
+ trackWrites(process.stderr);
19
+ // logTo "stderr": everything console prints goes to stderr — the MCP server, whose stdout carries nothing but the
20
+ // JSON-RPC its transport writes (a stray console.log there would corrupt the protocol stream).
21
+ const out = o.logTo === "stderr" ? process.stderr : process.stdout;
22
+ const err = process.stderr;
23
+ // console.group indentation, as the built-in console keeps it (two spaces per open group, on every line printed).
24
+ let indent = "";
25
+ const write = (s: NodeJS.WriteStream, text: string): void => { s.write((indent ? text.replace(/^/gm, indent) : text) + "\n"); };
26
+ const line = (s: NodeJS.WriteStream) => (...a: unknown[]): void => { write(s, format(...a)); };
27
+ console.log = console.info = console.debug = line(out);
28
+ console.warn = console.error = line(err);
29
+ // EVERY other console method that prints, so none of them reaches the fd past the queue — or the MCP server's stdout
30
+ // (test/cli-exit.test.ts holds this list to every method the runtime's console has).
31
+ // The text is close to the built-in's (inspect for dir, `Trace: …` + a stack for trace, `label: n` for count,
32
+ // `label: n.nnnms` for the timers), on the stream the built-in uses (stdout, or stderr for trace / assert / a timer
33
+ // warning); console.table prints its data inspected, not as a grid — nothing here tabulates, and a stray call must
34
+ // still never reach the fd past the queue, or the MCP server's stdout.
35
+ console.dir = (item?: unknown, options?: InspectOptions): void => { write(out, inspect(item, { customInspect: false, ...options })); };
36
+ console.dirxml = line(out);
37
+ console.table = (data?: unknown): void => { write(out, inspect(data)); };
38
+ // Bun's own: console.write(...data) is raw bytes to stdout, no newline, no format — returns the byte count.
39
+ console.write = (...data: Array<string | ArrayBufferView | ArrayBuffer>): number => {
40
+ let n = 0;
41
+ for (const d of data) {
42
+ const b = typeof d === "string" ? Buffer.from(d) : d instanceof ArrayBuffer ? Buffer.from(d) : Buffer.from(d.buffer, d.byteOffset, d.byteLength);
43
+ if (b.length) { out.write(b); n += b.length; }
44
+ }
45
+ return n;
46
+ };
47
+ // Only a terminal is cleared (as the built-in does); never a pipe — least of all the MCP server's.
48
+ console.clear = (): void => { if (out.isTTY) out.write("\u001b[1;1H\u001b[0J"); };
49
+ console.trace = (...a: unknown[]): void => {
50
+ const e = new Error(format(...a));
51
+ e.name = "Trace"; // before the capture: the stack's first line is `Trace: <message>`, as the built-in prints
52
+ Error.captureStackTrace(e, console.trace);
53
+ write(err, e.stack ?? `Trace: ${e.message}`);
54
+ };
55
+ console.assert = (cond?: unknown, ...a: unknown[]): void => { if (!cond) write(err, a.length ? "Assertion failed: " + format(...a) : "Assertion failed"); };
56
+ console.group = console.groupCollapsed = (...a: unknown[]): void => { if (a.length) write(out, format(...a)); indent += " "; };
57
+ console.groupEnd = (): void => { indent = indent.slice(0, -2); };
58
+ const counts = new Map<string, number>();
59
+ console.count = (label = "default"): void => { const n = (counts.get(label) ?? 0) + 1; counts.set(label, n); write(out, `${label}: ${n}`); };
60
+ console.countReset = (label = "default"): void => { counts.delete(label); };
61
+ const timers = new Map<string, number>();
62
+ const elapsed = (label: string): string | null => { const t = timers.get(label); return t === undefined ? null : `${label}: ${(performance.now() - t).toFixed(3)}ms`; };
63
+ console.time = (label = "default"): void => { timers.set(label, performance.now()); };
64
+ console.timeLog = (label = "default", ...a: unknown[]): void => { const e = elapsed(label); if (e === null) write(err, `Warning: No such label '${label}' for console.timeLog()`); else write(out, [e, ...a.map((x) => format(x))].join(" ")); };
65
+ console.timeEnd = (label = "default"): void => { const e = elapsed(label); timers.delete(label); write(e === null ? err : out, e ?? `Warning: No such label '${label}' for console.timeEnd()`); };
66
+ }
67
+ // How many non-empty writes each stream has not yet handed to the OS, and who waits for that to reach 0. Bun (1.4.2) keeps
68
+ // no usable writableLength (0 while 400 KB are queued) and calls back an EMPTY write at once, before what was queued ahead of
69
+ // it is out; a non-empty write's callback does come once its bytes are written, in order. So every write is counted (the
70
+ // stream's write wrapped once) and the count says when the queue is empty.
71
+ const inFlight = new Map<NodeJS.WriteStream, { n: number; idle: Array<() => void> }>();
72
+ function trackWrites(s: NodeJS.WriteStream): void {
73
+ if (inFlight.has(s)) return;
74
+ const st = { n: 0, idle: [] as Array<() => void> };
75
+ inFlight.set(s, st);
76
+ const write = s.write.bind(s) as (chunk: string | Uint8Array, encoding?: BufferEncoding, cb?: (err?: Error | null) => void) => boolean;
77
+ const settle = (): void => { if (--st.n === 0) for (const r of st.idle.splice(0)) r(); };
78
+ s.write = (chunk: string | Uint8Array, encoding?: BufferEncoding | ((err?: Error | null) => void), cb?: (err?: Error | null) => void): boolean => {
79
+ const done = typeof encoding === "function" ? encoding : cb;
80
+ const enc = typeof encoding === "function" ? undefined : encoding;
81
+ if (chunk.length === 0) return write(chunk, enc, done);
82
+ st.n++;
83
+ let called = false;
84
+ return write(chunk, enc, (err?: Error | null) => { if (!called) { called = true; settle(); } done?.(err); });
85
+ };
86
+ }
87
+ /** Resolves once every write made through installQueuedConsole's streams has been handed to the OS (at once when there is
88
+ * none in flight, or the streams are not tracked). */
89
+ export const flushStdio = (): Promise<void> => Promise.all([process.stdout, process.stderr].map((s) => new Promise<void>((r) => {
90
+ const st = inFlight.get(s);
91
+ if (!st || st.n === 0) r(); else st.idle.push(r);
92
+ }))).then(() => undefined);
93
+
94
+ // ---- ending a CLI without losing its output
95
+ // Every CLI entry point (dtwin, seed-components, each design-to-code CLI) runs its main() through runCli: console goes through the queued streams, and the exit
96
+ // code is process.exitCode — the process ends once the queue has drained, never with a bare exit call that drops it
97
+ // (Bun loses what is still queued on a pipe at exit(): a 300 KB report into a slow reader arrived as its first 64 KB).
98
+ // The few library helpers that must end the CLI mid-call (cli-args.ts cliParse, catalog-input.ts's fail-loud readers)
99
+ // call exitCli(): inside runCli it throws CliExit, which unwinds to runCli and becomes the exit code; anywhere else (a
100
+ // suite calling main() in-process, the MCP server importing a module) it exits the process at once, as before.
101
+ // "Inside" is until main() SETTLES: a long-lived server is not a CLI run. `dtwin mcp` awaits the MCP server's module (so
102
+ // an exitCli while it LOADS is still that CLI's exit code, its line out first), starts it (runServer), then returns; from there the server runs exactly as `bun figma-mcp.ts` does, where an exitCli exits at once.
103
+ // One rule for both launches — the server never depends on how it was started.
104
+ /** What exitCli throws inside runCli. Not an error: runCli turns it into the exit code, silently. */
105
+ export class CliExit extends Error {
106
+ readonly code: number;
107
+ constructor(code: number) { super(`exit ${code}`); this.name = "CliExit"; this.code = code; }
108
+ }
109
+ let cliDepth = 0;
110
+ /** End the CLI with `code` after what it already printed (see above). Never returns. */
111
+ export function exitCli(code: number): never {
112
+ if (cliDepth > 0) throw new CliExit(code);
113
+ process.exit(code);
114
+ }
115
+ export interface RunCliOpts {
116
+ /** The prefix of an uncaught error's line on stderr (`<name>: <stack>`). */
117
+ name: string;
118
+ /** An uncaught error's line on stderr instead (e.g. its message without the stack). */
119
+ errorLine?: (e: unknown) => string;
120
+ /** An uncaught error's exit code, after its line (default 1; verifier-guard fails open: 0). */
121
+ errorCode?: number;
122
+ /** What to do with the exit code (default: process.exitCode = code). */
123
+ done?: (code: number) => void;
124
+ }
125
+ /** Run a CLI's main(argv) as the process entry: queued console, exitCli honoured, an uncaught error one line + exit 1. */
126
+ export function runCli(main: (argv: string[]) => number | Promise<number>, o: RunCliOpts, argv: string[] = process.argv.slice(2)): void {
127
+ installQueuedConsole();
128
+ const done = o.done ?? ((code: number): void => { process.exitCode = code; });
129
+ const fail = (e: unknown): void => {
130
+ if (e instanceof CliExit) return done(e.code);
131
+ console.error(o.errorLine ? o.errorLine(e) : `${o.name}: ${(e instanceof Error && e.stack) || String(e)}`);
132
+ done(o.errorCode ?? 1);
133
+ };
134
+ // Inside main() — and only until it settles — exitCli throws CliExit (see above). Back to 0 once it has, so whatever
135
+ // outlives a CLI's main (a server it started, a timer) sees exitCli as it would outside any CLI: an immediate exit.
136
+ cliDepth++;
137
+ let settled = false;
138
+ const leave = (): void => { if (!settled) { settled = true; cliDepth--; } };
139
+ try {
140
+ const r = main(argv);
141
+ if (typeof r === "number") { leave(); done(r); } else r.then((c) => { leave(); done(c); }, (e: unknown) => { leave(); fail(e); });
142
+ } catch (e) { leave(); fail(e); }
143
+ }
144
+ /** Whether exitCli would throw CliExit here (inside a runCli main that has not settled) rather than exit. For the suites. */
145
+ export const insideCli = (): boolean => cliDepth > 0;
146
+ /** Exit with `code` once everything written to stdout and stderr is out (flushStdio) — or after `capMs` when a reader never
147
+ * drains them. For a process that has to end while something still holds the loop (a hung step's watchdog, a browser that
148
+ * will not go). Needs installQueuedConsole (runCli calls it) to know what is in flight.
149
+ * The cap bounds QUEUED writes (POSIX, where a write to a pipe is queued). On Windows Bun makes stdout/stderr on a pipe
150
+ * synchronous (as Node does there): a write into a full pipe blocks the thread until the reader takes it, so nothing is
151
+ * ever left in flight here — and a reader that never reads holds the process in that write, as it would any program. */
152
+ export function exitFlushed(code: number, capMs = 2_000): void {
153
+ const exit = (): never => process.exit(code);
154
+ setTimeout(exit, capMs).unref(); // a pending write keeps the loop up, so this still fires
155
+ void flushStdio().then(exit);
156
+ }
@@ -0,0 +1,205 @@
1
+ // svg-normalize.ts — the definitions of "same SVG, modulo Figma's export noise". There are TWO, on purpose:
2
+ //
3
+ // Figma's own SVG export is not bit-reproducible: re-exporting the SAME icon, with NOTHING changed in
4
+ // the design, comes back with different floating-point path coordinates (measured ≤0.004px drift —
5
+ // d="…8.77734…4.97401…" vs d="…8.7793 …4.97596…" for one icon exported twice), and with def ids that
6
+ // embed the exporting node's id (`clip0_22012_15691` vs `clip0_22012_24907` for one icon reached through
7
+ // two instances).
8
+ //
9
+ // 1. `normalizeSvgText` (v1, below): round every decimal to 1 place, then hash the text. Used by the
10
+ // plugin's per-run content hash (figma-plugin/src/assets.ts register(), re-exported crypto-free
11
+ // from figma-plugin/src/util.ts) and by the change diff (design-to-code/design-diff.ts
12
+ // hashAssetBytes(), via bridge/src/asset-compare.ts). It stays EXACTLY as it is: design-diff
13
+ // persists these hashes in snapshot sidecars, so any change to v1 would report every SVG with
14
+ // defs as "redrawn" against every snapshot already on disk.
15
+ // 2. `svgFingerprint` / `sameSvg` (below v1): a skeleton plus every number, compared within an
16
+ // absolute tolerance. Used by the bridge's shared assets/ directory — the content reuse in
17
+ // bridge/src/write-out.ts writeAssets() and its `duplicates` report, via asset-compare.ts
18
+ // ContentIndex. It closes the three gaps v1 has on real exports: def ids, integer
19
+ // tokens (`12` vs `11.9999`, which v1's decimal-only regex never touches) and the rounding
20
+ // boundary (21.9485 → 21.9 but 21.9504 → 22.0: two exports 0.002 apart hash differently).
21
+ //
22
+ // The plugin bundle inlines THIS file, so it stays dependency-free and crypto-free: the byte/hash
23
+ // helpers live in asset-compare.ts, because `node:crypto` does not exist in the plugin's sandbox and
24
+ // this file must also type-check under the plugin's tsconfig, which has no Node types — an
25
+ // `import … from "node:crypto"` ANYWHERE in this file would break the plugin build even if the plugin
26
+ // bundle never calls that function.
27
+ //
28
+ // ---- v1
29
+ // Rounding to 1 decimal place (~0.1px), not 2 (~0.01px): 2 decimals do NOT reliably collapse the
30
+ // drift, because two legitimately identical re-exports can straddle a rounding boundary — 4.97401
31
+ // rounds to 4.97 but 4.97596 (0.00195 away, well inside the ≤0.002px drift budget) rounds to 4.98, so
32
+ // the "same" icon split into two hashes anyway. Checked against eight real exports of one icon
33
+ // (test/fixtures/livetest3/arrow-down/): 1 decimal place is the coarsest rounding that does NOT itself
34
+ // introduce a boundary split on this data, and it still separates the three genuinely different icons
35
+ // among those eight files — two orders of magnitude below anything a human would call "moved".
36
+ // Matches BOTH plain decimals (`-0.000406265`) and scientific notation (`2.09808e-05`) — Figma emits
37
+ // both for a coordinate near zero, sometimes for the SAME logical value across two exports of one
38
+ // icon. The regex must not stop at the decimal mantissa (`-?\d+\.\d+`): `2.09808e-05` would have only
39
+ // its `2.09808` replaced, leaving the exponent dangling as `2.1e-05` — a corrupted leftover that could
40
+ // never match the plain-decimal form's `0.0`, and the icon would fail to dedup even though every other
41
+ // coordinate matched. A real pull showed this: five files for what should have been two (an up/down pair).
42
+ const NUM_RE = /-?\d+\.\d+(?:[eE][+-]?\d+)?/g;
43
+ export function normalizeSvgText(svg: string): string {
44
+ return svg.replace(NUM_RE, (m) => {
45
+ const n = Number(m);
46
+ if (!Number.isFinite(n)) return m;
47
+ // toFixed can print "-0.0" for a tiny negative value that rounds to zero — a plain "0.0" and a
48
+ // "-0.0" are the same design value and must normalise identically, or they poison dedup the same
49
+ // way the scientific-notation bug above did.
50
+ const fixed = n.toFixed(1);
51
+ return fixed === "-0.0" ? "0.0" : fixed;
52
+ });
53
+ }
54
+
55
+ // `fileName` decides whether normalisation even applies: only a textual SVG has a coordinate space to
56
+ // round. PNG/other bytes are never touched — a changed pixel there is real signal, not noise.
57
+ export function isSvgName(fileName: unknown): boolean {
58
+ return /\.svg$/i.test(String(fileName || ""));
59
+ }
60
+
61
+ // ---- the fingerprint (bridge only — see the header for why v1 is not changed instead)
62
+ //
63
+ // The skeleton is the SVG text with (1) every id DEFINED in the document renamed `idN` in order of first
64
+ // definition, together with every `#X` reference to it (`url(#X)`, `href="#X"`, `xlink:href="#X"`) —
65
+ // generic, so it covers any id shape Figma emits, not only the clip/pattern/image ids seen so far; (2)
66
+ // hex colours and `base64,` payloads kept verbatim (a colour is a token, not a coordinate, and a changed
67
+ // pixel in an embedded image is real signal); and (3) every other numeric token, integers included,
68
+ // replaced by `#`, its value pushed to `nums`. Two exports of one icon have equal skeletons and nums
69
+ // within SVG_TOL of each other.
70
+ //
71
+ // SVG_TOL = 0.01, absolute. On the three real export directories every tolerance from 0.005 to 0.05
72
+ // merges the same files; the largest real drift inside a merged group is 0.004 and the closest real
73
+ // pair that is genuinely different is 0.3 apart (a stroke width, 1.5 vs 1.2). 0.01 is 2.5x the drift,
74
+ // far below any visible change, and does not merge opacity steps (0.5 vs 0.54) the way 0.05 would.
75
+ // "Within" is inclusive up to float noise (TOL_EPS): 0.11 vs 0.12 and 0.5 vs 0.51 are both exactly one
76
+ // step apart, but their float differences land on either side of 0.01.
77
+ //
78
+ // One exception: a number inside the `transform` of a `<use>` or `<image>` is in the space of the
79
+ // element it fills — for Figma's raster shells, `<pattern patternContentUnits="objectBoundingBox"><use
80
+ // transform="matrix(0.00195312 0 0 0.00195312 0 0)">`, where the whole box is 1 unit. 0.01 there is a
81
+ // 4.5x zoom of the same photo (scale 1/512 vs 1/113). The `<pattern>`'s own x/y/width/height (and
82
+ // patternTransform) are box fractions too, unless patternUnits="userSpaceOnUse" (a tile size).
83
+ // Those numbers go to `rel` (a `%` in the skeleton)
84
+ // and compare RELATIVELY: within SVG_TOL of the larger magnitude, never looser than the absolute rule.
85
+ // Path and box coordinates keep the absolute tolerance — their near-zero drift (2.09808e-05 vs
86
+ // -0.000406265, one real chevron) is noise, not a different drawing.
87
+ export const SVG_TOL = 0.01;
88
+ const TOL_EPS = 1e-9;
89
+
90
+ export interface SvgFingerprint {
91
+ /** the text with ids canonicalised and every number replaced by `#` */
92
+ skeleton: string;
93
+ /** every number the skeleton replaced with `#`, in document order */
94
+ nums: Float64Array;
95
+ /** every number inside a `<use>`/`<image>` `transform` or a `<pattern>`'s own x/y/width/height/patternTransform
96
+ * (a `%` in the skeleton), compared relatively */
97
+ rel: Float64Array;
98
+ }
99
+
100
+ const ID_DEF_RE = /(?<![\w:-])id\s*=\s*(["'])(.*?)\1/g;
101
+ // One pass over the text with the protected spans first in the alternation, so a protected span is
102
+ // consumed whole and its digits never reach the number branch: a canonical id marker, a hex colour of
103
+ // 3/4/6/8 digits not followed by another name character (so `#clip0` or `#12345` are not colours), a
104
+ // base64 payload, then the number itself — `-?(\d+\.?\d*|\.\d+)` with an optional exponent.
105
+ const ID_MARK = "\u0000";
106
+ const TOKEN_RE = /(\u0000id\d+\u0000)|(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-]))|(base64,[A-Za-z0-9+/=]+)|(-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/g;
107
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+
109
+ function canonicalIds(svg: string): string {
110
+ const order = new Map<string, string>(); // defined id -> its canonical marker, in order of first definition
111
+ // exec, not matchAll: the plugin's tsconfig is es2019 (no String.prototype.matchAll).
112
+ ID_DEF_RE.lastIndex = 0;
113
+ for (let m = ID_DEF_RE.exec(svg); m; m = ID_DEF_RE.exec(svg)) {
114
+ const id = m[2];
115
+ if (id && !order.has(id)) order.set(id, ID_MARK + "id" + order.size + ID_MARK);
116
+ }
117
+ if (!order.size) return svg;
118
+ // Longest first, so an id that is a prefix of another (`a` / `a1`) never claims the longer one's text.
119
+ const alts = [...order.keys()].sort((x, y) => y.length - x.length).map(escapeRe).join("|");
120
+ const re = new RegExp("(?<![\\w:-])(id\\s*=\\s*[\"'])(" + alts + ")(?=[\"'])|#(" + alts + ")(?![\\w.:-])", "g");
121
+ return svg.replace(re, (m: string, attr: string | undefined, defined: string | undefined, ref: string | undefined) => {
122
+ if (attr !== undefined && defined !== undefined) return attr + (order.get(defined) ?? defined);
123
+ if (ref !== undefined) return "#" + (order.get(ref) ?? ref);
124
+ return m;
125
+ });
126
+ }
127
+
128
+ // The value spans compared relatively: `transform` on `<use>`/`<image>` tags, and a `<pattern>`'s own
129
+ // x/y/width/height/patternTransform. A tag's attributes never contain `>` (a base64 payload has none),
130
+ // so `[^>]*` stays inside the tag.
131
+ const REL_TAG_RE = /<(?:use|image|pattern)\b[^>]*>/g;
132
+ const TRANSFORM_ATTR_RE = /(?<![\w:-])transform\s*=\s*(["'])([^"']*)\1/g;
133
+ // A <pattern>'s own box is a fraction of the filled shape (patternUnits defaults to objectBoundingBox): a
134
+ // TILE fill of 0.05 vs 0.0588 is 20 vs 17 tiles across. With patternUnits="userSpaceOnUse" they are user
135
+ // units, compared absolutely like any other number.
136
+ const PATTERN_ATTR_RE = /(?<![\w:-])(?:x|y|width|height|patternTransform)\s*=\s*(["'])([^"']*)\1/g;
137
+ function relSpans(svg: string): Array<[number, number]> {
138
+ const spans: Array<[number, number]> = [];
139
+ REL_TAG_RE.lastIndex = 0;
140
+ for (let t = REL_TAG_RE.exec(svg); t; t = REL_TAG_RE.exec(svg)) {
141
+ const re = t[0].startsWith("<pattern") ? (/patternUnits\s*=\s*["']userSpaceOnUse/.test(t[0]) ? null : PATTERN_ATTR_RE) : TRANSFORM_ATTR_RE;
142
+ if (!re) continue;
143
+ re.lastIndex = 0;
144
+ for (let a = re.exec(t[0]); a; a = re.exec(t[0])) {
145
+ const start = t.index + a.index + a[0].length - 1 - (a[2] || "").length;
146
+ spans.push([start, start + (a[2] || "").length]);
147
+ }
148
+ }
149
+ return spans;
150
+ }
151
+
152
+ export function svgFingerprint(svg: string): SvgFingerprint {
153
+ const nums: number[] = [];
154
+ const rel: number[] = [];
155
+ const text = canonicalIds(svg);
156
+ const spans = relSpans(text);
157
+ const inRel = (at: number): boolean => spans.some(([s, e]) => at >= s && at < e);
158
+ const skeleton = text.replace(TOKEN_RE, (m: string, _id: string | undefined, _hex: string | undefined, _b64: string | undefined, num: string | undefined, at: number) => {
159
+ if (num === undefined) return m;
160
+ if (spans.length && inRel(at)) { rel.push(Number(num)); return "%"; }
161
+ nums.push(Number(num));
162
+ return "#";
163
+ });
164
+ return { skeleton, nums: Float64Array.from(nums), rel: Float64Array.from(rel) };
165
+ }
166
+
167
+ /** Every number within `tol` of its counterpart (and as many of them). Exact equality also counts, so two
168
+ * infinities from an overflowing token compare equal rather than NaN-unequal. */
169
+ export function numsWithin(a: Float64Array, b: Float64Array, tol: number = SVG_TOL): boolean {
170
+ if (a.length !== b.length) return false;
171
+ for (let i = 0; i < a.length; i++) {
172
+ const x = a[i] as number, y = b[i] as number;
173
+ if (x !== y && !(Math.abs(x - y) <= tol + TOL_EPS)) return false;
174
+ }
175
+ return true;
176
+ }
177
+
178
+ /** The `rel` half: every number within `tol` × the larger magnitude of the pair, and never more than
179
+ * `tol` apart — so a big number keeps the absolute rule and a tiny one is compared to its own scale. */
180
+ export function relWithin(a: Float64Array, b: Float64Array, tol: number = SVG_TOL): boolean {
181
+ if (a.length !== b.length) return false;
182
+ for (let i = 0; i < a.length; i++) {
183
+ const x = a[i] as number, y = b[i] as number;
184
+ if (x !== y && !(Math.abs(x - y) <= Math.min(tol, tol * Math.max(Math.abs(x), Math.abs(y))) + TOL_EPS)) return false;
185
+ }
186
+ return true;
187
+ }
188
+
189
+ /** The bridge's "same SVG": equal skeletons, every number within `tol`, every `rel` number relatively so. */
190
+ export function sameSvg(a: string, b: string, tol: number = SVG_TOL): boolean {
191
+ const fa = svgFingerprint(a), fb = svgFingerprint(b);
192
+ return fa.skeleton === fb.skeleton && numsWithin(fa.nums, fb.nums, tol) && relWithin(fa.rel, fb.rel, tol);
193
+ }
194
+
195
+ // ---- a raster image in an SVG shell
196
+ //
197
+ // An SVG with an embedded `<image>` and almost no paths is a PHOTO in an SVG shell (an avatar exported as an
198
+ // icon-like container: 2 paths and a 1.98 MB base64 JPEG). The "thousands of vector paths" advice is wrong
199
+ // for it: there is nothing to simplify, and a PNG/JPG export of the layer is the fix. ONE rule for the
200
+ // bridge's pull warning (write-out.ts) and the audit's heavy-asset finding (design-to-code/audit.ts):
201
+ // an illustration with 2000 paths and one `<image>` is still "too many paths", in both.
202
+ export const RASTER_SHELL_MAX_PATHS = 50;
203
+ export function isRasterShell(rasters: number | undefined, paths: number | undefined): boolean {
204
+ return (rasters ?? 0) > 0 && (paths ?? 0) < RASTER_SHELL_MAX_PATHS;
205
+ }