@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,268 @@
1
+ // One writer at a time for a shared file (a run's live status, a plan): an exclusive lock file, held only for a
2
+ // short synchronous read-check-write. Shared by bridge/ and design-to-code/ (the layer may import bridge/src; bridge
3
+ // cannot import the layer).
4
+ //
5
+ // The lock (`<file>.lock` beside it, or a path the caller names) is created with open(2)'s O_CREAT|O_EXCL (fs flag
6
+ // "wx": "fails if the path exists"; on Windows the same flags map to CreateFileW's CREATE_NEW), so exactly one process
7
+ // creates it. It holds {pid, host, at, token} (+ ns on Linux: the holder's pid namespace), and the creator holds it
8
+ // only once it reads those bytes back after writing them: a lock judged dead while still empty (the instant between
9
+ // the create and the write) and removed is never taken for its own. A waiter polls every POLL_MS
10
+ // with Atomics.wait on a private SharedArrayBuffer (a synchronous sleep: these writes are synchronous, and the runtime lets
11
+ // the main thread block) and gives up after `waitMs` with LockBusy. Every turn of that loop that takes nothing sleeps
12
+ // and counts against `waitMs` — only a turn that removed a dead holder's lock tries again at once.
13
+ //
14
+ // When a lock is a dead holder's:
15
+ // - same machine (host, and on Linux the pid namespace): its pid no longer exists — process.kill(pid, 0) throws
16
+ // for a pid that does not exist and is "a platform independent way to test for the existence of a process";
17
+ // EPERM means it exists under another user. A live holder is waited for however long it holds, up to
18
+ // LIVE_STALE_MS (a dead holder's pid taken by an unrelated process);
19
+ // - another machine (its pid means nothing here): older than `staleMs`;
20
+ // - no readable holder (killed between the create and the write, which are microseconds apart): the file's mtime
21
+ // older than UNWRITTEN_STALE_MS.
22
+ // Breaking one: waiters that judged the same lock dead take turns under a second exclusive file, `<lock>.break`,
23
+ // claimed the same way as the lock (the breaker's stamp, read back); under it the lock is read and judged again and
24
+ // removed only when its bytes are still the ones judged dead, it is still dead (an empty lock's mtime is new again
25
+ // when another process has just created it) and the guard is still this breaker's. Nothing can take the lock while
26
+ // those bytes are there, and only a breaker removes them, so no breaker removes a lock another waiter has taken since.
27
+ // A breaker killed inside its turn leaves its guard, which is judged like a lock: cleared when its breaker is dead
28
+ // (same machine), older than `staleMs` (another host) or never written past UNWRITTEN_STALE_MS — never for its age
29
+ // alone while its breaker lives, so a breaker that is merely descheduled (stopped, swapped out, a sleeping laptop)
30
+ // keeps its turn. A holder whose lock was nevertheless taken (it stalled past LIVE_STALE_MS, or a holder on another
31
+ // host past `staleMs`) finds out with stillMine() before its write; its release never removes the new holder's lock.
32
+ // Not re-entrant: a nested lock of the same file in one process waits for itself until LockBusy. The exclusive
33
+ // flag might not work on a network filesystem (Node's fs docs) — there the lock narrows the window, not closes it.
34
+ import crypto from "node:crypto";
35
+ import fs from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+ import { errCode } from "./errmsg.ts";
39
+
40
+ export interface FileLockOpts {
41
+ /** a lock from another host older than this is a dead holder's (default 30 s — every locked section is a few small
42
+ * file writes) */
43
+ staleMs?: number;
44
+ /** how long to wait for a live holder before LockBusy (default 10 s) */
45
+ waitMs?: number;
46
+ }
47
+ const STALE_MS = 30_000, WAIT_MS = 10_000, POLL_MS = 25;
48
+ /** A same-machine holder whose pid is alive is waited for up to this age; past it the pid is taken for a reused one
49
+ * (Windows reuses pids soon). A locked section is a few small file writes and a stalled holder is caught by
50
+ * assertMine before its write, so minutes is ample. */
51
+ const LIVE_STALE_MS = 2 * 60_000;
52
+ /** A lock or guard with no readable holder past this age was left by a process killed between its create and its write. */
53
+ const UNWRITTEN_STALE_MS = 2_000;
54
+ /** How long a run of Windows create refusals that can be transient (a lock still delete-pending, an antivirus scan)
55
+ * is retried, counted from the first refusal of the run. */
56
+ const TRANSIENT_MS = 2_000;
57
+ const TRANSIENT_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
58
+
59
+ /** The lock of `file`: `<file>.lock` beside it. */
60
+ export const lockFileOf = (file: string): string => file + ".lock";
61
+ /** The guard breakers of `lockFile` take turns under. */
62
+ export const breakGuardOf = (lockFile: string): string => lockFile + ".break";
63
+
64
+ /** The lock stayed held by a live holder for the whole wait — or was taken from this holder (LockLost). */
65
+ export class LockBusy extends Error {
66
+ readonly lockFile: string;
67
+ constructor(lockFile: string, message: string) {
68
+ super(message);
69
+ this.name = "LockBusy";
70
+ this.lockFile = lockFile;
71
+ }
72
+ }
73
+ /** The lock was broken and taken by another writer while this one held it (it held past the stale age). */
74
+ export class LockLost extends LockBusy {
75
+ constructor(lockFile: string) {
76
+ super(lockFile, `${lockFile} was taken over by another writer while this one held it — nothing more written; retry`);
77
+ this.name = "LockLost";
78
+ }
79
+ }
80
+
81
+ /** A taken lock. */
82
+ export interface HeldLock {
83
+ /** remove the lock — only while it is still this holder's */
84
+ release(): void;
85
+ /** whether the lock on disk is still this holder's */
86
+ stillMine(): boolean;
87
+ /** throws LockLost when it is not — called right before a write the lock guards */
88
+ assertMine(): void;
89
+ }
90
+
91
+ /** The pid namespace of this process on Linux (`pid:[<inode>]`), "" elsewhere: two processes with the same hostname
92
+ * in different namespaces (a container on the host's network) cannot see each other's pids. */
93
+ export const PID_NS = ((): string => {
94
+ if (process.platform !== "linux") return "";
95
+ try { return fs.readlinkSync("/proc/self/ns/pid"); } catch { return ""; }
96
+ })();
97
+
98
+ interface Holder { pid: number; host: string; at: number; token: string; ns: string }
99
+ function holderOf(text: string): Holder | null {
100
+ try {
101
+ const v: unknown = JSON.parse(text);
102
+ if (v === null || typeof v !== "object" || !("pid" in v) || !("host" in v) || !("at" in v) || !("token" in v)) return null;
103
+ const { pid, host, at, token } = v;
104
+ const ns = "ns" in v ? v.ns : "";
105
+ // a pid of 0 or below names a process GROUP to process.kill — never probed
106
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && typeof host === "string" && typeof at === "number" && typeof token === "string" && typeof ns === "string"
107
+ ? { pid, host, at, token, ns } : null;
108
+ } catch { return null; }
109
+ }
110
+ /** process.kill(pid, 0): no throw → it exists; EPERM → it exists, owned by another user; anything else (ESRCH) → gone. */
111
+ function pidAlive(pid: number): boolean {
112
+ try { process.kill(pid, 0); return true; } catch (e) { return errCode(e) === "EPERM"; }
113
+ }
114
+ /** Whether this process can probe the holder's pid: same host, same pid namespace. */
115
+ const sameMachine = (h: Pick<Holder, "host" | "ns">): boolean => h.host === os.hostname() && h.ns === PID_NS;
116
+
117
+ /** A process as a lock stamp names it: pid, host and (on Linux) its pid namespace. */
118
+ export interface ProcessStamp { pid: number; host: string; ns?: string }
119
+ /** This process's stamp — for a record that outlives a lock and must say whether its writer still runs (a probe's own run). */
120
+ export const thisProcess = (): ProcessStamp => ({ pid: process.pid, host: os.hostname(), ...(PID_NS ? { ns: PID_NS } : {}) });
121
+ /** The process a stamp names, judged as a lock holder is: "gone" (same machine, the pid no longer exists), "alive" (same
122
+ * machine, the pid exists — or is reused), "unknown" (another host or pid namespace, or no usable pid: nothing to probe). */
123
+ export function processState(p: ProcessStamp): "gone" | "alive" | "unknown" {
124
+ if (!Number.isInteger(p.pid) || p.pid <= 0 || !sameMachine({ host: p.host, ns: p.ns ?? "" })) return "unknown";
125
+ return pidAlive(p.pid) ? "alive" : "gone";
126
+ }
127
+ const read = (file: string): string | null => { try { return fs.readFileSync(file, "utf8"); } catch { return null; } };
128
+ /** How far `t` (ms since the epoch) is from now, either way: a clock that jumped back must not keep a dead lock forever. */
129
+ const ageOf = (t: number): number => Math.abs(Date.now() - t);
130
+ const describeHolder = (text: string | null): string => {
131
+ const h = text === null ? null : holderOf(text);
132
+ return h ? `process ${h.pid}${h.host !== os.hostname() ? ` on ${h.host}` : ""} since ${new Date(h.at).toISOString()}` : "another process";
133
+ };
134
+
135
+ /** Whether the lock or break guard whose bytes are `text` is a dead holder's (see the header). */
136
+ function isStale(file: string, text: string, staleMs: number): boolean {
137
+ const h = holderOf(text);
138
+ if (h === null) {
139
+ try { return ageOf(fs.statSync(file).mtimeMs) > Math.min(staleMs, UNWRITTEN_STALE_MS); } catch { return false; } // gone: the next open takes it
140
+ }
141
+ const state = processState(h);
142
+ if (state !== "unknown") return state === "gone" || ageOf(h.at) > Math.max(staleMs, LIVE_STALE_MS);
143
+ return ageOf(h.at) > staleMs;
144
+ }
145
+
146
+ /** This process's stamp for a lock or guard it creates. `at` is the moment it is written: the age a waiter compares. */
147
+ const stampOf = (token: string): string => JSON.stringify({ ...thisProcess(), at: Date.now(), token });
148
+ /**
149
+ * Create `file` exclusively and write this process's stamp into it. Returns the stamp when the file on disk holds it
150
+ * once written, null when it does not: the file was judged a dead holder's while still empty and removed (another
151
+ * process may have created it again since) — then it is not this process's and nothing is removed. Throws what the
152
+ * create throws (EEXIST: someone else's), and what the write throws (the file removed first: never one naming no one).
153
+ */
154
+ function claim(file: string, token: string): string | null {
155
+ const fd = fs.openSync(file, "wx");
156
+ const mine = stampOf(token);
157
+ try { fs.writeSync(fd, mine); } catch (e) {
158
+ fs.closeSync(fd);
159
+ fs.rmSync(file, { force: true });
160
+ throw e;
161
+ }
162
+ fs.closeSync(fd);
163
+ return read(file) === mine ? mine : null;
164
+ }
165
+
166
+ /**
167
+ * Remove the dead holder's lock `lockFile` whose bytes were `seen` — under its break guard, and only when the bytes are
168
+ * still `seen` then, still a dead holder's, and the guard still this breaker's. True when it removed it; false when the
169
+ * guard is another breaker's (a dead breaker's is cleared for the next turn) or the lock changed since (released, or
170
+ * taken and released again, or an empty one just created again). Throws what creating the guard or removing the lock
171
+ * throws (EACCES in a directory this process may not write). Exported for its test.
172
+ */
173
+ export function breakStale(lockFile: string, seen: string, staleMs: number = STALE_MS): boolean {
174
+ const guard = breakGuardOf(lockFile);
175
+ let mine: string | null;
176
+ try { mine = claim(guard, crypto.randomBytes(8).toString("hex")); } catch (e) {
177
+ if (errCode(e) !== "EEXIST") throw e;
178
+ // another breaker's turn: cleared only when that breaker is dead, and only while the guard still holds the bytes
179
+ // judged dead (a guard cleared and claimed again by a live breaker meanwhile is left)
180
+ const g = read(guard);
181
+ if (g !== null && isStale(guard, g, staleMs) && read(guard) === g) try { fs.unlinkSync(guard); } catch { /* gone, or cleared by another waiter */ }
182
+ return false;
183
+ }
184
+ if (mine === null) return false; // the guard was cleared while still empty: not this breaker's turn
185
+ try {
186
+ if (read(lockFile) !== seen || !isStale(lockFile, seen, staleMs)) return false;
187
+ // the guard is judged like a lock, so a breaker stalled past it may have lost its turn: then another breaker can
188
+ // be inside its own, and this one leaves the lock to it
189
+ if (read(guard) !== mine) return false;
190
+ try { fs.unlinkSync(lockFile); } catch (e) { if (errCode(e) === "ENOENT") return false; throw e; }
191
+ return true;
192
+ } finally {
193
+ // only this breaker's own guard: one cleared and claimed by another breaker is theirs
194
+ if (read(guard) === mine) try { fs.rmSync(guard, { force: true }); } catch { /* left: cleared once its breaker is gone */ }
195
+ }
196
+ }
197
+
198
+ /** Whether `code`, refusing a create, can be transient: on Windows a file removed while another process still has it
199
+ * open stays delete-pending until that handle closes, and creating it again meanwhile is refused
200
+ * (ERROR_ACCESS_DENIED → EPERM); an antivirus scan can do the same. */
201
+ const maybeTransient = (code: string | undefined): boolean => process.platform === "win32" && code !== undefined && TRANSIENT_CODES.has(code);
202
+
203
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
204
+ const sleepSync = (ms: number): void => { Atomics.wait(sleeper, 0, 0, ms); };
205
+
206
+ /**
207
+ * Take the lock `lockFile` (its directory is created when missing). Throws LockBusy when a live holder keeps it for
208
+ * `waitMs`; when the lock was a dead holder's that could not be removed for the whole wait, what removing it threw
209
+ * (EACCES in a read-only directory…); any other error creating it (EACCES, EROFS…) as it is — on Windows a refusal
210
+ * that can be transient only once a run of them has lasted TRANSIENT_MS (a wait that ends sooner is LockBusy).
211
+ */
212
+ export function acquireFileLock(lockFile: string, opts: FileLockOpts = {}): HeldLock {
213
+ const staleMs = opts.staleMs ?? STALE_MS, waitMs = opts.waitMs ?? WAIT_MS;
214
+ const token = crypto.randomBytes(8).toString("hex");
215
+ fs.mkdirSync(path.dirname(path.resolve(lockFile)), { recursive: true });
216
+ const t0 = Date.now();
217
+ /** when the current run of possibly transient refusals (creating the lock or its break guard) began */
218
+ let firstTransientAt: number | null = null;
219
+ /** true while a possibly transient refusal has not yet lasted TRANSIENT_MS: retried like a held lock */
220
+ const transientYet = (e: unknown): boolean => {
221
+ if (!maybeTransient(errCode(e))) return false;
222
+ firstTransientAt ??= Date.now();
223
+ return Date.now() - firstTransientAt < TRANSIENT_MS;
224
+ };
225
+ for (;;) {
226
+ let mine: string | null = null, text: string | null = null, transient = false;
227
+ /** why this turn took nothing other than a live holder: thrown instead of LockBusy when the wait runs out */
228
+ let refused: unknown = null;
229
+ try { mine = claim(lockFile, token); } catch (e) {
230
+ const code = errCode(e);
231
+ if (code === "EEXIST") {
232
+ text = read(lockFile); // null: released since, or unreadable — waited on like a live holder
233
+ if (text !== null && isStale(lockFile, text, staleMs)) {
234
+ try { if (breakStale(lockFile, text, staleMs)) continue; } catch (be) {
235
+ if (transientYet(be)) transient = true;
236
+ else refused = be;
237
+ }
238
+ }
239
+ } else if (transientYet(e)) transient = true;
240
+ else throw e;
241
+ }
242
+ if (!transient) firstTransientAt = null;
243
+ if (mine !== null) {
244
+ const held = mine;
245
+ const stillMine = (): boolean => read(lockFile) === held;
246
+ return {
247
+ // only our own lock: one broken as stale and taken by another holder is theirs now
248
+ release: () => { if (stillMine()) try { fs.rmSync(lockFile, { force: true }); } catch { /* left: stale once this process is gone */ } },
249
+ stillMine,
250
+ assertMine: () => { if (!stillMine()) throw new LockLost(lockFile); },
251
+ };
252
+ }
253
+ const waited = Date.now() - t0;
254
+ if (waited >= waitMs) {
255
+ if (refused !== null) throw refused;
256
+ if (transient) throw new LockBusy(lockFile, `${lockFile} could not be created for ${Math.round((Date.now() - (firstTransientAt ?? t0)) / 100) / 10}s (a lock just released is still being removed, or a scan holds it); retry`);
257
+ throw new LockBusy(lockFile, `${lockFile} is held by ${describeHolder(text)} — another write was still in progress after ${Math.round(waited / 100) / 10}s; retry once it is done`);
258
+ }
259
+ sleepSync(Math.min(POLL_MS, waitMs - waited));
260
+ }
261
+ }
262
+
263
+ /** Run `fn` (synchronous) holding the lock `lockFile`; the lock is released however `fn` ends. `fn` gets the lock, to
264
+ * check it is still its own right before the write it guards. */
265
+ export function withFileLock<T>(lockFile: string, fn: (lock: HeldLock) => T, opts: FileLockOpts = {}): T {
266
+ const lock = acquireFileLock(lockFile, opts);
267
+ try { return fn(lock); } finally { lock.release(); }
268
+ }
@@ -0,0 +1,62 @@
1
+ // file-memo.ts — a value computed from a file's contents, remembered across pulls in one process while the file
2
+ // is provably unchanged. A process that pulls many screens (the daemon, the MCP server, a multi-screen CLI run)
3
+ // would otherwise re-read every file a pull consults on every pull: the shared assets/ directory
4
+ // (asset-compare.ts FileKeyCache) and every other screen's .assets.json (write-out.ts otherScreenTrees). The
5
+ // memo lives in memory only: a sidecar file in the export would have to be trusted across processes and dtwin
6
+ // versions (a changed rule would silently keep old values), and the one-shot CLI pulls few screens per process.
7
+ //
8
+ // A remembered value is used only while the file's identity and status are unchanged: size, mtime, ctime, inode
9
+ // and device all equal to what they were when the value was computed. ctime is set by the kernel on every write,
10
+ // rename-over or utimes call and cannot be set back by a user, so a file rewritten with the same size and a
11
+ // preserved or restored mtime still misses; a file replaced by rename (atomic-write.ts) has a new inode as well.
12
+ // Timestamp precision is platform specific (whole seconds on some file systems, coarse ctime steps on Windows),
13
+ // so a rewrite within the same tick could keep every field equal; a value is therefore stored only for a file
14
+ // whose mtime and ctime are both at least `recentMs` (default 2 s) in the past — a file still being written to,
15
+ // or written in the last tick, is always read again. The stat is read as bigint: a JavaScript number holds
16
+ // integers exactly only up to 2^53, and inode numbers above that (NTFS file ids, some NFS and exFAT volumes) would
17
+ // round, so two different files could compare as one; nanosecond times come with it.
18
+ import fs from "node:fs";
19
+
20
+ interface Entry<V> { size: bigint; mtimeNs: bigint; ctimeNs: bigint; ino: bigint; dev: bigint; value: V }
21
+ export interface FileMemoOptions { recentMs?: number; maxEntries?: number; now?: () => number }
22
+
23
+ export class FileMemo<V> {
24
+ private readonly entries = new Map<string, Entry<V>>();
25
+ private readonly recentMs: number;
26
+ private readonly maxEntries: number;
27
+ private readonly now: () => number;
28
+ constructor(o: FileMemoOptions = {}) {
29
+ this.recentMs = o.recentMs ?? 2000;
30
+ this.maxEntries = o.maxEntries ?? 50_000;
31
+ this.now = o.now ?? Date.now;
32
+ }
33
+
34
+ get size(): number { return this.entries.size; }
35
+ clear(): void { this.entries.clear(); }
36
+
37
+ /** `compute()` for `file` — it reads the file itself — or the value it gave while the file is unchanged.
38
+ * undefined when `file` cannot be stat'ed (compute is not called) or when compute returns undefined (nothing is
39
+ * stored). A path that is not a regular file is computed every time and never stored. */
40
+ get(file: string, compute: () => V | undefined): V | undefined {
41
+ // The clock is read before the stat, so the window is measured from no later than the moment the file was seen.
42
+ const now = this.now();
43
+ let st: fs.BigIntStats;
44
+ try { st = fs.statSync(file, { bigint: true }); } catch { return undefined; }
45
+ if (!st.isFile()) return compute();
46
+ const hit = this.entries.get(file);
47
+ if (hit && hit.size === st.size && hit.mtimeNs === st.mtimeNs && hit.ctimeNs === st.ctimeNs && hit.ino === st.ino && hit.dev === st.dev) {
48
+ this.entries.delete(file); this.entries.set(file, hit); // most recently used last
49
+ return hit.value;
50
+ }
51
+ this.entries.delete(file);
52
+ const value = compute();
53
+ if (value === undefined) return undefined;
54
+ // `st` is from before the read: a change between the two moves ctime past it, so the next stat misses.
55
+ const changedNs = st.mtimeNs > st.ctimeNs ? st.mtimeNs : st.ctimeNs;
56
+ if (changedNs <= BigInt(Math.floor(now - this.recentMs)) * 1_000_000n) {
57
+ this.entries.set(file, { size: st.size, mtimeNs: st.mtimeNs, ctimeNs: st.ctimeNs, ino: st.ino, dev: st.dev, value });
58
+ for (const oldest of this.entries.keys()) { if (this.entries.size <= this.maxEntries) break; this.entries.delete(oldest); }
59
+ }
60
+ return value;
61
+ }
62
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,6 @@
1
+ // Hex digests, shared by bridge/ and design-to-code/ (the layer may import bridge/src; bridge cannot import
2
+ // the layer). sha1Hex, the asset-content hash, lives with the asset comparison in asset-compare.ts.
3
+ import crypto from "node:crypto";
4
+
5
+ /** sha256 of a string (hashed as UTF-8) or of bytes, as lowercase hex. */
6
+ export const sha256Hex = (data: string | Uint8Array): string => crypto.createHash("sha256").update(data).digest("hex");
@@ -0,0 +1,39 @@
1
+ // The hex colour a figma_write op carries (`fill`, `color`): validated at both ends — the MCP
2
+ // schema (figma-mcp.ts) and the plugin's write executor (figma-plugin/src/writes.ts) — so a value that is not a
3
+ // colour is refused instead of being painted as a wrong one (a lenient parse turns "red" into #rreedd
4
+ // and every unparseable channel into 0). Also the one hex FORMATTER (formatHex), used by the plugin for every
5
+ // exported colour and by design-to-code/color.ts. Dependency-free: the plugin bundle imports it too, and
6
+ // design-to-code imports it from here (bridge/src never imports design-to-code).
7
+
8
+ /** `#` then 3, 4, 6 or 8 hex digits (#rgb, #rgba, #rrggbb, #rrggbbaa). The `#` is required: "bad" or
9
+ * "fade" are words as often as colours. */
10
+ export const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
11
+
12
+ /** What an error says the value should look like. */
13
+ export const HEX_COLOR_HINT = "a hex colour like #1A2B3C (#rgb, #rgba, #rrggbb or #rrggbbaa)";
14
+
15
+ /** Channels 0..1; `a` is present only when the value carried an alpha digit pair. */
16
+ export interface HexColor { r: number; g: number; b: number; a?: number }
17
+
18
+ /** Parse a hex colour (see HEX_COLOR_RE), or null when the value is anything else. Shorthand doubles each digit. */
19
+ export function parseHexColor(value: unknown): HexColor | null {
20
+ if (typeof value !== "string" || !HEX_COLOR_RE.test(value)) return null;
21
+ let h = value.slice(1);
22
+ if (h.length <= 4) h = h.split("").map((c) => c + c).join("");
23
+ const chan = (i: number): number => parseInt(h.slice(i, i + 2), 16) / 255;
24
+ const c: HexColor = { r: chan(0), g: chan(2), b: chan(4) };
25
+ if (h.length === 8) c.a = chan(6);
26
+ return c;
27
+ }
28
+
29
+ /** 0–255 channels plus alpha 0–1 — the shape a colour is formatted from. */
30
+ export interface Rgba { r: number; g: number; b: number; a: number }
31
+
32
+ /** An Rgba as "#rrggbb" (alpha rounds to 255) or "#rrggbbaa", lowercase — channels clamped and rounded to
33
+ * 0–255. The one hex formatter: the plugin writes every exported colour with it, design-to-code (color.ts)
34
+ * every colour it computes, so an opaque colour is always six digits whoever wrote it. */
35
+ export function formatHex(c: Rgba): string {
36
+ const to = (x: number): string => Math.round(Math.min(255, Math.max(0, x))).toString(16).padStart(2, "0");
37
+ const a = Math.round(c.a * 255);
38
+ return "#" + to(c.r) + to(c.g) + to(c.b) + (a < 255 ? to(a) : "");
39
+ }
@@ -0,0 +1,7 @@
1
+ // human-ms.ts — a duration for a log line. A leaf (no imports), so the daemon's served session can say how long it
2
+ // idled without importing settings.ts, which imports the daemon module.
3
+
4
+ /** An idle window for a log line: whole minutes from one minute up, else whole seconds. */
5
+ export function humanMs(ms: number): string {
6
+ return ms >= 60000 ? `${Math.round(ms / 60000)} min` : `${Math.round(ms / 1000)}s`;
7
+ }