From 2f12cdd696ab21f41d895193b1e730f1631e0d53 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:20:14 -0700 Subject: [PATCH 01/56] spec(figmog): fold-backed local Figma file mirror design Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-15-figmog-build-design.md | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-15-figmog-build-design.md diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md new file mode 100644 index 0000000..c2052ea --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -0,0 +1,444 @@ +# figmog — a fold-backed local mirror of a Figma file + +**Date:** 2026-08-15 +**Status:** Draft for review +**Crate:** `examples/figmog` +**Target file (dev/manual testing):** `flAtUnMfzvA5daBSTFQK35` (g3d-Index-Web-Handoff) + +## 1. Problem + +Figma's MCP server and REST API are slow and aggressively rate limited. +Since the November 2025 rate-limit overhaul, the file endpoints +(`GET /v1/files/:key`, `.../nodes`, `/v1/images`) are Tier 1: **~10 +requests/min on the free (Starter) plan**. There is no delta API — the only +way to see what changed is to re-download the entire multi-MB document tree. +Webhooks are unavailable on the free plan, and `FILE_UPDATE` is debounced up +to 30 minutes anyway. + +The result: every agent interaction with a Figma file re-pays a huge fetch +through a stingy per-minute bucket. + +## 2. Goal + +**Lightning-fast local reads of one Figma file for agents.** A sync engine +pulls the file when it changes (spending Tier-1 budget only on actual +changes) and maintains materialized indexes in a fold database. A CLI reads +those indexes locally — point lookups, tree walks, full-text search, +design-system queries — with zero Figma calls and zero rate limits. + +Fold's `KeyedStream` upsert semantics synthesize the delta API Figma lacks: +re-upserting a byte-identical record causes **zero graph churn**; a changed +record is retracted from and re-inserted into every index atomically. A +10,000-node file where one layer was renamed costs one fetch plus one +retract/insert pair. + +First-class support for Figma's design-system features — components, +component sets, variants and their property axes, styles, bound-variable +references — because a later layer will sync this design system into +Tailwind. That layer is **out of scope** here; this spec only guarantees the +data it needs is mirrored and queryable. + +### Non-goals (v1) + +- Image renders / thumbnails. +- MCP server (v2; it becomes a second binary over the same DB — the schema + is designed so this needs no migration). +- Multi-file / team mirroring (the store layout is per-file-key, so this is + additive later). +- Embeddings / HNSW semantic search (BM25 over names + text is enough for + v1; an ese branch can be added to the pipeline later; keeping ese out + keeps builds fast). +- Writing back to Figma. +- Dev Mode extras (annotations, measurements, dev resources, Code + Connect) — not needed by the Tailwind layer. +- Calling the Enterprise-only Variables REST endpoints. Variables are + still fully supported — see §6a for how, on a free plan. + +## 3. Architecture + +Three units with one shared type vocabulary: + +``` + ┌─────────────┐ file JSON ┌──────────────┐ (Id, Rec) recs ┌───────────────┐ +Figma ───▶│ api (HTTP) │──────────────▶│ flatten │─────────────────▶│ store (fold) │ + └─────────────┘ └──────────────┘ └───────┬───────┘ + ▲ ▲ │ rtx (local) + pull │ │ watch (poll Tier-3 metadata; Tier-1 fetch only on change) ▼ + ┌──┴────┴─────────────────────────────────────────────────────────────────────┐ + │ cli │ + │ pull · watch · tree · get · search · instances · components · styles · … │ + └─────────────────────────────────────────────────────────────────────────────┘ +``` + +- **`api`** — thin HTTP client behind a `FigmaApi` trait (so every other + unit is testable without a network). Two calls: `file_meta(key)` (Tier 3, + cheap, for change detection) and `file(key)` (Tier 1, the full document). + Honors `Retry-After` on 429. +- **`flatten`** — pure function: file JSON → deterministic + `Vec<(Id, Rec)>`. All parsing, variant-name parsing, and canonicalization + lives here. No I/O. +- **`store`** — owns the fold `KeyedStream` and pipeline definition, the + sync transaction (upsert + remove-vanished + meta bump, all in one `wtx`), + and typed read helpers. +- **`cli`** — clap-based command surface; every read command supports + `--json` for agents and a human-readable default. + +### Module map + +``` +examples/figmog/src/ + main.rs — arg parsing, dispatch, process exit codes + api.rs — FigmaApi trait, UreqApi impl, RateLimited/Http errors + flatten.rs — file JSON -> Vec<(Id, Rec)>; pure, deterministic + model.rs — Id, Rec, NodeRec, ComponentRec, ComponentSetRec, StyleRec, + VariableRec, VariableCollectionRec, FileMeta + store.rs — pipeline construction, open(), sync(), read helpers + cli.rs — subcommand impls, output formatting (human + JSON) +tests/ + flatten.rs — unit tests over fixtures + sync.rs — churn/diff/removal tests over fixtures + cli.rs — CLI smoke tests against a fixture-built DB + fixtures/ — small SYNTHETIC file JSONs (see §9 on provenance) +``` + +## 4. Data model + +One `KeyedStream` over one fjall store at `.figmog//`. +Everything the sync writes — nodes, design-system metadata, and the file +meta row — flows through the same stream, so a sync commits **atomically**: +readers never observe a half-applied pull or a version number ahead of its +data. + +```rust +enum Id { Node(String), Component(String), ComponentSet(String), Style(String), + Variable(String), VariableCollection(String), Meta } +enum Rec { Node(NodeRec), Component(ComponentRec), ComponentSet(ComponentSetRec), + Style(StyleRec), Variable(VariableRec), + VariableCollection(VariableCollectionRec), Meta(FileMeta) } +``` + +`NodeRec` (one per node in the document tree, keyed by Figma's stable node +id): + +| field | type | notes | +|---|---|---| +| `id` | `String` | Figma node id, e.g. `"12:34"` | +| `parent_id` | `Option` | `None` only for the document root | +| `child_index` | `u32` | position within parent | +| `page_id` | `String` | enclosing CANVAS id (root/pages: own id) | +| `node_type` | `String` | `FRAME`, `TEXT`, `COMPONENT`, `COMPONENT_SET`, `INSTANCE`, … | +| `name` | `String` | layer name | +| `visible` | `bool` | absent in JSON ⇒ `true` | +| `text` | `Option` | `characters` for TEXT nodes | +| `component_id` | `Option` | INSTANCE → its component's node id | +| `component_properties` | `Vec<(String, String)>` | INSTANCE `componentProperties` assignments (variant values, booleans, text, instance swaps), **sorted by key**; values stringified | +| `property_definitions` | `Option` | `componentPropertyDefinitions` as canonical JSON — present on **both** COMPONENT and COMPONENT_SET nodes (property types: VARIANT, BOOLEAN, TEXT, INSTANCE_SWAP) | +| `style_refs` | `Vec<(String, String)>` | node `styles` map as (style_type, style_id), **sorted** — style types FILL, TEXT, EFFECT, GRID | +| `bound_variables` | `Vec<(String, String)>` | every variable binding in this node's JSON as (json_path, variable_id), **sorted** — collected by a generic recursive scan for `boundVariables` objects (robust to Figma adding bindable properties), not per-property typed extraction | +| `abs_bounds` | `Option<[f64; 4]>` | absoluteBoundingBox x, y, w, h | +| `raw` | `String` | canonical JSON of the node **with `children` stripped** — full fidelity for `get` | + +`ComponentRec` / `ComponentSetRec` (from the file response's `components` / +`componentSets` maps, keyed by node id): `node_id`, `key` (global key), +`name`, `description`, `remote` (library component used by an instance vs +defined locally), and for components `component_set_id: Option`. +`StyleRec` (from `styles`, keyed by style id): `style_id`, `key`, `name`, +`style_type`, `description`, `remote`. `FileMeta`: `name`, `version`, +`last_touched_at`, `synced_at_unix_ms`. + +`VariableRec` / `VariableCollectionRec` (populated by `import-variables` +only — see §6a): variable `id`, `name`, `resolved_type` +(COLOR/FLOAT/STRING/BOOLEAN), `collection_id`, `values_by_mode` as +canonical JSON (values or `VARIABLE_ALIAS` refs), `description`, +`scopes`; collection `id`, `name`, `modes` as sorted (mode_id, mode_name) +pairs, `default_mode_id`. + +Variant support falls out of this model: a COMPONENT_SET node's variants are +its COMPONENT children (the `children` index gives the axis), the set's +`property_definitions` carries the axes/options, and each INSTANCE's +`component_properties` carries its chosen variant values. `instances_of` +answers "every usage of this component"; joining through +`ComponentRec.component_set_id` answers "every usage of any variant of this +set". + +### Determinism (byte-equality contract) + +`KeyedStream::upsert` detects change by comparing postcard bytes, so +`flatten` must be a pure, deterministic function of the file JSON: + +- Every map-shaped field is stored as a **sorted `Vec` of pairs**, never a + `HashMap`. +- `raw` / `property_definitions` are re-serialized through `serde_json` + **without** the `preserve_order` feature, so object keys are + BTreeMap-sorted and canonical. +- No timestamps, randomness, or environment reads inside `flatten`. + (`FileMeta.synced_at_unix_ms` is the one wall-clock value and lives only + in the meta row, which is expected to change every sync.) + +## 5. Pipeline + +```rust +KeyedStream::new(db_path, ( + // -- node branch: FilterMap Keyed -> Keyed -- + FilterMap(node_only, ( + terminal::Table::new("nodes"), // id -> NodeRec + Map(|n| Keyed::new(n.parent_id?, (n.child_index, n.id)), + terminal::Multimap::new("children")), // parent -> (idx, child) + FilterMap(|n| non_empty(name + text), + terminal::search::Bm25::new("text")), // full-text + FilterMap(|n| n.component_id.map(|c| Keyed::new(n.id, c)), + terminal::InvertedIndex::new("instances_of")), // component id -> instances + FlatMap(|n| n.style_refs -> Keyed::new(n.id, style_id), + terminal::InvertedIndex::new("styled_by")), // style id -> nodes + FlatMap(|n| n.bound_variables -> Keyed::new(n.id, variable_id), + terminal::InvertedIndex::new("bound_to")), // variable id -> nodes + Map(|n| Keyed::new(n.id, n.node_type), + terminal::InvertedIndex::new("by_type")), // type -> nodes + )), + // -- design-system branch -- + FilterMap(component_only, terminal::Table::new("components")), + FilterMap(component_set_only, terminal::Table::new("component_sets")), + FilterMap(style_only, terminal::Table::new("styles")), + FilterMap(variable_only, terminal::Table::new("variables")), + FilterMap(collection_only, terminal::Table::new("variable_collections")), + // -- meta branch -- + FilterMap(meta_only, terminal::Table::new("meta")), +)) +``` + +(Pseudocode: real code uses fold's `FilterMap::new(closure, next)` forms. +The point is the shape: one stream, typed branches, seven node sinks.) + +Notes: + +- `children` values sort by postcard encoding, which is **not** numeric + order for varint-encoded `u32` — readers sort the returned `Vec` by + `child_index` before output. Alternatively `child_index` is stored + big-endian-encoded; decision left to implementation, but output order + must be numeric. +- All closures are pure functions of the record, as fold requires for + retraction to cancel. +- Sink names are frozen here; they are part of the on-disk schema. Any + rename is a breaking change requiring a re-pull (acceptable v1 policy: + `figmog pull --fresh` wipes and rebuilds). + +## 6. Sync engine + +**`pull`** — one full refresh, one `wtx`: + +1. `api.file(key)` — single Tier-1 request. No `geometry=paths` (vector + outlines bloat the payload and serve no v1 query). +2. `flatten` → `Vec<(Id, Rec)>` + the set of live `Id`s. +3. Read the currently stored id set (snapshot read of the nodes, + components, component_sets, and styles tables). **Variable and + collection records are exempt from the sweep** — they come from + `import-variables`, not the file fetch, and must survive pulls. +4. In one `wtx`: `upsert` every flattened record (unchanged records + short-circuit inside fold), `remove` every stored sweepable id not in + the live set, upsert `FileMeta`. +5. Print a churn summary (added / changed / removed counts — obtained by + counting `upsert`/`remove` return values, which report the prior + record). + +**`watch`** — the polling loop: + +``` +loop: + meta = api.file_meta(key) # Tier 3: 50/min budget on Starter + if meta.last_touched_at != stored: # documented as content-modification time + pull() + sleep(interval) # default 10s, --interval to change +``` + +- A spurious trigger (touch without a real edit) costs one Tier-1 fetch + that produces zero churn — the design is self-healing, so the trigger + needs to be cheap, not perfect. +- On 429: sleep `Retry-After` seconds (plus small jitter), then resume. + On network errors: exponential backoff capped at 5 min, keep looping — + `watch` must survive laptop sleep and flaky wifi. +- `watch` performs an initial `pull` if the DB is empty or stale. + +Auth: personal access token from `FIGMA_TOKEN` (flag `--token` overrides). +File identity: accept a bare key, a full `figma.com/design/...` URL, or a +URL with `?node-id=`; node ids accept both `12:34` and `12-34` forms +everywhere. + +### 6a. Variables & design tokens on a free plan + +The Variables REST endpoints (`/v1/files/:key/variables/local`, `/published`) +are **Enterprise-only**, so figmog never calls them. Variables are supported +through two complementary paths: + +**Path 1 — mirrored bindings + inference (always on, zero setup).** The +file JSON annotates every variable-bound property with a `boundVariables` +object, and Figma also bakes the *resolved* concrete value into the same +node (the fill's color, the padding's number, …). Flatten collects every +binding as `(json_path, variable_id)` on `NodeRec` (generic recursive +scan), and the `bound_to` index inverts them. `figmog vars` then +aggregates at read time: for each variable id — its binding sites +(node, property path) and the *observed values* extracted from the +consumers' `raw` JSON next to each binding. This yields +`variable id → inferred value(s) + everywhere it's used`, which covers the +default mode (values from non-default modes appear only where a frame +explicitly overrides the mode — documented caveat). + +**Path 2 — authoritative import (optional).** `figmog import-variables +` upserts `VariableRec` / `VariableCollectionRec` — full +fidelity: collections, modes (light/dark), per-mode values, aliases, +scopes. Accepted shapes: the Enterprise REST `variables/local` response, +or the JSON produced by the standard free-plan escape hatch — a Figma +plugin export (the Plugin API can read local variables on **any** plan; +figmog's README ships a ~20-line "run in Figma's plugin console" snippet +that dumps the REST-shaped JSON). Imports flow through the same +`KeyedStream`, so re-imports diff incrementally like everything else and +`figmog vars` prefers authoritative records over inference when present. + +Everything else the Tailwind layer needs is already mirrored at full +fidelity in `raw` and queryable through the indexes: TypeStyle (font +family/size/weight/line-height/letter-spacing), fills/strokes/effects, +auto-layout (layoutMode, padding, itemSpacing, sizing), corner radii, +grids, `characterStyleOverrides`/`styleOverrideTable` for mixed text, and +component property definitions. Style *definitions* are not in the file +JSON (the `styles` map is metadata only), so `figmog styles --values` +derives each style's value from its `styled_by` consumers — e.g. a text +style's TypeStyle from any TEXT node using it. + +## 7. CLI surface + +Read commands never touch the network. All output ordering is +deterministic (sorted); `--json` emits machine-readable JSON on stdout. + +| command | reads | behavior | +|---|---|---| +| `figmog pull ` | — | sync now; prints churn summary | +| `figmog watch [--interval 10s]` | — | poll loop as above | +| `figmog pages` | children of root | list CANVAS pages (id, name) | +| `figmog tree [id] [--depth N]` | children + nodes | indented outline: `name [type] id`; root defaults to document | +| `figmog get [--children]` | nodes (+children) | the full `raw` JSON of a node; `--children` inlines one level of child summaries | +| `figmog search [-n 10]` | Bm25 + nodes | ranked hits: score, id, type, name, page, text snippet | +| `figmog instances ` | components + instances_of | resolve arg to a component (by node id, global key, or unique name / name of a set ⇒ all its variants), list instance nodes | +| `figmog components` | components + component_sets + children | design-system inventory: sets with their variant axes/options, standalone components | +| `figmog styles [--type text\|fill\|effect\|grid] [--values]` | styles + styled_by (+nodes) | styles with usage counts; `--values` derives each style's definition from consumer nodes (§6a) | +| `figmog uses ` | styled_by / bound_to + nodes | nodes using a style or bound to a variable | +| `figmog vars [id]` | bound_to + nodes + variables | variables: authoritative records if imported, else inferred values + binding sites (§6a) | +| `figmog import-variables ` | — | upsert variable/collection records (§6a Path 2) | +| `figmog find --type TEXT [--page id]` | by_type + nodes | nodes by type, optional page filter | +| `figmog status` | meta | file name, version, last modified, last synced, node count | + +DB location: `.figmog//` under the current directory (override +`--db`). The CLI stores the last-used file key in `.figmog/config` so read +commands don't need the file argument every time. + +## 8. Rust practices + +- **Errors:** `thiserror` error enums per module; no `unwrap`/`expect` on + network, parse, or user-input paths. `unwrap` is acceptable only where + fold itself panics by contract (store open, duplicate sink names) and in + tests. CLI exits nonzero with a one-line message on error; `--json` mode + errors are JSON on stderr. +- **Testability by construction:** `FigmaApi` is a trait; `flatten` is + pure; `store::sync` takes pre-flattened records. The `watch` loop takes + its sleep function as a parameter. No test needs a network or a real + clock. +- **Typed edges, dynamic core:** the response envelope (`name`, `version`, + `components`, `styles`, …) parses into serde structs; node trees parse as + `serde_json::Value` (the node schema is huge and mostly passed through + `raw`), with typed extraction only for the `NodeRec` fields. Exact field + names pinned against `figma/rest-api-spec` (OpenAPI) during + implementation. +- **Determinism everywhere:** no `HashMap` iteration at any output + boundary (matches the repo's standing rule); sorted `Vec`s in records; + canonical JSON; CLI output sorted. +- **Hygiene:** `cargo clippy -- -D warnings` and `cargo fmt --check` + clean; rustdoc on every public item; crate-level doc comment explaining + the pipeline (matching the style of the `search` example). +- Workspace: scaffolded via `./scripts/new-project.sh figmog`; deps + `fold`, `serde`, `serde_json`, `postcard` (transitively via fold), + `ureq`, `clap`, `thiserror`. **No `ese`** (build speed), no tokio (ureq + is blocking; the watch loop is a plain thread). + +## 9. Test plan + +Fixtures are **synthetic** miniature Figma file JSONs (hand-written, +~30–60 nodes) exercising: 3 pages, nested frames, TEXT nodes, a +COMPONENT_SET with two axes (e.g. `Size` × `State`), variant COMPONENT +children, INSTANCEs with `componentProperties` covering all four property +types, standalone COMPONENT with non-variant properties, fill/text style +refs, `boundVariables` refs at several depths, an invisible node — plus a +separate REST-shaped variables-export fixture for `import-variables`. **No +fixture may be derived from the g3d file** — it is client work and stays +out of git (same policy as the repo's garden3d-corpus rule). The real file +is used only for local manual verification. + +1. **Flatten unit tests** (`tests/flatten.rs`) + - ids/parents/child_index/page attribution correct for the whole fixture + - TEXT `characters` extracted; visibility default handling + - INSTANCE → `component_id` + sorted `component_properties` (all four + property types: VARIANT, BOOLEAN, TEXT, INSTANCE_SWAP) + - `property_definitions` canonical JSON on both COMPONENT and + COMPONENT_SET nodes + - style refs sorted and complete + - bound-variable scan finds bindings at every depth (a fill, a + TypeStyle field, a padding, a deeply nested property) and is + unaffected by unknown/new binding sites + - `raw` has no `children` key; parses back to JSON + - **Determinism:** flatten the same JSON twice (and a key-order-shuffled + copy of it) → identical postcard bytes per record. +2. **Sync tests** (`tests/sync.rs`) — pipeline built with a test-only + delta-probe (a passthrough `Map` incrementing a `Rc>`) + between stream and sinks: + - **No-churn:** pull fixture, pull identical fixture again → probe count + 0 in the second `wtx`; all sink contents identical. + - **Minimal-churn diff:** fixture v1 → v2 (one rename, one node + deleted, one added, one instance's variant property changed) → probe + count equals exactly the expected retract+insert pairs; Bm25 no + longer matches the old name but matches the new one; deleted node + absent from `nodes`, `children`, `by_type`; new node present + everywhere applicable. + - **Removal cascade:** delete an INSTANCE → it disappears from + `instances_of`; delete a styled node → `styled_by` count drops. + - **Vanished-id sweep:** node present in store but absent from fetch is + removed even when nothing else changed. + - **Atomicity:** a flatten record that panics mid-`wtx` (injected) leaves + the store at the previous version (meta unchanged, old data readable). +3. **Watch-loop tests** (unit, fake `FigmaApi` + recorded sleeps) + - unchanged metadata → no `file()` call + - changed metadata → exactly one `file()` call, then quiescent + - 429 with `Retry-After: 30` → recorded sleep ≥ 30s, loop continues + - network error → backoff grows, caps, loop continues +4. **Design-token tests** + - `vars` inference: fixture with a color variable bound to fills on two + nodes → inferred value equals the baked-in color, both binding sites + listed + - `import-variables` round trip: import a REST-shaped export (two + collections, two modes, an alias) → `vars` shows authoritative + values incl. per-mode; re-import of identical file → zero churn + (delta probe); import with one changed mode value → minimal churn + - `styles --values`: text style value derived from a consumer TEXT + node's TypeStyle; fill style from a consumer's fills +5. **CLI smoke tests** (`tests/cli.rs`, via `assert_cmd` or equivalent) + - build a DB from the fixture, run each read command, snapshot-assert + stdout (`--json` mode: parse and assert structurally) + - URL/key/node-id argument parsing (`12-34` ⇒ `12:34`, full URLs) +6. **Manual live check** (documented in the crate README, not CI): `FIGMA_TOKEN=… figmog pull `, then `figmog components`, + `figmog search`, timing note. Acceptance: read commands return in + milliseconds on the real file. + +Full-feature test run (`cargo test -p figmog`) must pass before the +milestone is called done; `-p figmog` doesn't build ese, so iteration is +already fast. + +## 10. Risks & open items + +- **Change-detection field pinned:** `GET /v1/files/:key/meta` returns + `last_touched_at`, documented in the OpenAPI spec as "the UTC ISO 8601 + time at which the file content was last modified". Fallback if it + proves noisy: the `versions` endpoint (Tier 2). +- **File size:** GET file for a large handoff file can be tens of MB; + `ureq` reads it streaming into `serde_json`. If flatten+parse of the + real file is slow (>2–3s), acceptable — it's off the read path. +- **Instance sub-tree contents:** Figma serializes instances' overridden + subtrees as normal children; they mirror like any node. Overrides beyond + the serialized tree are not resolved (documented). +- **Branching files:** `branch_data` ignored in v1; mirroring a branch = + mirroring its own file key. From 951c8ea3585e8557e68c34be06ea67dda88a47a1 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:23:11 -0700 Subject: [PATCH 02/56] spec(figmog): note MCP as optional paid-seat variables source Co-Authored-By: Claude Fable 5 --- docs/superpowers/specs/2026-08-15-figmog-build-design.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index c2052ea..c0099ae 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -293,6 +293,14 @@ that dumps the REST-shaped JSON). Imports flow through the same `KeyedStream`, so re-imports diff incrementally like everything else and `figmog vars` prefers authoritative records over inference when present. +A third source exists for paid seats only, noted for completeness and +deliberately **not** built in v1: Figma's MCP servers expose +`get_variable_defs`, but the desktop server needs a Dev/Full seat on a +paid plan, the remote server allows Starter users only 6 tool calls per +*month*, and the tool is selection-scoped rather than +full-collections. Anyone with a paid seat can pipe its output into +`import-variables` by hand; figmog never depends on MCP. + Everything else the Tailwind layer needs is already mirrored at full fidelity in `raw` and queryable through the indexes: TypeStyle (font family/size/weight/line-height/letter-spacing), fills/strokes/effects, From 8c7b4ee88586c33ea0f8aca83c81063230e894ac Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:41:03 -0700 Subject: [PATCH 03/56] plan(figmog): 14-task TDD implementation plan Co-Authored-By: Claude Fable 5 --- docs/superpowers/plans/2026-08-15-figmog.md | 2687 +++++++++++++++++++ 1 file changed, 2687 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-15-figmog.md diff --git a/docs/superpowers/plans/2026-08-15-figmog.md b/docs/superpowers/plans/2026-08-15-figmog.md new file mode 100644 index 0000000..32ac0e4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-figmog.md @@ -0,0 +1,2687 @@ +# figmog Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A fold-backed local mirror of one Figma file: a sync engine that spends Figma rate-limit budget only on real changes, and a CLI whose reads are all local and instant. + +**Architecture:** One `KeyedStream` over fjall; a pure `flatten` turns the file-JSON into deterministic records; `KeyedStream::upsert` byte-equality synthesizes the delta API Figma lacks; thirteen CLI subcommands read materialized indexes (tables, multimap, BM25, inverted indexes) from one snapshot. + +**Tech Stack:** Rust (edition 2024), fold (workspace path dep), serde/serde_json, ureq 2 (blocking HTTP), clap 4 (derive), thiserror 2. Dev: tempfile, assert_cmd, postcard. + +**Spec:** `docs/superpowers/specs/2026-08-15-figmog-build-design.md` — read it first; every design rationale lives there. + +## Global Constraints + +- Crate is `examples/figmog`, a **lib + thin bin** (`src/lib.rs` + `src/main.rs`) so integration tests can link the modules. +- **No `ese`, no `anny`, no tokio.** Deps exactly: `fold`, `serde` (derive), `serde_json`, `ureq = "2"`, `clap = "4"` (derive), `thiserror = "2"`. Dev-deps: `tempfile = "3"`, `assert_cmd = "2"`, `postcard = "1"` (use-std). +- **Determinism:** map-shaped record fields are sorted `Vec`s of pairs, never `HashMap`; `serde_json` must NOT enable `preserve_order`; no wall-clock/randomness inside `flatten`; all CLI output sorted. +- **Sink names frozen** (on-disk schema): `nodes`, `children`, `text`, `instances_of`, `styled_by`, `bound_to`, `by_type`, `components`, `component_sets`, `styles`, `variables`, `variable_collections`, `meta`. +- Fixtures are **synthetic only** — nothing derived from the g3d file may land in git. +- Every task ends green: `cargo test -p figmog` passes, `cargo clippy -p figmog -- -D warnings` clean. +- If `cargo` is not on PATH in your shell, prefix commands with `export PATH="$HOME/.cargo/bin:$PATH" && `. +- Commit messages: `feat(figmog): …` / `test(figmog): …` / `docs(figmog): …`, each ending with the `Co-Authored-By: Claude Fable 5 ` trailer. + +--- + +### Task 1: Scaffold the crate (lib + bin) + +**Files:** +- Create: `examples/figmog/Cargo.toml`, `examples/figmog/src/main.rs`, `examples/figmog/src/lib.rs`, empty module files `examples/figmog/src/{model.rs,ident.rs,flatten.rs,store.rs,api.rs,watch.rs,vars.rs,cli.rs}` + +**Interfaces:** +- Produces: crate `figmog` importable from integration tests; `figmog::…` module paths used by every later task. + +- [ ] **Step 1: Run the scaffold script from the worktree root** + +```bash +./scripts/new-project.sh figmog +``` + +- [ ] **Step 2: Replace the generated Cargo.toml** + +```toml +[package] +name = "figmog" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +fold = { path = "../../fold" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ureq = { version = "2", features = ["json"] } +clap = { version = "4", features = ["derive"] } +thiserror = "2" + +[dev-dependencies] +tempfile = "3" +assert_cmd = "2" +postcard = { version = "1", features = ["use-std"] } +``` + +- [ ] **Step 3: Create `src/lib.rs`** + +```rust +//! figmog — a fold-backed local mirror of one Figma file. +//! +//! A sync engine pulls the file when it changes (change detection on the +//! cheap Tier-3 metadata endpoint, one Tier-1 fetch per real change) and a +//! `KeyedStream` upsert-diffs every node into materialized indexes. The CLI +//! reads those indexes locally: zero Figma calls, zero rate limits. +//! +//! See `docs/superpowers/specs/2026-08-15-figmog-build-design.md`. + +pub mod api; +pub mod cli; +pub mod flatten; +pub mod ident; +pub mod model; +pub mod store; +pub mod vars; +pub mod watch; +``` + +- [ ] **Step 4: Point `src/main.rs` at the lib** + +```rust +fn main() { + std::process::exit(figmog::cli::run()); +} +``` + +and add a temporary stub so it compiles (replaced in Task 12) — in `src/cli.rs`: + +```rust +//! Command-line surface. Real implementation lands with the CLI tasks. +pub fn run() -> i32 { + eprintln!("figmog: not yet implemented"); + 2 +} +``` + +Leave the other module files as empty files (a lone `//! …` doc line each is fine). + +- [ ] **Step 5: Verify it builds and commit** + +```bash +cargo check -p figmog && cargo clippy -p figmog -- -D warnings +git add examples/figmog Cargo.toml Cargo.lock +git commit -m "feat(figmog): scaffold lib+bin crate" +``` + +(`new-project.sh` relies on the workspace `members = ["examples/*"]` glob, so the root `Cargo.toml` may be untouched — commit whatever changed.) + +--- + +### Task 2: `model.rs` — record types + +**Files:** +- Modify: `examples/figmog/src/model.rs` +- Test: unit tests in the same file (`#[cfg(test)]`) + +**Interfaces:** +- Produces (used by every later task): + - `enum Id { Node(String), Component(String), ComponentSet(String), Style(String), Variable(String), VariableCollection(String), Meta }` + - `enum Rec { Node(NodeRec), Component(ComponentRec), ComponentSet(ComponentSetRec), Style(StyleRec), Variable(VariableRec), VariableCollection(VariableCollectionRec), Meta(FileMeta) }` + - structs exactly as below; all derive `Debug, Clone, PartialEq, Serialize, Deserialize`; `Id` additionally `Eq, PartialOrd, Ord, Hash`. + +- [ ] **Step 1: Write the failing tests** (bottom of `model.rs`) + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn sample_node() -> NodeRec { + NodeRec { + id: "1:2".into(), + parent_id: Some("0:1".into()), + child_index: 0, + page_id: "0:1".into(), + node_type: "TEXT".into(), + name: "Title".into(), + visible: true, + text: Some("hello".into()), + component_id: None, + component_properties: vec![("Size".into(), "\"Large\"".into())], + property_definitions: None, + style_refs: vec![("text".into(), "S:2".into())], + bound_variables: vec![("/style/fontSize".into(), "VariableID:9".into())], + abs_bounds: Some([0.0, 0.0, 100.0, 20.0]), + raw: "{}".into(), + } + } + + #[test] + fn rec_postcard_roundtrip() { + let rec = Rec::Node(sample_node()); + let bytes = postcard::to_allocvec(&rec).unwrap(); + let back: Rec = postcard::from_bytes(&bytes).unwrap(); + assert_eq!(rec, back); + } + + #[test] + fn identical_records_encode_identically() { + let a = postcard::to_allocvec(&Rec::Node(sample_node())).unwrap(); + let b = postcard::to_allocvec(&Rec::Node(sample_node())).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn ids_order_and_roundtrip() { + let ids = vec![Id::Meta, Id::Node("1:1".into()), Id::Style("S:1".into())]; + let set: std::collections::BTreeSet = ids.iter().cloned().collect(); + assert_eq!(set.len(), 3); + let bytes = postcard::to_allocvec(&ids).unwrap(); + let back: Vec = postcard::from_bytes(&bytes).unwrap(); + assert_eq!(ids, back); + } +} +``` + +Note: `postcard` is a dev-dependency, so these unit tests can use it directly. + +- [ ] **Step 2: Run to verify failure** + +```bash +cargo test -p figmog model +``` +Expected: compile FAIL (types not defined). + +- [ ] **Step 3: Implement the types** + +```rust +//! Record vocabulary shared by flatten, store, and the CLI. +//! +//! Determinism contract: every map-shaped field is a **sorted** `Vec` of +//! pairs, and canonical-JSON strings come from `serde_json` without +//! `preserve_order`. `KeyedStream` diffs records by postcard bytes, so two +//! flattens of the same file JSON must be byte-identical. + +use serde::{Deserialize, Serialize}; + +/// Primary key of every mirrored record. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Id { + Node(String), + Component(String), + ComponentSet(String), + Style(String), + Variable(String), + VariableCollection(String), + Meta, +} + +/// One mirrored record; variant always matches its [`Id`] variant. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Rec { + Node(NodeRec), + Component(ComponentRec), + ComponentSet(ComponentSetRec), + Style(StyleRec), + Variable(VariableRec), + VariableCollection(VariableCollectionRec), + Meta(FileMeta), +} + +/// One node of the document tree (children stripped from `raw`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NodeRec { + pub id: String, + pub parent_id: Option, + pub child_index: u32, + /// Enclosing CANVAS id; the document root and CANVAS nodes carry their own id. + pub page_id: String, + pub node_type: String, + pub name: String, + pub visible: bool, + /// `characters` for TEXT nodes. + pub text: Option, + /// INSTANCE → the component's node id. + pub component_id: Option, + /// INSTANCE `componentProperties` as (name, canonical-JSON value), sorted. + pub component_properties: Vec<(String, String)>, + /// `componentPropertyDefinitions` (COMPONENT / COMPONENT_SET) as canonical JSON. + pub property_definitions: Option, + /// Node `styles` map as (style_type, style_id), sorted. + pub style_refs: Vec<(String, String)>, + /// Variable bindings as (json-pointer to the bound property, variable id), sorted. + /// The pointer addresses the *resolved value* location, e.g. `/fills/0/color`. + pub bound_variables: Vec<(String, String)>, + /// absoluteBoundingBox x, y, w, h. + pub abs_bounds: Option<[f64; 4]>, + /// Canonical JSON of the node with `children` removed. + pub raw: String, +} + +/// Entry of the file response's `components` map. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ComponentRec { + pub node_id: String, + pub key: String, + pub name: String, + pub description: String, + pub component_set_id: Option, + pub remote: bool, +} + +/// Entry of the file response's `componentSets` map. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ComponentSetRec { + pub node_id: String, + pub key: String, + pub name: String, + pub description: String, + pub remote: bool, +} + +/// Entry of the file response's `styles` map. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StyleRec { + pub style_id: String, + pub key: String, + pub name: String, + pub style_type: String, + pub description: String, + pub remote: bool, +} + +/// Authoritative variable definition (from `import-variables` only). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VariableRec { + pub id: String, + pub name: String, + pub resolved_type: String, + pub collection_id: String, + /// mode id -> value-or-alias, as sorted (mode_id, canonical JSON) pairs. + pub values_by_mode: Vec<(String, String)>, + pub description: String, + pub scopes: Vec, +} + +/// Authoritative variable collection (from `import-variables` only). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VariableCollectionRec { + pub id: String, + pub name: String, + /// (mode_id, mode_name), sorted by mode_id. + pub modes: Vec<(String, String)>, + pub default_mode_id: String, +} + +/// The single file-level row (key [`Id::Meta`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FileMeta { + pub name: String, + pub version: String, + pub last_modified: String, + pub synced_at_unix_ms: u64, +} +``` + +- [ ] **Step 4: Run tests + clippy** + +```bash +cargo test -p figmog model && cargo clippy -p figmog -- -D warnings +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/figmog/src/model.rs +git commit -m "feat(figmog): record model (Id/Rec + node and design-system records)" +``` + +--- + +### Task 3: `ident.rs` — file/node identity parsing + +**Files:** +- Modify: `examples/figmog/src/ident.rs` +- Test: unit tests in the same file + +**Interfaces:** +- Produces: + - `pub fn parse_file_ref(input: &str) -> Option` — bare key or figma URL → file key + - `pub fn normalize_node_id(input: &str) -> String` — `12-34` → `12:34`; already-canonical and instance-path ids (`I12:34;56:78`) pass through + +- [ ] **Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_bare_key() { + assert_eq!(parse_file_ref("flAtUnMfzvA5daBSTFQK35").as_deref(), Some("flAtUnMfzvA5daBSTFQK35")); + } + + #[test] + fn parses_design_and_file_urls() { + for url in [ + "https://www.figma.com/design/flAtUnMfzvA5daBSTFQK35/g3d-Index-Web-Handoff?node-id=0-1&t=x-1", + "https://www.figma.com/file/flAtUnMfzvA5daBSTFQK35/whatever", + "figma.com/design/flAtUnMfzvA5daBSTFQK35", + ] { + assert_eq!(parse_file_ref(url).as_deref(), Some("flAtUnMfzvA5daBSTFQK35"), "{url}"); + } + } + + #[test] + fn rejects_garbage() { + assert_eq!(parse_file_ref("https://example.com/nope"), None); + assert_eq!(parse_file_ref("not a key!"), None); + assert_eq!(parse_file_ref(""), None); + } + + #[test] + fn normalizes_node_ids() { + assert_eq!(normalize_node_id("0-1"), "0:1"); + assert_eq!(normalize_node_id("12-345"), "12:345"); + assert_eq!(normalize_node_id("12:345"), "12:345"); + // instance sub-node paths pass through untouched + assert_eq!(normalize_node_id("I206:7;104:22"), "I206:7;104:22"); + } +} +``` + +- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog ident` → compile FAIL. + +- [ ] **Step 3: Implement** + +```rust +//! Parsing of user-supplied file references and node ids. + +/// Extract a file key from a bare key or a figma.com URL +/// (`figma.com/design//…`, `figma.com/file//…`). +pub fn parse_file_ref(input: &str) -> Option { + let is_key = |s: &str| { + s.len() >= 10 && s.chars().all(|c| c.is_ascii_alphanumeric()) + }; + if is_key(input) { + return Some(input.to_string()); + } + let rest = input + .split_once("figma.com/") + .map(|(_, r)| r)?; + let mut parts = rest.split('/'); + match parts.next()? { + "design" | "file" | "board" => {} + _ => return None, + } + let key = parts.next()?; + is_key(key).then(|| key.to_string()) +} + +/// Canonicalize a node id: URLs write `12:34` as `12-34`. Ids that are not +/// exactly `-` (already-canonical ids, instance paths like +/// `I206:7;104:22`) pass through unchanged. +pub fn normalize_node_id(input: &str) -> String { + if let Some((a, b)) = input.split_once('-') { + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + if digits(a) && digits(b) { + return format!("{a}:{b}"); + } + } + input.to_string() +} +``` + +- [ ] **Step 4: Run tests + clippy** — `cargo test -p figmog ident && cargo clippy -p figmog -- -D warnings` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/figmog/src/ident.rs +git commit -m "feat(figmog): file/node identity parsing" +``` + +--- + +### Task 4: fixture builder + `flatten` core (tree walk) + +**Files:** +- Modify: `examples/figmog/src/flatten.rs` +- Create: `examples/figmog/tests/common/mod.rs` (fixture builders) +- Create: `examples/figmog/tests/flatten.rs` + +**Interfaces:** +- Consumes: `figmog::model::*`. +- Produces: + - `pub struct Flattened { pub recs: Vec<(Id, Rec)>, pub file: FileInfo }` + - `pub struct FileInfo { pub name: String, pub version: String, pub last_modified: String }` + - `pub fn flatten_file(resp: &serde_json::Value) -> Result` + - `#[derive(Debug, thiserror::Error)] pub enum FlattenError { #[error("missing field: {0}")] Missing(&'static str) }` + - test helper `common::fixture_v1() -> serde_json::Value` (and, in Task 7, `common::fixture_v2()`). +- Design-system fields (`component_id`, `component_properties`, `property_definitions`, `style_refs`, `bound_variables`) and the components/styles maps land in **Task 5**; this task leaves them empty (`vec![]` / `None`) and only walks the tree. + +- [ ] **Step 1: Write the fixture builder** — `tests/common/mod.rs`. This is the single synthetic file used by all tests; content matters, copy it exactly: + +```rust +//! Synthetic Figma file fixtures. Deliberately NOT derived from any real +//! file. Shape mirrors GET /v1/files/:key responses. + +use serde_json::{Value, json}; + +/// 12 nodes over 3 pages: a hero frame with a text, a variant'd button +/// instance, an invisible node, a component set (2 variants), a standalone +/// component, and an empty page. Fill/text styles + variable bindings. +pub fn fixture_v1() -> Value { + json!({ + "name": "Fixture", + "version": "100", + "lastModified": "2026-08-01T00:00:00Z", + "document": { + "id": "0:0", "name": "Document", "type": "DOCUMENT", + "children": [ + { "id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [ + { "id": "1:1", "name": "Hero", "type": "FRAME", + "absoluteBoundingBox": {"x": 0.0, "y": 0.0, "width": 800.0, "height": 400.0}, + "layoutMode": "VERTICAL", "paddingLeft": 16.0, + "boundVariables": { "paddingLeft": {"type": "VARIABLE_ALIAS", "id": "VariableID:200"} }, + "fills": [ { "type": "SOLID", + "color": {"r": 0.06, "g": 0.13, "b": 0.2, "a": 1.0}, + "boundVariables": { "color": {"type": "VARIABLE_ALIAS", "id": "VariableID:100"} } } ], + "styles": { "fill": "S:1" }, + "children": [ + { "id": "1:2", "name": "Title", "type": "TEXT", + "characters": "Welcome to the garden", + "style": {"fontFamily": "Basis", "fontSize": 32.0, "fontWeight": 500}, + "styles": { "text": "S:2" }, + "children": [] }, + { "id": "1:3", "name": "Button", "type": "INSTANCE", + "componentId": "2:2", + "componentProperties": { + "Size": {"value": "Large", "type": "VARIANT"}, + "State": {"value": "Default", "type": "VARIANT"}, + "Label": {"value": "Go", "type": "TEXT"}, + "HasIcon": {"value": false, "type": "BOOLEAN"}, + "Icon": {"value": "3:1", "type": "INSTANCE_SWAP"} + }, + "children": [] } + ] }, + { "id": "1:9", "name": "Old badge", "type": "RECTANGLE", + "visible": false, "children": [] } + ] }, + { "id": "0:2", "name": "Components", "type": "CANVAS", "children": [ + { "id": "2:1", "name": "Button", "type": "COMPONENT_SET", + "componentPropertyDefinitions": { + "Size": {"type": "VARIANT", "defaultValue": "Large", "variantOptions": ["Large", "Small"]}, + "State": {"type": "VARIANT", "defaultValue": "Default", "variantOptions": ["Default", "Hover"]}, + "Label": {"type": "TEXT", "defaultValue": "Go"}, + "HasIcon": {"type": "BOOLEAN", "defaultValue": false}, + "Icon": {"type": "INSTANCE_SWAP", "defaultValue": "3:1"} + }, + "children": [ + { "id": "2:2", "name": "Size=Large, State=Default", "type": "COMPONENT", "children": [] }, + { "id": "2:3", "name": "Size=Small, State=Hover", "type": "COMPONENT", "children": [] } + ] }, + { "id": "3:1", "name": "IconStar", "type": "COMPONENT", "children": [] } + ] }, + { "id": "0:3", "name": "Empty", "type": "CANVAS", "children": [] } + ] + }, + "components": { + "2:2": {"key": "key22", "name": "Size=Large, State=Default", "description": "", "componentSetId": "2:1", "remote": false}, + "2:3": {"key": "key23", "name": "Size=Small, State=Hover", "description": "", "componentSetId": "2:1", "remote": false}, + "3:1": {"key": "key31", "name": "IconStar", "description": "a star", "remote": false} + }, + "componentSets": { + "2:1": {"key": "keyset21", "name": "Button", "description": "the button", "remote": false} + }, + "styles": { + "S:1": {"key": "sk1", "name": "Brand/Primary", "styleType": "FILL", "description": "", "remote": false}, + "S:2": {"key": "sk2", "name": "Heading/H1", "styleType": "TEXT", "description": "", "remote": false} + } + }) +} +``` + +- [ ] **Step 2: Write the failing tests** — `tests/flatten.rs`: + +```rust +mod common; + +use figmog::flatten::flatten_file; +use figmog::model::{Id, Rec}; + +fn node(recs: &[(Id, Rec)], id: &str) -> figmog::model::NodeRec { + recs.iter() + .find_map(|(k, r)| match (k, r) { + (Id::Node(n), Rec::Node(rec)) if n == id => Some(rec.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("node {id} not flattened")) +} + +#[test] +fn walks_the_whole_tree() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let node_ids: Vec<&str> = out + .recs + .iter() + .filter_map(|(k, _)| match k { Id::Node(n) => Some(n.as_str()), _ => None }) + .collect(); + assert_eq!( + node_ids, + ["0:0", "0:1", "1:1", "1:2", "1:3", "1:9", "0:2", "2:1", "2:2", "2:3", "3:1", "0:3"], + "depth-first order, all 12 nodes" + ); + assert_eq!(out.file.name, "Fixture"); + assert_eq!(out.file.version, "100"); + assert_eq!(out.file.last_modified, "2026-08-01T00:00:00Z"); +} + +#[test] +fn parent_index_page_attribution() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let title = node(&out.recs, "1:2"); + assert_eq!(title.parent_id.as_deref(), Some("1:1")); + assert_eq!(title.child_index, 0); + assert_eq!(title.page_id, "0:1"); + let button = node(&out.recs, "1:3"); + assert_eq!(button.child_index, 1); + + let root = node(&out.recs, "0:0"); + assert_eq!(root.parent_id, None); + assert_eq!(root.page_id, "0:0"); + let canvas = node(&out.recs, "0:2"); + assert_eq!(canvas.page_id, "0:2"); + let variant = node(&out.recs, "2:2"); + assert_eq!(variant.page_id, "0:2"); +} + +#[test] +fn basic_fields() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let title = node(&out.recs, "1:2"); + assert_eq!(title.node_type, "TEXT"); + assert_eq!(title.name, "Title"); + assert!(title.visible); + assert_eq!(title.text.as_deref(), Some("Welcome to the garden")); + + let hidden = node(&out.recs, "1:9"); + assert!(!hidden.visible); + + let hero = node(&out.recs, "1:1"); + assert_eq!(hero.abs_bounds, Some([0.0, 0.0, 800.0, 400.0])); +} + +#[test] +fn raw_is_canonical_and_childless() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let hero = node(&out.recs, "1:1"); + let raw: serde_json::Value = serde_json::from_str(&hero.raw).unwrap(); + assert!(raw.get("children").is_none()); + assert_eq!(raw["name"], "Hero"); + // canonical: re-serializing the parsed value reproduces the string + assert_eq!(serde_json::to_string(&raw).unwrap(), hero.raw); +} + +#[test] +fn deterministic_bytes() { + let a = flatten_file(&common::fixture_v1()).unwrap(); + let b = flatten_file(&common::fixture_v1()).unwrap(); + let enc = |f: &figmog::flatten::Flattened| postcard::to_allocvec(&f.recs).unwrap(); + assert_eq!(enc(&a), enc(&b)); +} + +#[test] +fn missing_document_errors() { + assert!(flatten_file(&serde_json::json!({"name": "x"})).is_err()); +} +``` + +- [ ] **Step 3: Run to verify failure** — `cargo test -p figmog --test flatten` → compile FAIL (`flatten_file` undefined). + +- [ ] **Step 4: Implement `flatten.rs` (core walk)** + +```rust +//! Pure flattening of a Figma file response into deterministic records. +//! +//! No I/O, no clock, no randomness: two calls on equal JSON must produce +//! byte-identical records (postcard), because `KeyedStream::upsert` uses +//! byte equality as its change detector. + +use serde_json::Value; + +use crate::model::{Id, NodeRec, Rec}; + +/// File-level fields lifted from the response envelope. +#[derive(Debug, Clone, PartialEq)] +pub struct FileInfo { + pub name: String, + pub version: String, + pub last_modified: String, +} + +/// Everything `flatten_file` extracts. +#[derive(Debug)] +pub struct Flattened { + pub recs: Vec<(Id, Rec)>, + pub file: FileInfo, +} + +#[derive(Debug, thiserror::Error)] +pub enum FlattenError { + #[error("missing field: {0}")] + Missing(&'static str), +} + +/// Flatten a full `GET /v1/files/:key` response. +pub fn flatten_file(resp: &Value) -> Result { + let file = FileInfo { + name: str_field(resp, "name").ok_or(FlattenError::Missing("name"))?, + version: str_field(resp, "version").ok_or(FlattenError::Missing("version"))?, + last_modified: str_field(resp, "lastModified").ok_or(FlattenError::Missing("lastModified"))?, + }; + let document = resp.get("document").ok_or(FlattenError::Missing("document"))?; + + let mut recs = Vec::new(); + walk(document, None, 0, None, &mut recs); + Ok(Flattened { recs, file }) +} + +fn str_field(v: &Value, k: &str) -> Option { + v.get(k)?.as_str().map(str::to_string) +} + +/// Depth-first walk. `page_id` is the nearest CANVAS ancestor (None above +/// pages — the record then carries the node's own id). +fn walk( + node: &Value, + parent_id: Option<&str>, + child_index: u32, + page_id: Option<&str>, + out: &mut Vec<(Id, Rec)>, +) { + let Some(id) = node.get("id").and_then(Value::as_str) else { + return; // node without id: skip it and its subtree + }; + let node_type = node + .get("type") + .and_then(Value::as_str) + .unwrap_or("UNKNOWN") + .to_string(); + let own_page = matches!(node_type.as_str(), "DOCUMENT" | "CANVAS"); + let page = if own_page { id } else { page_id.unwrap_or(id) }; + + let mut raw = node.clone(); + if let Some(obj) = raw.as_object_mut() { + obj.remove("children"); + } + + let rec = NodeRec { + id: id.to_string(), + parent_id: parent_id.map(str::to_string), + child_index, + page_id: page.to_string(), + node_type, + name: str_field(node, "name").unwrap_or_default(), + visible: node.get("visible").and_then(Value::as_bool).unwrap_or(true), + text: str_field(node, "characters"), + component_id: None, + component_properties: Vec::new(), + property_definitions: None, + style_refs: Vec::new(), + bound_variables: Vec::new(), + abs_bounds: node.get("absoluteBoundingBox").and_then(|b| { + Some([ + b.get("x")?.as_f64()?, + b.get("y")?.as_f64()?, + b.get("width")?.as_f64()?, + b.get("height")?.as_f64()?, + ]) + }), + raw: serde_json::to_string(&raw).expect("serde_json::Value serializes"), + }; + out.push((Id::Node(id.to_string()), Rec::Node(rec))); + + if let Some(children) = node.get("children").and_then(Value::as_array) { + for (i, child) in children.iter().enumerate() { + walk(child, Some(id), i as u32, Some(page), out); + } + } +} +``` + +- [ ] **Step 5: Run tests + clippy** + +```bash +cargo test -p figmog --test flatten && cargo test -p figmog && cargo clippy -p figmog -- -D warnings +``` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add examples/figmog/src/flatten.rs examples/figmog/tests +git commit -m "feat(figmog): flatten core tree walk + synthetic fixture" +``` + +--- + +### Task 5: `flatten` design-system fields + envelope maps + +**Files:** +- Modify: `examples/figmog/src/flatten.rs` +- Modify: `examples/figmog/tests/flatten.rs` (add tests) + +**Interfaces:** +- Consumes/Produces: same `flatten_file`; `NodeRec` design-system fields now populated; `recs` additionally contains `Component`/`ComponentSet`/`Style` records, emitted **after** all nodes, in sorted-key order. + +- [ ] **Step 1: Add the failing tests** to `tests/flatten.rs`: + +```rust +use figmog::model::{ComponentRec, StyleRec}; + +fn component(recs: &[(Id, Rec)], id: &str) -> ComponentRec { + recs.iter() + .find_map(|(k, r)| match (k, r) { + (Id::Component(n), Rec::Component(rec)) if n == id => Some(rec.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("component {id} not flattened")) +} + +#[test] +fn instance_component_fields() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let button = node(&out.recs, "1:3"); + assert_eq!(button.component_id.as_deref(), Some("2:2")); + // sorted by property name; values are canonical JSON of the `value` field + assert_eq!( + button.component_properties, + vec![ + ("HasIcon".to_string(), "false".to_string()), + ("Icon".to_string(), "\"3:1\"".to_string()), + ("Label".to_string(), "\"Go\"".to_string()), + ("Size".to_string(), "\"Large\"".to_string()), + ("State".to_string(), "\"Default\"".to_string()), + ] + ); +} + +#[test] +fn property_definitions_on_set_and_component() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let set = node(&out.recs, "2:1"); + let defs: serde_json::Value = + serde_json::from_str(set.property_definitions.as_deref().unwrap()).unwrap(); + assert_eq!(defs["Size"]["variantOptions"], serde_json::json!(["Large", "Small"])); + // standalone component without the field -> None + assert_eq!(node(&out.recs, "3:1").property_definitions, None); +} + +#[test] +fn style_refs_extracted_sorted() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + assert_eq!(node(&out.recs, "1:1").style_refs, vec![("fill".to_string(), "S:1".to_string())]); + assert_eq!(node(&out.recs, "1:2").style_refs, vec![("text".to_string(), "S:2".to_string())]); +} + +#[test] +fn bound_variable_scan_finds_all_depths() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let hero = node(&out.recs, "1:1"); + // sorted by pointer; pointer addresses the RESOLVED value location + assert_eq!( + hero.bound_variables, + vec![ + ("/fills/0/color".to_string(), "VariableID:100".to_string()), + ("/paddingLeft".to_string(), "VariableID:200".to_string()), + ] + ); +} + +#[test] +fn envelope_maps_flattened() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let c = component(&out.recs, "2:2"); + assert_eq!(c.key, "key22"); + assert_eq!(c.component_set_id.as_deref(), Some("2:1")); + assert!(!c.remote); + + let styles: Vec = out.recs.iter() + .filter_map(|(_, r)| match r { Rec::Style(s) => Some(s.clone()), _ => None }) + .collect(); + assert_eq!(styles.len(), 2); + assert_eq!(styles[0].style_id, "S:1"); // sorted by style id + assert_eq!(styles[0].style_type, "FILL"); + + let sets = out.recs.iter().filter(|(k, _)| matches!(k, Id::ComponentSet(_))).count(); + assert_eq!(sets, 1); +} +``` + +- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog --test flatten` → FAILs (fields empty, maps missing). + +- [ ] **Step 3: Implement.** In `walk`, replace the four placeholder fields: + +```rust + component_id: str_field(node, "componentId"), + component_properties: sorted_map(node.get("componentProperties"), |v| { + v.get("value").map(|val| serde_json::to_string(val).expect("Value serializes")) + }), + property_definitions: node + .get("componentPropertyDefinitions") + .map(|v| serde_json::to_string(v).expect("Value serializes")), + style_refs: sorted_map(node.get("styles"), |v| v.as_str().map(str::to_string)), + bound_variables: scan_bound_variables(&raw), +``` + +with the helpers (place after `walk`): + +```rust +/// Turn a JSON object into sorted (key, f(value)) pairs; absent/None entries drop. +fn sorted_map(obj: Option<&Value>, f: impl Fn(&Value) -> Option) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = obj + .and_then(Value::as_object) + .map(|m| { + m.iter() + .filter_map(|(k, v)| Some((k.clone(), f(v)?))) + .collect() + }) + .unwrap_or_default(); + out.sort(); + out +} + +/// Recursively find every `boundVariables` object and emit +/// (pointer-to-resolved-value, variable id) pairs. The binding +/// `…/boundVariables/ = {type: VARIABLE_ALIAS, id}` resolves at the +/// sibling `…/`, which is where Figma bakes the concrete value. +fn scan_bound_variables(node_raw: &Value) -> Vec<(String, String)> { + let mut out = Vec::new(); + scan_bv(node_raw, "", &mut out); + out.sort(); + out.dedup(); + out +} + +fn scan_bv(v: &Value, path: &str, out: &mut Vec<(String, String)>) { + match v { + Value::Object(map) => { + for (k, child) in map { + if k == "boundVariables" { + collect_aliases(child, path, out); + } else { + scan_bv(child, &format!("{path}/{k}"), out); + } + } + } + Value::Array(items) => { + for (i, child) in items.iter().enumerate() { + scan_bv(child, &format!("{path}/{i}"), out); + } + } + _ => {} + } +} + +/// Walk the *inside* of a `boundVariables` object: values are aliases, +/// arrays of aliases, or nested objects of them. +fn collect_aliases(v: &Value, prop_path: &str, out: &mut Vec<(String, String)>) { + match v { + Value::Object(map) => { + let alias = map.get("type").and_then(Value::as_str) == Some("VARIABLE_ALIAS"); + if alias && let Some(id) = map.get("id").and_then(Value::as_str) { + out.push((prop_path.to_string(), id.to_string())); + return; + } + for (k, child) in map { + collect_aliases(child, &format!("{prop_path}/{k}"), out); + } + } + Value::Array(items) => { + for (i, child) in items.iter().enumerate() { + collect_aliases(child, &format!("{prop_path}/{i}"), out); + } + } + _ => {} + } +} +``` + +Then, in `flatten_file` after the walk, flatten the envelope maps (BTreeMap iteration = sorted keys = deterministic order): + +```rust +use crate::model::{ComponentRec, ComponentSetRec, StyleRec}; +use std::collections::BTreeMap; + + let obj_map = |key: &str| -> BTreeMap { + resp.get(key) + .and_then(Value::as_object) + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default() + }; + for (node_id, v) in obj_map("components") { + recs.push(( + Id::Component(node_id.clone()), + Rec::Component(ComponentRec { + node_id, + key: str_field(&v, "key").unwrap_or_default(), + name: str_field(&v, "name").unwrap_or_default(), + description: str_field(&v, "description").unwrap_or_default(), + component_set_id: str_field(&v, "componentSetId"), + remote: v.get("remote").and_then(Value::as_bool).unwrap_or(false), + }), + )); + } + for (node_id, v) in obj_map("componentSets") { + recs.push(( + Id::ComponentSet(node_id.clone()), + Rec::ComponentSet(ComponentSetRec { + node_id, + key: str_field(&v, "key").unwrap_or_default(), + name: str_field(&v, "name").unwrap_or_default(), + description: str_field(&v, "description").unwrap_or_default(), + remote: v.get("remote").and_then(Value::as_bool).unwrap_or(false), + }), + )); + } + for (style_id, v) in obj_map("styles") { + recs.push(( + Id::Style(style_id.clone()), + Rec::Style(StyleRec { + style_id, + key: str_field(&v, "key").unwrap_or_default(), + name: str_field(&v, "name").unwrap_or_default(), + style_type: str_field(&v, "styleType").unwrap_or_default(), + description: str_field(&v, "description").unwrap_or_default(), + remote: v.get("remote").and_then(Value::as_bool).unwrap_or(false), + }), + )); + } +``` + +Note the `scan_bound_variables(&raw)` call receives the children-stripped value, so bindings inside child nodes are attributed to the child (its own walk), never duplicated on the parent. + +- [ ] **Step 4: Run all tests + clippy** — `cargo test -p figmog && cargo clippy -p figmog -- -D warnings` → PASS (including the determinism test, which now covers the new fields). + +- [ ] **Step 5: Commit** + +```bash +git add examples/figmog/src/flatten.rs examples/figmog/tests/flatten.rs +git commit -m "feat(figmog): flatten design-system fields and envelope maps" +``` + +--- + +### Task 6: `store.rs` — pipeline + sync (populate & no-churn) + +**Files:** +- Modify: `examples/figmog/src/store.rs` +- Create: `examples/figmog/tests/sync.rs` + +**Interfaces:** +- Consumes: `model::*`, `flatten::FileInfo`. +- Produces: + - branch functions (pub, used by the macro): `node_only`, `child_edge`, `text_doc`, `instance_edge`, `style_edges`, `variable_edges`, `type_edge`, `component_only`, `component_set_only`, `style_only`, `variable_only`, `collection_only`, `meta_only` — signatures in the code below. + - `#[macro_export] macro_rules! figmog_pipeline` — expands to the pipeline value; `#[macro_export] macro_rules! open_store` — `open_store!(path)` → `KeyedStream` (unnameable type; bind with `let`). + - `pub struct Churn { pub added: usize, pub changed: usize, pub removed: usize, pub unchanged: usize }` (derives `Debug, Default, PartialEq, Serialize`) + - `pub fn sync>>(st: &mut KeyedStream, prior_sweepable: &BTreeSet, flattened: &Flattened, synced_at_unix_ms: u64) -> Churn` — one `wtx`: upserts every rec + the meta rec, removes vanished sweepable ids. `Id::Variable`/`Id::VariableCollection`/`Id::Meta` are never swept. + - `pub fn collect_sweepable(…)` — see code; gathers prior node/component/set/style ids from readers. + - Reader tuple shape (every read site destructures exactly this): + `((nodes, children, text, instances_of, styled_by, bound_to, by_type), components, component_sets, styles, variables, variable_collections, meta)` + with element types + `TableReader<'_, R, String, NodeRec>`, `MultimapReader<'_, R, String, (u32, String)>`, `Bm25Reader<'_, R, String>` (whatever fold names it — check `fold::pipeline::terminal::search` exports at implementation time), `InvertedIndexReader<'_, R, String, String>` ×3, `TableReader` for the rest, `TableReader<'_, R, (), FileMeta>` for meta. + +- [ ] **Step 1: Implement `store.rs`** (pipeline shape is fixed by spec §5; write it first — the tests drive `sync` behavior, not the shape): + +```rust +//! Pipeline definition and the sync transaction. +//! +//! The pipeline type contains fn items and so can't be written down; the +//! `figmog_pipeline!` / `open_store!` macros expand it at each use site +//! (main + tests). Everything else here is ordinary generic functions. + +use std::collections::BTreeSet; + +use fold::pipeline::{Keyed, Push}; +use fold::stream::KeyedStream; +use serde::Serialize; + +use crate::flatten::Flattened; +use crate::model::{FileMeta, Id, NodeRec, Rec}; + +// ---- pipeline branch functions (pure; fold requires determinism) ---- + +pub fn node_only(d: &Keyed) -> Option> { + match &d.val { + Rec::Node(n) => Some(Keyed::new(n.id.clone(), n.clone())), + _ => None, + } +} + +pub fn child_edge(d: &Keyed) -> Option> { + let parent = d.val.parent_id.clone()?; + Some(Keyed::new(parent, (d.val.child_index, d.val.id.clone()))) +} + +pub fn text_doc(d: &Keyed) -> Option> { + let mut s = d.val.name.clone(); + if let Some(t) = &d.val.text { + s.push(' '); + s.push_str(t); + } + let s = s.trim().to_string(); + (!s.is_empty()).then(|| Keyed::new(d.val.id.clone(), s)) +} + +pub fn instance_edge(d: &Keyed) -> Option> { + d.val + .component_id + .clone() + .map(|c| Keyed::new(d.val.id.clone(), c)) +} + +pub fn style_edges(d: &Keyed) -> Vec> { + d.val + .style_refs + .iter() + .map(|(_, style_id)| Keyed::new(d.val.id.clone(), style_id.clone())) + .collect() +} + +pub fn variable_edges(d: &Keyed) -> Vec> { + let mut edges: Vec<_> = d + .val + .bound_variables + .iter() + .map(|(_, var_id)| Keyed::new(d.val.id.clone(), var_id.clone())) + .collect(); + edges.dedup_by(|a, b| a.val == b.val); // sorted input: dedup repeated ids + edges +} + +pub fn type_edge(d: &Keyed) -> Keyed { + Keyed::new(d.val.id.clone(), d.val.node_type.clone()) +} + +macro_rules! rec_branch { + ($name:ident, $idvar:ident, $recvar:ident, $rec:ty) => { + pub fn $name(d: &Keyed) -> Option> { + match (&d.key, &d.val) { + (Id::$idvar(k), Rec::$recvar(r)) => Some(Keyed::new(k.clone(), r.clone())), + _ => None, + } + } + }; +} +rec_branch!(component_only, Component, Component, crate::model::ComponentRec); +rec_branch!(component_set_only, ComponentSet, ComponentSet, crate::model::ComponentSetRec); +rec_branch!(style_only, Style, Style, crate::model::StyleRec); +rec_branch!(variable_only, Variable, Variable, crate::model::VariableRec); +rec_branch!(collection_only, VariableCollection, VariableCollection, crate::model::VariableCollectionRec); + +pub fn meta_only(d: &Keyed) -> Option> { + match &d.val { + Rec::Meta(m) => Some(Keyed::new((), m.clone())), + _ => None, + } +} + +/// The full figmog pipeline. Sink names are frozen on-disk schema. +#[macro_export] +macro_rules! figmog_pipeline { + () => {{ + use fold::pipeline::{FilterMap, FlatMap, Map, terminal}; + ( + FilterMap::new( + $crate::store::node_only, + ( + terminal::Table::new("nodes"), + FilterMap::new($crate::store::child_edge, terminal::Multimap::new("children")), + FilterMap::new($crate::store::text_doc, terminal::search::Bm25::new("text")), + FilterMap::new($crate::store::instance_edge, terminal::InvertedIndex::new("instances_of")), + FlatMap::new($crate::store::style_edges, terminal::InvertedIndex::new("styled_by")), + FlatMap::new($crate::store::variable_edges, terminal::InvertedIndex::new("bound_to")), + Map::new($crate::store::type_edge, terminal::InvertedIndex::new("by_type")), + ), + ), + FilterMap::new($crate::store::component_only, terminal::Table::new("components")), + FilterMap::new($crate::store::component_set_only, terminal::Table::new("component_sets")), + FilterMap::new($crate::store::style_only, terminal::Table::new("styles")), + FilterMap::new($crate::store::variable_only, terminal::Table::new("variables")), + FilterMap::new($crate::store::collection_only, terminal::Table::new("variable_collections")), + FilterMap::new($crate::store::meta_only, terminal::Table::new("meta")), + ) + }}; +} + +/// Open (or create) the figmog store at `$path`. +#[macro_export] +macro_rules! open_store { + ($path:expr) => { + ::fold::stream::KeyedStream::<$crate::model::Id, $crate::model::Rec, _>::new( + $path, + $crate::figmog_pipeline!(), + ) + }; +} + +// ---- sync ---- + +/// What one sync did, per record. +#[derive(Debug, Default, PartialEq, Serialize)] +pub struct Churn { + pub added: usize, + pub changed: usize, + pub removed: usize, + pub unchanged: usize, +} + +/// Apply a flattened file in one atomic transaction: upsert every record +/// and the meta row, then remove previously-stored ids that vanished. +/// Variables, collections, and the meta row are exempt from the sweep. +pub fn sync>>( + st: &mut KeyedStream, + prior_sweepable: &BTreeSet, + flattened: &Flattened, + synced_at_unix_ms: u64, +) -> Churn { + let meta = FileMeta { + name: flattened.file.name.clone(), + version: flattened.file.version.clone(), + last_modified: flattened.file.last_modified.clone(), + synced_at_unix_ms, + }; + let live: BTreeSet<&Id> = flattened.recs.iter().map(|(id, _)| id).collect(); + + let mut churn = Churn::default(); + st.wtx(|tx| { + for (id, rec) in &flattened.recs { + match tx.upsert(id, rec) { + None => churn.added += 1, + Some(old) if old == *rec => churn.unchanged += 1, + Some(_) => churn.changed += 1, + } + } + tx.upsert(&Id::Meta, &Rec::Meta(meta)); + for id in prior_sweepable { + if !live.contains(id) { + debug_assert!(!matches!( + id, + Id::Variable(_) | Id::VariableCollection(_) | Id::Meta + )); + if tx.remove(id).is_some() { + churn.removed += 1; + } + } + } + }); + churn +} + +/// Gather the sweepable id set from the four table readers. Call inside +/// `rtx` *before* `sync` (single-writer process: no write races). +pub fn collect_sweepable( + nodes: &fold::pipeline::terminal::TableReader<'_, R, String, NodeRec>, + components: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::ComponentRec>, + component_sets: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::ComponentSetRec>, + styles: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::StyleRec>, +) -> BTreeSet { + let mut out = BTreeSet::new(); + out.extend(nodes.iter().map(|(k, _)| Id::Node(k))); + out.extend(components.iter().map(|(k, _)| Id::Component(k))); + out.extend(component_sets.iter().map(|(k, _)| Id::ComponentSet(k))); + out.extend(styles.iter().map(|(k, _)| Id::Style(k))); + out +} +``` + +Adjust import paths to whatever `fold` actually exports (`TableReader` etc. are re-exported from `fold::pipeline::terminal`); if `Bm25`'s reader type name differs, follow the source in `fold/src/pipeline/terminal/search/mod.rs`. + +- [ ] **Step 2: Write the failing tests** — `tests/sync.rs`: + +```rust +mod common; + +use std::cell::Cell; +use std::collections::BTreeSet; +use std::rc::Rc; + +use figmog::flatten::flatten_file; +use figmog::model::{Id, Rec}; +use figmog::store::{Churn, collect_sweepable, sync}; +use fold::pipeline::{Keyed, Map}; + +/// Open a store whose pipeline is fronted by a delta probe: every push +/// into the graph bumps the counter. Zero churn must mean zero pushes. +macro_rules! open_probed { + ($path:expr, $counter:expr) => {{ + let c = $counter.clone(); + ::fold::stream::KeyedStream::::new( + $path, + Map::new( + move |d: &Keyed| { + c.set(c.get() + 1); + d.clone() + }, + figmog::figmog_pipeline!(), + ), + ) + }}; +} + +fn pull( + st: &mut fold::stream::KeyedStream>>, + fixture: &serde_json::Value, +) -> Churn { + // NOTE: `impl Trait` in argument position works here because we only + // use write-path (upsert/remove) APIs; readers stay at the call site. + let flattened = flatten_file(fixture).unwrap(); + let prior = BTreeSet::new(); // overridden by tests that need the sweep + sync(st, &prior, &flattened, 1_000) +} + +#[test] +fn initial_pull_populates_every_sink() { + let dir = tempfile::tempdir().unwrap(); + let counter = Rc::new(Cell::new(0usize)); + let mut st = open_probed!(dir.path().join("db"), counter); + + let churn = pull(&mut st, &common::fixture_v1()); + assert_eq!(churn, Churn { added: 18, changed: 0, removed: 0, unchanged: 0 }); + // 18 records + 1 meta row, all fresh inserts -> 19 pushes + assert_eq!(counter.get(), 19); + + st.rtx(|((nodes, children, text, instances_of, styled_by, bound_to, by_type), + components, component_sets, styles, _vars, _colls, meta)| { + assert_eq!(nodes.iter().count(), 12); + assert_eq!(nodes.get(&"1:2".to_string()).unwrap().name, "Title"); + + let mut kids = children.get(&"1:1".to_string()); + kids.sort(); + assert_eq!(kids, vec![(0, "1:2".to_string()), (1, "1:3".to_string())]); + + let hits = text.search("garden", 5); + assert!(hits.iter().any(|h| h.val == "1:2"), "bm25 finds the title text"); + + assert_eq!(instances_of.search(&"2:2".to_string()), vec!["1:3".to_string()]); + assert_eq!(styled_by.search(&"S:2".to_string()), vec!["1:2".to_string()]); + assert_eq!(bound_to.search(&"VariableID:100".to_string()), vec!["1:1".to_string()]); + + let mut texts = by_type.search(&"TEXT".to_string()); + texts.sort(); + assert_eq!(texts, vec!["1:2".to_string()]); + + assert_eq!(components.iter().count(), 3); + assert_eq!(component_sets.iter().count(), 1); + assert_eq!(styles.iter().count(), 2); + let m = meta.get(&()).unwrap(); + assert_eq!(m.version, "100"); + assert_eq!(m.synced_at_unix_ms, 1_000); + }); +} + +#[test] +fn identical_repull_causes_zero_churn() { + let dir = tempfile::tempdir().unwrap(); + let counter = Rc::new(Cell::new(0usize)); + let mut st = open_probed!(dir.path().join("db"), counter); + + pull(&mut st, &common::fixture_v1()); + counter.set(0); + let churn = pull(&mut st, &common::fixture_v1()); // same synced_at too + assert_eq!(churn, Churn { added: 0, changed: 0, removed: 0, unchanged: 18 }); + assert_eq!(counter.get(), 0, "no delta may enter the graph on an identical re-pull"); +} + +#[test] +fn reopen_resumes_persisted_state() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("db"); + { + let mut st = figmog::open_store!(&db); + let flattened = flatten_file(&common::fixture_v1()).unwrap(); + sync(&mut st, &BTreeSet::new(), &flattened, 1_000); + } + let st = figmog::open_store!(&db); + st.rtx(|((nodes, ..), _, _, _, _, _, _)| { + assert_eq!(nodes.iter().count(), 12); + }); +} +``` + +Adjust the destructuring/count numbers only if the fixture changes: 12 nodes + 3 components + 1 set + 2 styles = **18** records, +1 meta row. + +- [ ] **Step 3: Run to verify failure, then make it compile and pass** + +```bash +cargo test -p figmog --test sync +``` +Iterate until PASS. Likely friction points, in order: generic bounds on `sync` (`P: Push>` is all it needs), reader type paths, `Bm25::search` signature (`fn search(&self, q: &str, k: usize) -> Vec>` per the `search` example), tuple arity (root = 7 elements, node branch = 7 — both within fold's 16-tuple `Push`). + +- [ ] **Step 4: Full test run + clippy** — `cargo test -p figmog && cargo clippy -p figmog -- -D warnings` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/figmog/src/store.rs examples/figmog/tests/sync.rs +git commit -m "feat(figmog): pipeline, sync transaction, churn accounting" +``` + +--- + +### Task 7: `store` — diff, sweep, atomicity (fixture v2) + +**Files:** +- Modify: `examples/figmog/tests/common/mod.rs` (add `fixture_v2`) +- Modify: `examples/figmog/tests/sync.rs` (add tests) + +**Interfaces:** +- Consumes: Task 6's `sync`/`collect_sweepable`/probe macro. +- Produces: `common::fixture_v2() -> Value` — v1 with: node `1:2` renamed `Title` → `Headline`; node `1:9` deleted; new TEXT node `1:4` (name `Subtitle`, characters `Planting season`) appended as third child of `1:1`; instance `1:3` `Size` property changed to `Small` and `componentId` to `2:3`; version `"101"`. + +- [ ] **Step 1: Implement `fixture_v2`** in `tests/common/mod.rs` — derive from v1 by mutation so the fixtures can't drift apart: + +```rust +/// v1 plus: rename 1:2, delete 1:9, add 1:4, repoint instance 1:3 at the +/// Small variant, bump version. +pub fn fixture_v2() -> Value { + let mut v = fixture_v1(); + v["version"] = json!("101"); + v["lastModified"] = json!("2026-08-02T00:00:00Z"); + let page1 = &mut v["document"]["children"][0]; + // delete 1:9 (second child of the canvas) + page1["children"].as_array_mut().unwrap().remove(1); + let hero = &mut page1["children"][0]; + hero["children"][0]["name"] = json!("Headline"); + hero["children"][1]["componentId"] = json!("2:3"); + hero["children"][1]["componentProperties"]["Size"]["value"] = json!("Small"); + hero["children"][1]["componentProperties"]["State"]["value"] = json!("Hover"); + hero["children"].as_array_mut().unwrap().push(json!({ + "id": "1:4", "name": "Subtitle", "type": "TEXT", + "characters": "Planting season", "children": [] + })); + v +} +``` + +- [ ] **Step 2: Add the failing tests** to `tests/sync.rs`: + +```rust +/// Pull v2 over v1 with the sweep enabled, capturing probe deltas. +fn pull_with_sweep( + st: &mut fold::stream::KeyedStream>>, + fixture: &serde_json::Value, + prior: BTreeSet, + synced_at: u64, +) -> Churn { + let flattened = flatten_file(fixture).unwrap(); + sync(st, &prior, &flattened, synced_at) +} + +#[test] +fn v1_to_v2_minimal_churn_and_index_consistency() { + let dir = tempfile::tempdir().unwrap(); + let counter = Rc::new(Cell::new(0usize)); + let mut st = open_probed!(dir.path().join("db"), counter); + pull(&mut st, &common::fixture_v1()); + + let prior = st.rtx(|((nodes, ..), components, component_sets, styles, _, _, _)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + counter.set(0); + let churn = pull_with_sweep(&mut st, &common::fixture_v2(), prior, 1_000); + + // v2 has 18 records: 12 nodes (12 - 1:9 + 1:4) + 3 components + 1 set + // + 2 styles. changed: 1:2 (rename), 1:3 (variant repoint). added: 1:4. + // removed: 1:9. unchanged: 18 - 1 - 2 = 15 (meta row is not counted). + assert_eq!(churn, Churn { added: 1, changed: 2, removed: 1, unchanged: 15 }); + // pushes: changed 2×2 + added 1 + removed 1 + meta retract/insert 2 = 8 + assert_eq!(counter.get(), 8); + + st.rtx(|((nodes, children, text, instances_of, _styled, _bound, by_type), + _c, _cs, _s, _v, _vc, meta)| { + // rename re-indexed in bm25 + assert!(text.search("Headline", 5).iter().any(|h| h.val == "1:2")); + assert!(!text.search("Title", 5).iter().any(|h| h.val == "1:2")); + // deleted node gone everywhere + assert!(nodes.get(&"1:9".to_string()).is_none()); + assert!(!by_type.search(&"RECTANGLE".to_string()).contains(&"1:9".to_string())); + let kids = children.get(&"0:1".to_string()); + assert!(!kids.iter().any(|(_, id)| id == "1:9")); + // instance repoint moved the inverted index posting + assert_eq!(instances_of.search(&"2:2".to_string()), Vec::::new()); + assert_eq!(instances_of.search(&"2:3".to_string()), vec!["1:3".to_string()]); + // new node present + assert_eq!(nodes.get(&"1:4".to_string()).unwrap().name, "Subtitle"); + assert!(text.search("Planting", 5).iter().any(|h| h.val == "1:4")); + assert_eq!(meta.get(&()).unwrap().version, "101"); + }); +} + +#[test] +fn sweep_never_touches_variables() { + use figmog::model::{VariableCollectionRec, VariableRec}; + let dir = tempfile::tempdir().unwrap(); + let mut st = figmog::open_store!(dir.path().join("db")); + pull(&mut st, &common::fixture_v1()); + // hand-insert an imported variable, then re-pull with a full sweep set + st.wtx(|tx| { + tx.upsert( + &Id::Variable("VariableID:100".into()), + &Rec::Variable(VariableRec { + id: "VariableID:100".into(), + name: "color/bg".into(), + resolved_type: "COLOR".into(), + collection_id: "VC:1".into(), + values_by_mode: vec![("M:1".into(), "{\"r\":0.06}".into())], + description: String::new(), + scopes: vec![], + }), + ); + tx.upsert( + &Id::VariableCollection("VC:1".into()), + &Rec::VariableCollection(VariableCollectionRec { + id: "VC:1".into(), + name: "core".into(), + modes: vec![("M:1".into(), "light".into())], + default_mode_id: "M:1".into(), + }), + ); + }); + let prior = st.rtx(|((nodes, ..), components, component_sets, styles, _, _, _)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + pull_with_sweep(&mut st, &common::fixture_v2(), prior, 2_000); + st.rtx(|(_, _, _, _, vars, colls, _)| { + assert!(vars.get(&"VariableID:100".to_string()).is_some()); + assert!(colls.get(&"VC:1".to_string()).is_some()); + }); +} + +#[test] +fn panicking_transaction_rolls_back_entirely() { + let dir = tempfile::tempdir().unwrap(); + let mut st = figmog::open_store!(dir.path().join("db")); + pull(&mut st, &common::fixture_v1()); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + st.wtx(|tx| { + tx.upsert( + &Id::Node("9:9".into()), + &Rec::Node(figmog::model::NodeRec { + id: "9:9".into(), + parent_id: Some("0:1".into()), + child_index: 7, + page_id: "0:1".into(), + node_type: "FRAME".into(), + name: "doomed".into(), + visible: true, + text: None, + component_id: None, + component_properties: vec![], + property_definitions: None, + style_refs: vec![], + bound_variables: vec![], + abs_bounds: None, + raw: "{}".into(), + }), + ); + panic!("mid-transaction failure"); + }) + })); + assert!(result.is_err()); + st.rtx(|((nodes, ..), _, _, _, _, _, meta)| { + assert!(nodes.get(&"9:9".to_string()).is_none(), "aborted upsert must not persist"); + assert_eq!(nodes.iter().count(), 12); + assert_eq!(meta.get(&()).unwrap().version, "100"); + }); +} +``` + +- [ ] **Step 3: Run to verify failure, fix, pass** — `cargo test -p figmog --test sync`. The probe-count assertion (8) is the heart of the whole project — if it's off, find out which record churned unexpectedly (print `churn` and diff the flattens) rather than adjusting the number. Legitimate adjustment only if a fixture edit above changes the arithmetic, and then the comment must be recomputed too. + +- [ ] **Step 4: Full test run + clippy** → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/figmog/tests +git commit -m "test(figmog): diff churn, sweep exemptions, rollback atomicity" +``` + +--- + +### Task 8: `api.rs` — Figma HTTP client behind a trait + +**Files:** +- Modify: `examples/figmog/src/api.rs` +- Test: unit tests in the same file + +**Interfaces:** +- Produces: + - `#[derive(Debug, thiserror::Error)] pub enum ApiError { RateLimited { retry_after: Duration }, Auth, Http { status: u16, msg: String }, Network(String), Parse(String) }` + - `pub struct FileMetaResp { pub name: String, pub last_touched_at: String }` + - `pub trait FigmaApi { fn file_meta(&self, key: &str) -> Result; fn file(&self, key: &str) -> Result; }` + - `pub struct UreqApi { … } ; UreqApi::new(token: String) ; UreqApi::with_base_url(token, base)` + - `pub(crate) fn parse_meta_response(v: &serde_json::Value) -> Result` + +- [ ] **Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_meta_envelope() { + let v = serde_json::json!({ + "file": {"name": "F", "last_touched_at": "2026-08-01T00:00:00Z", "folder_name": "x"} + }); + let m = parse_meta_response(&v).unwrap(); + assert_eq!(m.name, "F"); + assert_eq!(m.last_touched_at, "2026-08-01T00:00:00Z"); + } + + #[test] + fn meta_missing_fields_is_parse_error() { + assert!(matches!( + parse_meta_response(&serde_json::json!({"file": {}})), + Err(ApiError::Parse(_)) + )); + } + + #[test] + fn error_from_status_maps_429_and_403() { + assert!(matches!( + error_from_status(429, Some("30"), "slow down".into()), + ApiError::RateLimited { retry_after } if retry_after == std::time::Duration::from_secs(30) + )); + // absent/garbage Retry-After falls back to 60s + assert!(matches!( + error_from_status(429, None, String::new()), + ApiError::RateLimited { retry_after } if retry_after == std::time::Duration::from_secs(60) + )); + assert!(matches!(error_from_status(403, None, String::new()), ApiError::Auth)); + assert!(matches!( + error_from_status(500, None, "boom".into()), + ApiError::Http { status: 500, .. } + )); + } +} +``` + +- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog api` → compile FAIL. + +- [ ] **Step 3: Implement** + +```rust +//! Figma REST client. Everything network-facing sits behind [`FigmaApi`] +//! so the rest of the crate is testable offline. + +use std::time::Duration; + +use serde_json::Value; + +/// Errors surfaced by [`FigmaApi`] implementations. +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("rate limited; retry after {retry_after:?}")] + RateLimited { retry_after: Duration }, + #[error("authentication failed — check FIGMA_TOKEN and file access")] + Auth, + #[error("figma returned {status}: {msg}")] + Http { status: u16, msg: String }, + #[error("network error: {0}")] + Network(String), + #[error("unexpected response shape: {0}")] + Parse(String), +} + +/// Subset of `GET /v1/files/:key/meta` figmog needs. +#[derive(Debug, Clone, PartialEq)] +pub struct FileMetaResp { + pub name: String, + /// "The UTC ISO 8601 time at which the file content was last modified." + pub last_touched_at: String, +} + +/// The two calls figmog makes. `file_meta` is Tier 3 (cheap, poll it); +/// `file` is Tier 1 (expensive, call only on change). +pub trait FigmaApi { + fn file_meta(&self, key: &str) -> Result; + fn file(&self, key: &str) -> Result; +} + +pub(crate) fn parse_meta_response(v: &Value) -> Result { + let file = v.get("file").ok_or_else(|| ApiError::Parse("no `file` object".into()))?; + let get = |k: &str| { + file.get(k) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| ApiError::Parse(format!("meta missing `{k}`"))) + }; + Ok(FileMetaResp { name: get("name")?, last_touched_at: get("last_touched_at")? }) +} + +pub(crate) fn error_from_status(status: u16, retry_after: Option<&str>, msg: String) -> ApiError { + match status { + 429 => ApiError::RateLimited { + retry_after: Duration::from_secs( + retry_after.and_then(|s| s.trim().parse().ok()).unwrap_or(60), + ), + }, + 401 | 403 => ApiError::Auth, + _ => ApiError::Http { status, msg }, + } +} + +/// Blocking `ureq` implementation against api.figma.com. +pub struct UreqApi { + token: String, + base_url: String, +} + +impl UreqApi { + pub fn new(token: String) -> Self { + Self::with_base_url(token, "https://api.figma.com".into()) + } + /// `base_url` override for tests / proxies. + pub fn with_base_url(token: String, base_url: String) -> Self { + UreqApi { token, base_url } + } + + fn get_json(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + match ureq::get(&url).set("X-Figma-Token", &self.token).call() { + Ok(resp) => resp + .into_json() + .map_err(|e| ApiError::Parse(e.to_string())), + Err(ureq::Error::Status(status, resp)) => { + let retry = resp.header("Retry-After").map(str::to_string); + let msg = resp.into_string().unwrap_or_default(); + Err(error_from_status(status, retry.as_deref(), msg)) + } + Err(e) => Err(ApiError::Network(e.to_string())), + } + } +} + +impl FigmaApi for UreqApi { + fn file_meta(&self, key: &str) -> Result { + parse_meta_response(&self.get_json(&format!("/v1/files/{key}/meta"))?) + } + fn file(&self, key: &str) -> Result { + self.get_json(&format!("/v1/files/{key}")) + } +} +``` + +- [ ] **Step 4: Run tests + clippy** → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/figmog/src/api.rs +git commit -m "feat(figmog): figma api client behind a testable trait" +``` + +--- + +### Task 9: `watch.rs` — change-detection state machine + +**Files:** +- Modify: `examples/figmog/src/watch.rs` +- Test: unit tests in the same file + +**Interfaces:** +- Consumes: `api::{ApiError, FigmaApi, FileMetaResp}`. +- Produces: + - `pub enum Tick { Unchanged, Changed { last_touched_at: String }, Wait { after: Duration } }` + - `pub struct Watcher { … } ; Watcher::new(last_seen: Option) ; fn tick(&mut self, api: &dyn FigmaApi, key: &str) -> Tick` + - Backoff contract: rate-limit waits use the server's `Retry-After`; network/HTTP failures start at 5s and double per consecutive failure, capped at 300s; any success resets the backoff. + +- [ ] **Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::api::{ApiError, FigmaApi, FileMetaResp}; + use std::cell::RefCell; + use std::time::Duration; + + /// Scripted API: pops one response per call; panics if `file` is called. + struct Script(RefCell>>); + impl Script { + fn new(mut responses: Vec>) -> Self { + responses.reverse(); + Script(RefCell::new(responses)) + } + } + impl FigmaApi for Script { + fn file_meta(&self, _key: &str) -> Result { + self.0.borrow_mut().pop().expect("unexpected extra file_meta call") + } + fn file(&self, _key: &str) -> Result { + panic!("watcher must never fetch the file itself"); + } + } + + fn meta(t: &str) -> Result { + Ok(FileMetaResp { name: "F".into(), last_touched_at: t.into() }) + } + + #[test] + fn unchanged_then_changed() { + let api = Script::new(vec![meta("t1"), meta("t1"), meta("t2")]); + let mut w = Watcher::new(Some("t1".into())); + assert!(matches!(w.tick(&api, "k"), Tick::Unchanged)); + assert!(matches!(w.tick(&api, "k"), Tick::Unchanged)); + match w.tick(&api, "k") { + Tick::Changed { last_touched_at } => assert_eq!(last_touched_at, "t2"), + other => panic!("expected Changed, got {other:?}"), + } + } + + #[test] + fn first_tick_with_no_history_is_changed() { + let api = Script::new(vec![meta("t1")]); + let mut w = Watcher::new(None); + assert!(matches!(w.tick(&api, "k"), Tick::Changed { .. })); + } + + #[test] + fn rate_limit_uses_retry_after() { + let api = Script::new(vec![ + Err(ApiError::RateLimited { retry_after: Duration::from_secs(30) }), + meta("t1"), + ]); + let mut w = Watcher::new(Some("t1".into())); + assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(30))); + assert!(matches!(w.tick(&api, "k"), Tick::Unchanged)); + } + + #[test] + fn failures_back_off_exponentially_and_reset_on_success() { + let api = Script::new(vec![ + Err(ApiError::Network("down".into())), + Err(ApiError::Network("down".into())), + Err(ApiError::Network("down".into())), + meta("t1"), + Err(ApiError::Network("down".into())), + ]); + let mut w = Watcher::new(Some("t1".into())); + assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(5))); + assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(10))); + assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(20))); + assert!(matches!(w.tick(&api, "k"), Tick::Unchanged)); + assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(5))); + } + + #[test] + fn backoff_caps_at_five_minutes() { + let mut responses: Vec> = + (0..10).map(|_| Err(ApiError::Network("down".into()))).collect(); + responses.push(meta("t1")); + let api = Script::new(responses); + let mut w = Watcher::new(Some("t1".into())); + let mut last = Duration::ZERO; + for _ in 0..10 { + match w.tick(&api, "k") { + Tick::Wait { after } => last = after, + other => panic!("expected Wait, got {other:?}"), + } + } + assert_eq!(last, Duration::from_secs(300)); + } +} +``` + +- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog watch` → compile FAIL. + +- [ ] **Step 3: Implement** + +```rust +//! Polling change detection: a pure state machine the CLI loop drives. +//! No sleeping, no clock — callers act on the returned [`Tick`]. + +use std::time::Duration; + +use crate::api::{ApiError, FigmaApi}; + +const BACKOFF_START: Duration = Duration::from_secs(5); +const BACKOFF_CAP: Duration = Duration::from_secs(300); + +/// Outcome of one poll. +#[derive(Debug)] +pub enum Tick { + /// File unchanged since the last seen `last_touched_at`. + Unchanged, + /// File changed — caller should pull. Carries the new watermark. + Changed { last_touched_at: String }, + /// Transient failure or rate limit — caller should sleep `after` + /// (instead of its normal interval), then tick again. + Wait { after: Duration }, +} + +/// Tracks the last seen content-modification time and failure backoff. +pub struct Watcher { + last_seen: Option, + backoff: Duration, +} + +impl Watcher { + /// `last_seen`: the stored `FileMeta.last_modified`, if any. A spurious + /// mismatch only costs one pull that produces zero churn. + pub fn new(last_seen: Option) -> Self { + Watcher { last_seen, backoff: BACKOFF_START } + } + + pub fn tick(&mut self, api: &dyn FigmaApi, key: &str) -> Tick { + match api.file_meta(key) { + Ok(meta) => { + self.backoff = BACKOFF_START; + if self.last_seen.as_deref() == Some(meta.last_touched_at.as_str()) { + Tick::Unchanged + } else { + self.last_seen = Some(meta.last_touched_at.clone()); + Tick::Changed { last_touched_at: meta.last_touched_at } + } + } + Err(ApiError::RateLimited { retry_after }) => Tick::Wait { after: retry_after }, + Err(_) => { + let after = self.backoff; + self.backoff = (self.backoff * 2).min(BACKOFF_CAP); + Tick::Wait { after } + } + } + } +} +``` + +- [ ] **Step 4: Run tests + clippy** → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/figmog/src/watch.rs +git commit -m "feat(figmog): watch state machine with retry-after and backoff" +``` + +--- + +### Task 10: `vars.rs` — variables import parsing + +**Files:** +- Modify: `examples/figmog/src/vars.rs` +- Create: `examples/figmog/tests/fixtures/variables-export.json` +- Create: `examples/figmog/tests/vars.rs` + +**Interfaces:** +- Consumes: `model::*`. +- Produces: + - `#[derive(Debug, thiserror::Error)] pub enum ImportError { #[error("unrecognized export shape: {0}")] Shape(String) }` + - `pub fn parse_variables_export(v: &serde_json::Value) -> Result, ImportError>` — accepts the Enterprise REST `variables/local` shape (`{meta: {variables, variableCollections}}`) and the bare `{variables, variableCollections}` shape (plugin-console export). Output sorted: collections first (by id), then variables (by id). + +- [ ] **Step 1: Write the fixture** — `tests/fixtures/variables-export.json` (synthetic, REST-shaped): + +```json +{ + "status": 200, + "error": false, + "meta": { + "variables": { + "VariableID:100": { + "id": "VariableID:100", + "name": "color/surface/primary", + "resolvedType": "COLOR", + "variableCollectionId": "VariableCollectionId:1", + "valuesByMode": { + "1:0": { "r": 0.06, "g": 0.13, "b": 0.2, "a": 1.0 }, + "1:1": { "type": "VARIABLE_ALIAS", "id": "VariableID:101" } + }, + "description": "primary surface", + "scopes": ["FRAME_FILL", "SHAPE_FILL"] + }, + "VariableID:101": { + "id": "VariableID:101", + "name": "color/base/ink", + "resolvedType": "COLOR", + "variableCollectionId": "VariableCollectionId:1", + "valuesByMode": { "1:0": { "r": 0.9, "g": 0.9, "b": 0.9, "a": 1.0 }, + "1:1": { "r": 0.1, "g": 0.1, "b": 0.1, "a": 1.0 } }, + "description": "", + "scopes": ["ALL_SCOPES"] + }, + "VariableID:200": { + "id": "VariableID:200", + "name": "space/md", + "resolvedType": "FLOAT", + "variableCollectionId": "VariableCollectionId:2", + "valuesByMode": { "2:0": 16.0 }, + "description": "", + "scopes": ["GAP"] + } + }, + "variableCollections": { + "VariableCollectionId:1": { + "id": "VariableCollectionId:1", + "name": "colors", + "modes": [ {"modeId": "1:0", "name": "light"}, {"modeId": "1:1", "name": "dark"} ], + "defaultModeId": "1:0" + }, + "VariableCollectionId:2": { + "id": "VariableCollectionId:2", + "name": "spacing", + "modes": [ {"modeId": "2:0", "name": "default"} ], + "defaultModeId": "2:0" + } + } + } +} +``` + +- [ ] **Step 2: Write the failing tests** — `tests/vars.rs`: + +```rust +mod common; + +use figmog::model::{Id, Rec}; +use figmog::vars::parse_variables_export; + +fn export() -> serde_json::Value { + serde_json::from_str(include_str!("fixtures/variables-export.json")).unwrap() +} + +#[test] +fn parses_rest_shape() { + let recs = parse_variables_export(&export()).unwrap(); + // 2 collections then 3 variables, sorted by id + assert_eq!(recs.len(), 5); + assert!(matches!(&recs[0].0, Id::VariableCollection(id) if id == "VariableCollectionId:1")); + let Rec::VariableCollection(c) = &recs[0].1 else { panic!() }; + assert_eq!(c.modes, vec![("1:0".to_string(), "light".to_string()), ("1:1".to_string(), "dark".to_string())]); + assert_eq!(c.default_mode_id, "1:0"); + + let Rec::Variable(v) = &recs[2].1 else { panic!() }; + assert_eq!(v.id, "VariableID:100"); + assert_eq!(v.resolved_type, "COLOR"); + assert_eq!(v.collection_id, "VariableCollectionId:1"); + // values canonical JSON, sorted by mode id; alias kept as-is + assert_eq!(v.values_by_mode[0].0, "1:0"); + assert!(v.values_by_mode[1].1.contains("VARIABLE_ALIAS")); + assert_eq!(v.scopes, vec!["FRAME_FILL", "SHAPE_FILL"]); +} + +#[test] +fn accepts_bare_shape_and_is_deterministic() { + let bare = export()["meta"].clone(); + let a = parse_variables_export(&bare).unwrap(); + let b = parse_variables_export(&export()).unwrap(); + assert_eq!( + postcard::to_allocvec(&a).unwrap(), + postcard::to_allocvec(&b).unwrap(), + "both shapes produce byte-identical records" + ); +} + +#[test] +fn garbage_is_a_shape_error() { + assert!(parse_variables_export(&serde_json::json!({"nope": 1})).is_err()); + assert!(parse_variables_export(&serde_json::json!(null)).is_err()); +} +``` + +- [ ] **Step 3: Run to verify failure** — `cargo test -p figmog --test vars` → compile FAIL. + +- [ ] **Step 4: Implement** (`src/vars.rs`; inference lands in Task 11 in this same file): + +```rust +//! Variables: authoritative import parsing (this module also hosts the +//! free-plan inference in `infer`). + +use std::collections::BTreeMap; + +use serde_json::Value; + +use crate::model::{Id, Rec, VariableCollectionRec, VariableRec}; + +#[derive(Debug, thiserror::Error)] +pub enum ImportError { + #[error("unrecognized variables export shape: {0}")] + Shape(String), +} + +/// Parse a variables export: either the Enterprise REST `variables/local` +/// response (`{meta: {variables, variableCollections}}`) or the bare +/// object a plugin-console export produces. +pub fn parse_variables_export(v: &Value) -> Result, ImportError> { + let root = v.get("meta").unwrap_or(v); + let variables = root + .get("variables") + .and_then(Value::as_object) + .ok_or_else(|| ImportError::Shape("missing `variables` object".into()))?; + let collections = root + .get("variableCollections") + .and_then(Value::as_object) + .ok_or_else(|| ImportError::Shape("missing `variableCollections` object".into()))?; + + let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); + + let mut recs = Vec::new(); + let sorted: BTreeMap<_, _> = collections.iter().collect(); + for (id, c) in sorted { + let mut modes: Vec<(String, String)> = c + .get("modes") + .and_then(Value::as_array) + .map(|ms| ms.iter().map(|m| (s(m, "modeId"), s(m, "name"))).collect()) + .unwrap_or_default(); + modes.sort(); + recs.push(( + Id::VariableCollection(id.clone()), + Rec::VariableCollection(VariableCollectionRec { + id: id.clone(), + name: s(c, "name"), + modes, + default_mode_id: s(c, "defaultModeId"), + }), + )); + } + let sorted: BTreeMap<_, _> = variables.iter().collect(); + for (id, var) in sorted { + let mut values_by_mode: Vec<(String, String)> = var + .get("valuesByMode") + .and_then(Value::as_object) + .map(|m| { + m.iter() + .map(|(mode, val)| { + (mode.clone(), serde_json::to_string(val).expect("Value serializes")) + }) + .collect() + }) + .unwrap_or_default(); + values_by_mode.sort(); + let scopes: Vec = var + .get("scopes") + .and_then(Value::as_array) + .map(|xs| xs.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + recs.push(( + Id::Variable(id.clone()), + Rec::Variable(VariableRec { + id: id.clone(), + name: s(var, "name"), + resolved_type: s(var, "resolvedType"), + collection_id: s(var, "variableCollectionId"), + values_by_mode, + description: s(var, "description"), + scopes, + }), + )); + } + Ok(recs) +} +``` + +- [ ] **Step 5: Run tests + clippy** → PASS. Commit: + +```bash +git add examples/figmog/src/vars.rs examples/figmog/tests/vars.rs examples/figmog/tests/fixtures +git commit -m "feat(figmog): variables export parsing (REST and plugin shapes)" +``` + +--- + +### Task 11: `vars.rs` — free-plan inference + style values + +**Files:** +- Modify: `examples/figmog/src/vars.rs` +- Modify: `examples/figmog/tests/vars.rs` (add tests) + +**Interfaces:** +- Consumes: `NodeRec.bound_variables` pointers (Task 5 contract: pointer addresses the resolved value), `NodeRec.raw`. +- Produces: + - `#[derive(Debug, Serialize)] pub struct VarUsage { pub variable_id: String, pub sites: Vec<(String, String)>, pub observed: Vec }` — sites are (node_id, pointer), sorted; observed values are deduped canonical JSON, sorted. + - `pub fn infer_from_nodes<'a>(nodes: impl Iterator) -> Vec` — iterator-based so it works from any reader or test vector; output sorted by variable id. + - `pub fn style_value_from_consumer(style_type: &str, consumer_raw: &str) -> Option` — TEXT→`/style`, FILL→`/fills`, EFFECT→`/effects`, GRID→`/layoutGrids`. + +- [ ] **Step 1: Add the failing tests** to `tests/vars.rs`: + +```rust +use figmog::flatten::flatten_file; +use figmog::vars::{infer_from_nodes, style_value_from_consumer}; + +#[test] +fn infers_values_and_sites_from_fixture() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let nodes: Vec = out + .recs + .iter() + .filter_map(|(_, r)| match r { Rec::Node(n) => Some(n.clone()), _ => None }) + .collect(); + let usages = infer_from_nodes(nodes.iter()); + + assert_eq!(usages.len(), 2, "two distinct variables bound in fixture"); + let color = usages.iter().find(|u| u.variable_id == "VariableID:100").unwrap(); + assert_eq!(color.sites, vec![("1:1".to_string(), "/fills/0/color".to_string())]); + let observed: serde_json::Value = serde_json::from_str(&color.observed[0]).unwrap(); + assert_eq!(observed["r"], 0.06); + + let pad = usages.iter().find(|u| u.variable_id == "VariableID:200").unwrap(); + assert_eq!(pad.observed, vec!["16.0".to_string()]); +} + +#[test] +fn style_values_come_from_consumers() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let title = out.recs.iter().find_map(|(k, r)| match (k, r) { + (Id::Node(id), Rec::Node(n)) if id == "1:2" => Some(n.clone()), + _ => None, + }).unwrap(); + let v = style_value_from_consumer("TEXT", &title.raw).unwrap(); + assert_eq!(v["fontSize"], 32.0); + assert!(style_value_from_consumer("FILL", &title.raw).is_none(), "no fills on the text node"); +} +``` + +Note on `"16.0"`: `serde_json` renders the fixture's `16.0` float back as `16.0`. If the implementation observes `16` instead, the fixture number parsed as an integer — keep the fixture value a float (as Figma emits) and assert what `serde_json::to_string` actually produces; the test value may be adjusted to the observed canonical form **once**, with a comment. + +- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog --test vars` → compile FAIL. + +- [ ] **Step 3: Implement** (append to `src/vars.rs`): + +```rust +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::model::NodeRec; + +/// Everything known about one variable from its usage sites alone. +#[derive(Debug, Serialize)] +pub struct VarUsage { + pub variable_id: String, + /// (node_id, json-pointer of the bound property), sorted. + pub sites: Vec<(String, String)>, + /// Distinct resolved values observed at those sites (canonical JSON), + /// sorted. Usually one value; more indicates multi-mode usage. + pub observed: Vec, +} + +/// Free-plan inference: fold every node's variable bindings into per-variable +/// usage + observed resolved values (the concrete values Figma bakes in +/// next to each binding — default-mode values unless a frame overrides its +/// mode). +pub fn infer_from_nodes<'a>(nodes: impl Iterator) -> Vec { + let mut by_var: BTreeMap, Vec)> = BTreeMap::new(); + for node in nodes { + let raw: serde_json::Value = match serde_json::from_str(&node.raw) { + Ok(v) => v, + Err(_) => continue, + }; + for (pointer, var_id) in &node.bound_variables { + let entry = by_var.entry(var_id.clone()).or_default(); + entry.0.push((node.id.clone(), pointer.clone())); + if let Some(v) = raw.pointer(pointer) { + entry.1.push(serde_json::to_string(v).expect("Value serializes")); + } + } + } + by_var + .into_iter() + .map(|(variable_id, (mut sites, mut observed))| { + sites.sort(); + observed.sort(); + observed.dedup(); + VarUsage { variable_id, sites, observed } + }) + .collect() +} + +/// Derive a style's definition from one consumer node's raw JSON. +/// Style definitions are not in the file JSON; consumers carry the +/// resolved properties. +pub fn style_value_from_consumer(style_type: &str, consumer_raw: &str) -> Option { + let raw: serde_json::Value = serde_json::from_str(consumer_raw).ok()?; + let pointer = match style_type { + "TEXT" => "/style", + "FILL" => "/fills", + "EFFECT" => "/effects", + "GRID" => "/layoutGrids", + _ => return None, + }; + raw.pointer(pointer).cloned() +} +``` + +- [ ] **Step 4: Run tests + clippy** → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/figmog/src/vars.rs examples/figmog/tests/vars.rs +git commit -m "feat(figmog): variable inference and style value derivation" +``` + +--- + +### Task 12: CLI part 1 — engine commands + core reads + +**Files:** +- Modify: `examples/figmog/src/cli.rs` (replace the stub) +- Create: `examples/figmog/tests/cli.rs` + +**Interfaces:** +- Consumes: everything above. +- Produces: `pub fn run() -> i32`; subcommands `pull` (with `--from-file`, `--fresh`), `status`, `pages`, `tree`, `get`, `find`; global `--json` and `--db`; config file `.figmog/current` holding the last file key; DB at `.figmog//db`. Remaining subcommands print a "not yet implemented" error (exit 2) until Task 13. +- Structure: `enum Cmd` (clap derive) + one `dispatch` that opens the store via `open_store!` once and calls per-command functions taking concrete reader references. Human output goes through small `fn`s; `--json` serializes explicit `serde_json::json!` structures — never `Debug` formatting. + +- [ ] **Step 1: Write the failing CLI smoke tests** — `tests/cli.rs`: + +```rust +mod common; + +use assert_cmd::Command; + +/// Materialize fixture_v1 into a DB via `pull --from-file` and return the +/// (tempdir, db-arg) pair every read command needs. +fn fixture_db() -> (tempfile::TempDir, String) { + let dir = tempfile::tempdir().unwrap(); + let response = dir.path().join("resp.json"); + std::fs::write(&response, serde_json::to_string(&common::fixture_v1()).unwrap()).unwrap(); + let db = dir.path().join("db").display().to_string(); + Command::cargo_bin("figmog") + .unwrap() + .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db]) + .assert() + .success(); + (dir, db) +} + +#[test] +fn pull_from_file_reports_churn_and_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let response = dir.path().join("resp.json"); + std::fs::write(&response, serde_json::to_string(&common::fixture_v1()).unwrap()).unwrap(); + let db = dir.path().join("db").display().to_string(); + + let out = Command::cargo_bin("figmog").unwrap() + .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db, "--json"]) + .assert().success(); + let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + assert_eq!(v["added"], 18); + + let out = Command::cargo_bin("figmog").unwrap() + .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db, "--json"]) + .assert().success(); + let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + assert_eq!(v["unchanged"], 18); + assert_eq!(v["added"], 0); +} + +#[test] +fn status_pages_tree_get_find() { + let (_dir, db) = fixture_db(); + let run = |args: &[&str]| { + let out = Command::cargo_bin("figmog").unwrap() + .args(args).args(["--db", &db, "--json"]) + .assert().success(); + serde_json::from_slice::(&out.get_output().stdout).unwrap() + }; + + let status = run(&["status"]); + assert_eq!(status["name"], "Fixture"); + assert_eq!(status["version"], "100"); + assert_eq!(status["nodes"], 12); + + let pages = run(&["pages"]); + assert_eq!(pages.as_array().unwrap().len(), 3); + assert_eq!(pages[0]["id"], "0:1"); + assert_eq!(pages[0]["name"], "Page 1"); + + let tree = run(&["tree", "1:1"]); + let kids = tree["children"].as_array().unwrap(); + assert_eq!(kids.len(), 2); + assert_eq!(kids[0]["id"], "1:2"); // numeric child order + + // node-id normalization: URL form accepted + let get = run(&["get", "1-2"]); + assert_eq!(get["name"], "Title"); + assert_eq!(get["characters"], "Welcome to the garden"); + + let texts = run(&["find", "--type", "TEXT"]); + assert_eq!(texts.as_array().unwrap().len(), 1); + assert_eq!(texts[0]["id"], "1:2"); + + let on_page = run(&["find", "--type", "COMPONENT", "--page", "0:2"]); + assert_eq!(on_page.as_array().unwrap().len(), 3); // 2:2, 2:3, 3:1 +} + +#[test] +fn get_unknown_node_fails_cleanly() { + let (_dir, db) = fixture_db(); + Command::cargo_bin("figmog").unwrap() + .args(["get", "99:99", "--db", &db]) + .assert() + .failure() + .code(1); +} +``` + +- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog --test cli` → FAIL (stub exits 2). + +- [ ] **Step 3: Implement `cli.rs`.** Full skeleton — the shape matters more than the printing details: + +```rust +//! Command-line surface. Read commands never touch the network: they open +//! the local store and read one snapshot. + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use clap::{Parser, Subcommand}; +use serde_json::{Value, json}; + +use crate::api::{FigmaApi, UreqApi}; +use crate::flatten::flatten_file; +use crate::ident::{normalize_node_id, parse_file_ref}; +use crate::model::{FileMeta, Id, NodeRec, Rec}; +use crate::store::{Churn, collect_sweepable, sync}; +use crate::watch::{Tick, Watcher}; + +#[derive(Parser)] +#[command(name = "figmog", about = "fold-backed local mirror of a Figma file")] +struct Cli { + /// Emit machine-readable JSON on stdout. + #[arg(long, global = true)] + json: bool, + /// Store directory (default: .figmog//db). + #[arg(long, global = true)] + db: Option, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Fetch the file (or read a saved response) and sync the mirror. + Pull { + /// File key or figma.com URL. Optional after the first pull. + file: Option, + /// Ingest a saved GET /v1/files/:key response instead of the network. + #[arg(long)] + from_file: Option, + /// Wipe the store and rebuild from scratch. + #[arg(long)] + fresh: bool, + }, + /// Poll for changes and pull automatically. + Watch { + file: Option, + /// Poll interval in seconds. + #[arg(long, default_value = "10")] + interval: u64, + }, + /// File name, version, last modified, node count. + Status, + /// List pages. + Pages, + /// Subtree outline (default: whole document). + Tree { id: Option, #[arg(long)] depth: Option }, + /// Full raw JSON of one node. + Get { id: String, #[arg(long)] children: bool }, + /// Nodes by type, optionally within one page. + Find { #[arg(long = "type")] node_type: String, #[arg(long)] page: Option }, + /// BM25 search over layer names and text content. + Search { query: String, #[arg(short = 'n', long, default_value = "10")] limit: usize }, + /// Instances of a component (by node id, key, or name). + Instances { target: String }, + /// Design-system inventory: sets, variant axes, standalone components. + Components, + /// Styles with usage counts; --values derives definitions from consumers. + Styles { #[arg(long = "type")] style_type: Option, #[arg(long)] values: bool }, + /// Nodes using a style id or bound to a variable id. + Uses { id: String }, + /// Variables: authoritative if imported, else inferred from bindings. + Vars { id: Option }, + /// Import a variables export (REST or plugin-console shape). + ImportVariables { path: PathBuf }, +} + +pub fn run() -> i32 { + let cli = Cli::parse(); + match dispatch(cli) { + Ok(()) => 0, + Err(e) => { + eprintln!("figmog: {e}"); + 1 + } + } +} + +fn dispatch(cli: Cli) -> Result<(), String> { + let db = resolve_db(&cli)?; + match cli.cmd { + Cmd::Pull { file, from_file, fresh } => cmd_pull(&db, file, from_file, fresh, cli.json), + Cmd::Watch { file, interval } => cmd_watch(&db, file, interval, cli.json), + other => { + let mut st = crate::open_store!(&db.path); + store_dispatch(&mut st, other, cli.json) // ImportVariables writes; the rest read + } + } +} +``` + +Key implementation points (write them exactly once, in this file): + +- **DB/config resolution** (`resolve_db`): `--db` wins; else read `.figmog/current` for the key → `.figmog//db`; `pull`/`watch` with an explicit file ref call `parse_file_ref`, write `.figmog/current`, and use `.figmog//db`. A read command with no `--db` and no config is an error: `"no mirror here — run `figmog pull ` first"`. Struct: `struct Db { path: PathBuf, key: Option }`. +- **`cmd_pull`**: obtain the response `Value` from `--from-file` (read + `serde_json::from_str`) or `UreqApi::new(token).file(&key)` where token comes from `FIGMA_TOKEN` (`std::env::var`; missing token with a network pull is an error mentioning `FIGMA_TOKEN`). `--fresh`: `std::fs::remove_dir_all(&db.path).ok()` first. Then: `flatten_file` → open store → `collect_sweepable` inside `rtx` → `sync(&mut st, &prior, &flattened, now_ms())` → print `Churn` (json: `serde_json::to_string(&churn)`; human: `synced v: +A ~C -R (=U unchanged)`). +- **`cmd_watch`**: initial pull if `status` has no meta row; then loop `Watcher::new(stored_last_modified)` → `tick(&api, &key)`; `Changed` → same pull path; `Wait { after }` → `std::thread::sleep(after)`; else sleep `interval`. Ctrl-C exits the process (no handler needed). +- **`store_dispatch`**: handles `ImportVariables` with a `wtx` (Task 13); every other command runs one `st.rtx(|readers| …)` with the Task 6 destructuring, matching the command to a `cmd_*` function that takes only the readers it needs. Task 12 implements `Status`, `Pages`, `Tree`, `Get`, `Find` and returns `Err("not yet implemented: ".into())` for the Task 13 set. +- **`cmd_pages`**: `children.get(&root)` where root is the node with `parent_id == None` — simpler: `by_type.search(&"CANVAS".to_string())`, sort ids, look up names in `nodes`, sort by (child_index) via each node's `child_index`. Output `[{id, name}]` sorted by child_index. +- **`cmd_tree`**: resolve start id (`normalize_node_id`, default = the DOCUMENT node, found via `by_type.search(&"DOCUMENT".to_string())`); recursive descent over `children.get(id)` sorting each level by the `u32` index; depth limit honored; JSON shape `{id, name, type, children: […]}`; human shape two-space indented `name [TYPE] id` lines. +- **`cmd_get`**: `nodes.get(&normalize_node_id(&id))` → parse `raw` to `Value`; if `--children`, attach `children: [{id, name, type}]` summaries; print pretty JSON in both modes (it *is* JSON); unknown id → `Err(format!("no node {id} in the mirror"))`. +- **`cmd_find`**: `by_type.search(&node_type)`, look up each node, optional `page` filter on `page_id` (normalized), sort by id, print `[{id, name, page_id}]`. +- **`now_ms()`**: `SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64` — the CLI is the wall-clock boundary; tests bypass it by driving `sync` directly. + +- [ ] **Step 4: Run the CLI tests, fix, pass** + +```bash +cargo test -p figmog --test cli +``` +Watch for: `assert_cmd` builds the real binary — `Command::cargo_bin("figmog")`; JSON output must go to stdout only (churn summary included), human/diagnostic text to stderr. + +- [ ] **Step 5: Full test run + clippy, commit** + +```bash +cargo test -p figmog && cargo clippy -p figmog -- -D warnings +git add examples/figmog/src/cli.rs examples/figmog/src/main.rs examples/figmog/tests/cli.rs +git commit -m "feat(figmog): CLI engine commands and core reads (pull/status/pages/tree/get/find)" +``` + +--- + +### Task 13: CLI part 2 — search & design-system commands + watch wiring + +**Files:** +- Modify: `examples/figmog/src/cli.rs` +- Modify: `examples/figmog/tests/cli.rs` (add tests) + +**Interfaces:** +- Consumes: Task 6 readers, Task 10/11 `vars` functions. +- Produces: working `search`, `instances`, `components`, `styles`, `uses`, `vars`, `import-variables`; `watch` loop wired (its state machine is already tested — no network test here). + +- [ ] **Step 1: Add the failing tests** to `tests/cli.rs`: + +```rust +#[test] +fn search_instances_components_styles_uses_vars() { + let (_dir, db) = fixture_db(); + let run = |args: &[&str]| { + let out = Command::cargo_bin("figmog").unwrap() + .args(args).args(["--db", &db, "--json"]) + .assert().success(); + serde_json::from_slice::(&out.get_output().stdout).unwrap() + }; + + let hits = run(&["search", "garden"]); + assert_eq!(hits[0]["id"], "1:2"); + assert!(hits[0]["score"].as_f64().unwrap() > 0.0); + + // by node id, by key, by set name (=> all variants' instances) + for target in ["2:2", "key22", "Button"] { + let inst = run(&["instances", target]); + assert_eq!(inst[0]["id"], "1:3", "target={target}"); + } + + let comps = run(&["components"]); + let sets = comps["sets"].as_array().unwrap(); + assert_eq!(sets.len(), 1); + assert_eq!(sets[0]["name"], "Button"); + assert_eq!(sets[0]["variants"].as_array().unwrap().len(), 2); + let axes = &sets[0]["property_definitions"]; + assert_eq!(axes["Size"]["variantOptions"], serde_json::json!(["Large", "Small"])); + assert_eq!(comps["components"].as_array().unwrap().len(), 1); // standalone only + assert_eq!(comps["components"][0]["name"], "IconStar"); + + let styles = run(&["styles"]); + assert_eq!(styles.as_array().unwrap().len(), 2); + assert_eq!(styles[0]["style_id"], "S:1"); + assert_eq!(styles[0]["uses"], 1); + + let styles = run(&["styles", "--values"]); + assert_eq!(styles[1]["value"]["fontSize"], 32.0); // S:2 from consumer 1:2 + + let uses = run(&["uses", "S:1"]); + assert_eq!(uses[0]["id"], "1:1"); + let uses = run(&["uses", "VariableID:100"]); + assert_eq!(uses[0]["id"], "1:1"); + + let vars = run(&["vars"]); + let arr = vars.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["variable_id"], "VariableID:100"); + assert_eq!(arr[0]["source"], "inferred"); +} + +#[test] +fn import_variables_upgrades_vars_to_authoritative() { + let (dir, db) = fixture_db(); + let export = dir.path().join("vars.json"); + std::fs::write(&export, include_str!("fixtures/variables-export.json")).unwrap(); + + Command::cargo_bin("figmog").unwrap() + .args(["import-variables", export.to_str().unwrap(), "--db", &db]) + .assert().success(); + + let out = Command::cargo_bin("figmog").unwrap() + .args(["vars", "--db", &db, "--json"]) + .assert().success(); + let vars: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + let v100 = vars.as_array().unwrap().iter() + .find(|v| v["variable_id"] == "VariableID:100").unwrap(); + assert_eq!(v100["source"], "imported"); + assert_eq!(v100["name"], "color/surface/primary"); + assert_eq!(v100["collection"], "colors"); + assert_eq!(v100["values_by_mode"]["light"]["r"], 0.06); + // inference detail still present alongside + assert_eq!(v100["sites"][0][0], "1:1"); +} +``` + +- [ ] **Step 2: Run to verify failure** — `cargo test -p figmog --test cli` → FAIL (not-yet-implemented errors). + +- [ ] **Step 3: Implement the remaining `cmd_*` functions:** + +- **`cmd_search`**: `text.search(&query, limit)` → for each hit look up `nodes.get(&hit.val)` → `[{id, score, type, name, page_id, snippet}]` where snippet = first 80 chars of `text` field if present. Keep BM25's own ranking order (it is deterministic); do not re-sort. +- **`cmd_instances`**: resolve `target` → component node ids: (1) if `components` table has the id → itself; (2) scan `components.iter()` for `key == target` → that node id; (3) scan `component_sets.iter()` for `name == target` → all components whose `component_set_id` is the set's node id; else scan `components.iter()` for `name == target`. Union → for each, `instances_of.search(&component_id)` → look up nodes, sort by id, print `[{id, name, page_id, component_id}]`. Ambiguous name matching >1 target class is fine — union them. +- **`cmd_components`**: iterate `component_sets` table sorted; for each set: its `NodeRec.property_definitions` (parse to `Value`), variants = components with matching `component_set_id` sorted by node id (`[{node_id, name, key}]`). Standalone components = those with `component_set_id == None`. Output `{sets: […], components: […]}`. +- **`cmd_styles`**: iterate `styles` table sorted by style id; optional `--type` filter (case-insensitive vs `style_type`); uses count = `styled_by.search(&style_id).len()`; with `--values`: first consumer sorted by node id → `vars::style_value_from_consumer(&style_type, &consumer.raw)` → include as `value` (null if none). +- **`cmd_uses`**: try `styled_by.search(&id)`; if empty try `bound_to.search(&id)`; look up nodes, sort, `[{id, name, page_id}]`. +- **`cmd_vars`**: inferred = `vars::infer_from_nodes(nodes.iter().map(|(_, n)| n))` — note `TableReader::iter` yields owned pairs; collect first. For each usage, if `variables` table has the id → merge: `source: "imported"`, `name`, `resolved_type`, `collection` (name via `variable_collections` table), `values_by_mode` re-keyed by mode *name* (fall back to mode id when the collection is unknown), plus `sites`/`observed`. Else `source: "inferred"` with `sites`/`observed` only. Also include imported variables that have **no** binding sites (they exist in the table but not in usages). Optional positional `id` filters to one variable. Sort by variable id. +- **`cmd_import_variables`**: read file → `vars::parse_variables_export` → one `st.wtx` upserting every rec (these are `Id::Variable`/`Id::VariableCollection`, sweep-exempt by Task 6) → print count. Re-import churn behavior comes free from `upsert`. +- **`cmd_watch`** (finish wiring per Task 12 sketch). Human mode prints one line per event (`changed → pulling…`, churn summary, `rate limited, waiting 30s`); `--json` prints one JSON object per line (`{"event": "pulled", …churn}`). + +- [ ] **Step 4: Run all tests, fix, pass** — `cargo test -p figmog` → PASS. Clippy clean. + +- [ ] **Step 5: Commit** + +```bash +git add examples/figmog/src/cli.rs examples/figmog/tests/cli.rs +git commit -m "feat(figmog): design-system CLI commands (search/instances/components/styles/uses/vars/import-variables) and watch loop" +``` + +--- + +### Task 14: docs, polish, full verification + +**Files:** +- Create: `examples/figmog/README.md` +- Modify: `README.md` (workspace) — add figmog to the examples list +- Modify: `examples/figmog/src/*.rs` — rustdoc pass + +**Interfaces:** none new; documentation of everything above. + +- [ ] **Step 1: Write `examples/figmog/README.md`** covering, in order: + 1. What it is (2 sentences: the mirror + the zero-rate-limit reads) and the quick start: + ```console + $ export FIGMA_TOKEN=figd_… # figma.com → settings → security → personal access tokens + $ cargo run -p figmog -- pull "https://www.figma.com/design//" + $ cargo run -p figmog -- search "pricing card" + $ cargo run -p figmog -- watch # keep it fresh in another terminal + ``` + 2. Command reference table (copy the spec §7 table, updated to what shipped). + 3. How sync works (3 sentences: Tier-3 polling, Tier-1 fetch only on change, upsert-diff means zero churn on no-ops) + plan-tier notes (free plan numbers). + 4. **Variables on a free plan** — the two paths, including the plugin-console export snippet, verbatim: + ```js + // Figma → Plugins → Development → Open console, then paste: + (async () => { + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + const variables = await figma.variables.getLocalVariablesAsync(); + const out = { variables: {}, variableCollections: {} }; + for (const c of collections) + out.variableCollections[c.id] = { id: c.id, name: c.name, modes: c.modes, defaultModeId: c.defaultModeId }; + for (const v of variables) + out.variables[v.id] = { id: v.id, name: v.name, resolvedType: v.resolvedType, + variableCollectionId: v.variableCollectionId, + valuesByMode: v.valuesByMode, description: v.description, scopes: v.scopes }; + console.log(JSON.stringify(out)); + })(); + // save the logged JSON, then: figmog import-variables vars.json + ``` + 5. Manual live check (spec §9.6): the commands to run against a real file and what "fast" should look like. + 6. Limitations (variables modes caveat, no image renders, style defs derived from consumers, `FILE_UPDATE`-free polling design). +- [ ] **Step 2: Add figmog to the workspace `README.md` examples list**, matching the existing bullet style: + +```markdown +- `figmog` — a local mirror of a Figma file: sync once, then search, walk, and + query components/styles/variables with zero API calls. `cargo run -p figmog -- --help` +``` + +- [ ] **Step 3: Rustdoc pass** — every `pub` item in the crate has a doc comment (most were written in-task; sweep for gaps). `cargo doc -p figmog --no-deps` builds warning-free. + +- [ ] **Step 4: Full verification** + +```bash +cargo fmt -p figmog --check +cargo clippy -p figmog -- -D warnings +cargo test -p figmog +cargo test -p fold # untouched, but prove the workspace is still green +cargo doc -p figmog --no-deps +``` +All green, no exceptions. If `FIGMA_TOKEN` is available in the environment, also run the manual live check against the g3d file and record timings in the task notes (not in git). + +- [ ] **Step 5: Commit** + +```bash +git add examples/figmog/README.md README.md examples/figmog/src +git commit -m "docs(figmog): README, plugin export snippet, rustdoc pass" +``` + +--- + +## Self-review checklist (run after writing, before execution) + +- Spec §4 data model → Tasks 2, 5, 10. Spec §5 pipeline (7 node sinks + 5 tables + meta) → Task 6. Spec §6 sync/watch → Tasks 6–9, 12. Spec §6a variables → Tasks 10, 11, 13, 14. Spec §7 CLI (13 subcommands) → Tasks 12, 13. Spec §8 practices → global constraints + per-task clippy. Spec §9 test plan items 1–6 → Tasks 4, 5 (flatten), 6, 7 (sync), 9 (watch), 10, 11, 13 (tokens), 12, 13 (CLI smoke), 14 (manual live). +- Known intentional deviation from spec §9: the delta probe uses a `Map` **in front of** the `KeyedStream` pipeline root (counts deltas entering the graph), which is strictly stronger than the spec's "between stream and sinks". +- `--from-file` on `pull` is an addition relative to spec §7 (offline ingestion; also what makes CLI tests hermetic). Document it in the README table. From c1bbb77c989755d165a9c415455e39f8cf4a44df Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:42:19 -0700 Subject: [PATCH 04/56] feat(figmog): scaffold lib+bin crate Co-Authored-By: Claude Fable 5 --- Cargo.lock | 89 ++++++++++++++++++++++++++++++++++ examples/figmog/Cargo.toml | 18 +++++++ examples/figmog/src/api.rs | 1 + examples/figmog/src/cli.rs | 6 +++ examples/figmog/src/flatten.rs | 1 + examples/figmog/src/ident.rs | 1 + examples/figmog/src/lib.rs | 17 +++++++ examples/figmog/src/main.rs | 3 ++ examples/figmog/src/model.rs | 1 + examples/figmog/src/store.rs | 1 + examples/figmog/src/vars.rs | 1 + examples/figmog/src/watch.rs | 1 + 12 files changed, 140 insertions(+) create mode 100644 examples/figmog/Cargo.toml create mode 100644 examples/figmog/src/api.rs create mode 100644 examples/figmog/src/cli.rs create mode 100644 examples/figmog/src/flatten.rs create mode 100644 examples/figmog/src/ident.rs create mode 100644 examples/figmog/src/lib.rs create mode 100644 examples/figmog/src/main.rs create mode 100644 examples/figmog/src/model.rs create mode 100644 examples/figmog/src/store.rs create mode 100644 examples/figmog/src/vars.rs create mode 100644 examples/figmog/src/watch.rs diff --git a/Cargo.lock b/Cargo.lock index f152980..62a1281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -375,6 +390,17 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -892,6 +918,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -1051,6 +1083,21 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "figmog" +version = "0.0.0" +dependencies = [ + "assert_cmd", + "clap", + "fold", + "postcard", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "ureq", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2323,6 +2370,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2956,6 +3030,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -3364,6 +3444,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/examples/figmog/Cargo.toml b/examples/figmog/Cargo.toml new file mode 100644 index 0000000..cc43305 --- /dev/null +++ b/examples/figmog/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "figmog" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +fold = { path = "../../fold" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ureq = { version = "2", features = ["json"] } +clap = { version = "4", features = ["derive"] } +thiserror = "2" + +[dev-dependencies] +tempfile = "3" +assert_cmd = "2" +postcard = { version = "1", features = ["use-std"] } diff --git a/examples/figmog/src/api.rs b/examples/figmog/src/api.rs new file mode 100644 index 0000000..1c1323a --- /dev/null +++ b/examples/figmog/src/api.rs @@ -0,0 +1 @@ +//! Placeholder — implemented in a later task. diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs new file mode 100644 index 0000000..21defdb --- /dev/null +++ b/examples/figmog/src/cli.rs @@ -0,0 +1,6 @@ +//! Command-line surface. Real implementation lands with the CLI tasks. + +pub fn run() -> i32 { + eprintln!("figmog: not yet implemented"); + 2 +} diff --git a/examples/figmog/src/flatten.rs b/examples/figmog/src/flatten.rs new file mode 100644 index 0000000..1c1323a --- /dev/null +++ b/examples/figmog/src/flatten.rs @@ -0,0 +1 @@ +//! Placeholder — implemented in a later task. diff --git a/examples/figmog/src/ident.rs b/examples/figmog/src/ident.rs new file mode 100644 index 0000000..1c1323a --- /dev/null +++ b/examples/figmog/src/ident.rs @@ -0,0 +1 @@ +//! Placeholder — implemented in a later task. diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs new file mode 100644 index 0000000..7a3a3f6 --- /dev/null +++ b/examples/figmog/src/lib.rs @@ -0,0 +1,17 @@ +//! figmog — a fold-backed local mirror of one Figma file. +//! +//! A sync engine pulls the file when it changes (change detection on the +//! cheap Tier-3 metadata endpoint, one Tier-1 fetch per real change) and a +//! `KeyedStream` upsert-diffs every node into materialized indexes. The CLI +//! reads those indexes locally: zero Figma calls, zero rate limits. +//! +//! See `docs/superpowers/specs/2026-08-15-figmog-build-design.md`. + +pub mod api; +pub mod cli; +pub mod flatten; +pub mod ident; +pub mod model; +pub mod store; +pub mod vars; +pub mod watch; diff --git a/examples/figmog/src/main.rs b/examples/figmog/src/main.rs new file mode 100644 index 0000000..4f61e48 --- /dev/null +++ b/examples/figmog/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(figmog::cli::run()); +} diff --git a/examples/figmog/src/model.rs b/examples/figmog/src/model.rs new file mode 100644 index 0000000..1c1323a --- /dev/null +++ b/examples/figmog/src/model.rs @@ -0,0 +1 @@ +//! Placeholder — implemented in a later task. diff --git a/examples/figmog/src/store.rs b/examples/figmog/src/store.rs new file mode 100644 index 0000000..1c1323a --- /dev/null +++ b/examples/figmog/src/store.rs @@ -0,0 +1 @@ +//! Placeholder — implemented in a later task. diff --git a/examples/figmog/src/vars.rs b/examples/figmog/src/vars.rs new file mode 100644 index 0000000..1c1323a --- /dev/null +++ b/examples/figmog/src/vars.rs @@ -0,0 +1 @@ +//! Placeholder — implemented in a later task. diff --git a/examples/figmog/src/watch.rs b/examples/figmog/src/watch.rs new file mode 100644 index 0000000..1c1323a --- /dev/null +++ b/examples/figmog/src/watch.rs @@ -0,0 +1 @@ +//! Placeholder — implemented in a later task. From 2bebfdb2ac1909e25f09956338481b9a7693e342 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:43:39 -0700 Subject: [PATCH 05/56] feat(figmog): record model (Id/Rec + node and design-system records) Co-Authored-By: Claude Fable 5 --- examples/figmog/src/model.rs | 177 ++++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/examples/figmog/src/model.rs b/examples/figmog/src/model.rs index 1c1323a..0c41a45 100644 --- a/examples/figmog/src/model.rs +++ b/examples/figmog/src/model.rs @@ -1 +1,176 @@ -//! Placeholder — implemented in a later task. +//! Record vocabulary shared by flatten, store, and the CLI. +//! +//! Determinism contract: every map-shaped field is a **sorted** `Vec` of +//! pairs, and canonical-JSON strings come from `serde_json` without +//! `preserve_order`. `KeyedStream` diffs records by postcard bytes, so two +//! flattens of the same file JSON must be byte-identical. + +use serde::{Deserialize, Serialize}; + +/// Primary key of every mirrored record. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Id { + Node(String), + Component(String), + ComponentSet(String), + Style(String), + Variable(String), + VariableCollection(String), + Meta, +} + +/// One mirrored record; variant always matches its [`Id`] variant. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Rec { + Node(NodeRec), + Component(ComponentRec), + ComponentSet(ComponentSetRec), + Style(StyleRec), + Variable(VariableRec), + VariableCollection(VariableCollectionRec), + Meta(FileMeta), +} + +/// One node of the document tree (children stripped from `raw`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NodeRec { + pub id: String, + pub parent_id: Option, + pub child_index: u32, + /// Enclosing CANVAS id; the document root and CANVAS nodes carry their own id. + pub page_id: String, + pub node_type: String, + pub name: String, + pub visible: bool, + /// `characters` for TEXT nodes. + pub text: Option, + /// INSTANCE → the component's node id. + pub component_id: Option, + /// INSTANCE `componentProperties` as (name, canonical-JSON value), sorted. + pub component_properties: Vec<(String, String)>, + /// `componentPropertyDefinitions` (COMPONENT / COMPONENT_SET) as canonical JSON. + pub property_definitions: Option, + /// Node `styles` map as (style_type, style_id), sorted. + pub style_refs: Vec<(String, String)>, + /// Variable bindings as (json-pointer to the bound property, variable id), sorted. + /// The pointer addresses the *resolved value* location, e.g. `/fills/0/color`. + pub bound_variables: Vec<(String, String)>, + /// absoluteBoundingBox x, y, w, h. + pub abs_bounds: Option<[f64; 4]>, + /// Canonical JSON of the node with `children` removed. + pub raw: String, +} + +/// Entry of the file response's `components` map. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ComponentRec { + pub node_id: String, + pub key: String, + pub name: String, + pub description: String, + pub component_set_id: Option, + pub remote: bool, +} + +/// Entry of the file response's `componentSets` map. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ComponentSetRec { + pub node_id: String, + pub key: String, + pub name: String, + pub description: String, + pub remote: bool, +} + +/// Entry of the file response's `styles` map. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StyleRec { + pub style_id: String, + pub key: String, + pub name: String, + pub style_type: String, + pub description: String, + pub remote: bool, +} + +/// Authoritative variable definition (from `import-variables` only). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VariableRec { + pub id: String, + pub name: String, + pub resolved_type: String, + pub collection_id: String, + /// mode id -> value-or-alias, as sorted (mode_id, canonical JSON) pairs. + pub values_by_mode: Vec<(String, String)>, + pub description: String, + pub scopes: Vec, +} + +/// Authoritative variable collection (from `import-variables` only). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VariableCollectionRec { + pub id: String, + pub name: String, + /// (mode_id, mode_name), sorted by mode_id. + pub modes: Vec<(String, String)>, + pub default_mode_id: String, +} + +/// The single file-level row (key [`Id::Meta`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FileMeta { + pub name: String, + pub version: String, + pub last_modified: String, + pub synced_at_unix_ms: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_node() -> NodeRec { + NodeRec { + id: "1:2".into(), + parent_id: Some("0:1".into()), + child_index: 0, + page_id: "0:1".into(), + node_type: "TEXT".into(), + name: "Title".into(), + visible: true, + text: Some("hello".into()), + component_id: None, + component_properties: vec![("Size".into(), "\"Large\"".into())], + property_definitions: None, + style_refs: vec![("text".into(), "S:2".into())], + bound_variables: vec![("/style/fontSize".into(), "VariableID:9".into())], + abs_bounds: Some([0.0, 0.0, 100.0, 20.0]), + raw: "{}".into(), + } + } + + #[test] + fn rec_postcard_roundtrip() { + let rec = Rec::Node(sample_node()); + let bytes = postcard::to_allocvec(&rec).unwrap(); + let back: Rec = postcard::from_bytes(&bytes).unwrap(); + assert_eq!(rec, back); + } + + #[test] + fn identical_records_encode_identically() { + let a = postcard::to_allocvec(&Rec::Node(sample_node())).unwrap(); + let b = postcard::to_allocvec(&Rec::Node(sample_node())).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn ids_order_and_roundtrip() { + let ids = vec![Id::Meta, Id::Node("1:1".into()), Id::Style("S:1".into())]; + let set: std::collections::BTreeSet = ids.iter().cloned().collect(); + assert_eq!(set.len(), 3); + let bytes = postcard::to_allocvec(&ids).unwrap(); + let back: Vec = postcard::from_bytes(&bytes).unwrap(); + assert_eq!(ids, back); + } +} From 15b97d8a4fd57e50bcf01d7319c2ddf79b910afe Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:44:05 -0700 Subject: [PATCH 06/56] feat(figmog): file/node identity parsing Co-Authored-By: Claude Fable 5 --- examples/figmog/src/ident.rs | 76 +++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/examples/figmog/src/ident.rs b/examples/figmog/src/ident.rs index 1c1323a..d17980a 100644 --- a/examples/figmog/src/ident.rs +++ b/examples/figmog/src/ident.rs @@ -1 +1,75 @@ -//! Placeholder — implemented in a later task. +//! Parsing of user-supplied file references and node ids. + +/// Extract a file key from a bare key or a figma.com URL +/// (`figma.com/design//…`, `figma.com/file//…`). +pub fn parse_file_ref(input: &str) -> Option { + let is_key = |s: &str| s.len() >= 10 && s.chars().all(|c| c.is_ascii_alphanumeric()); + if is_key(input) { + return Some(input.to_string()); + } + let rest = input.split_once("figma.com/").map(|(_, r)| r)?; + let mut parts = rest.split('/'); + match parts.next()? { + "design" | "file" | "board" => {} + _ => return None, + } + let key = parts.next()?; + is_key(key).then(|| key.to_string()) +} + +/// Canonicalize a node id: URLs write `12:34` as `12-34`. Ids that are not +/// exactly `-` (already-canonical ids, instance paths like +/// `I206:7;104:22`) pass through unchanged. +pub fn normalize_node_id(input: &str) -> String { + if let Some((a, b)) = input.split_once('-') { + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + if digits(a) && digits(b) { + return format!("{a}:{b}"); + } + } + input.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_bare_key() { + assert_eq!( + parse_file_ref("flAtUnMfzvA5daBSTFQK35").as_deref(), + Some("flAtUnMfzvA5daBSTFQK35") + ); + } + + #[test] + fn parses_design_and_file_urls() { + for url in [ + "https://www.figma.com/design/flAtUnMfzvA5daBSTFQK35/g3d-Index-Web-Handoff?node-id=0-1&t=x-1", + "https://www.figma.com/file/flAtUnMfzvA5daBSTFQK35/whatever", + "figma.com/design/flAtUnMfzvA5daBSTFQK35", + ] { + assert_eq!( + parse_file_ref(url).as_deref(), + Some("flAtUnMfzvA5daBSTFQK35"), + "{url}" + ); + } + } + + #[test] + fn rejects_garbage() { + assert_eq!(parse_file_ref("https://example.com/nope"), None); + assert_eq!(parse_file_ref("not a key!"), None); + assert_eq!(parse_file_ref(""), None); + } + + #[test] + fn normalizes_node_ids() { + assert_eq!(normalize_node_id("0-1"), "0:1"); + assert_eq!(normalize_node_id("12-345"), "12:345"); + assert_eq!(normalize_node_id("12:345"), "12:345"); + // instance sub-node paths pass through untouched + assert_eq!(normalize_node_id("I206:7;104:22"), "I206:7;104:22"); + } +} From ffd15eff09fc5ace58343da65f0d98cd16a6ac19 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:46:41 -0700 Subject: [PATCH 07/56] feat(figmog): flatten core tree walk + fixture tests Co-Authored-By: Claude Fable 5 --- examples/figmog/src/flatten.rs | 107 +++++++++++++++++++++++++++- examples/figmog/src/lib.rs | 2 + examples/figmog/tests/common/mod.rs | 99 +++++++++++++++++++++++++ examples/figmog/tests/flatten.rs | 92 ++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 examples/figmog/tests/common/mod.rs create mode 100644 examples/figmog/tests/flatten.rs diff --git a/examples/figmog/src/flatten.rs b/examples/figmog/src/flatten.rs index 1c1323a..d64068d 100644 --- a/examples/figmog/src/flatten.rs +++ b/examples/figmog/src/flatten.rs @@ -1 +1,106 @@ -//! Placeholder — implemented in a later task. +//! Pure flattening of a Figma file response into deterministic records. +//! +//! No I/O, no clock, no randomness: two calls on equal JSON must produce +//! byte-identical records (postcard), because `KeyedStream::upsert` uses +//! byte equality as its change detector. + +use serde_json::Value; + +use crate::model::{Id, NodeRec, Rec}; + +/// File-level fields lifted from the response envelope. +#[derive(Debug, Clone, PartialEq)] +pub struct FileInfo { + pub name: String, + pub version: String, + pub last_modified: String, +} + +/// Everything `flatten_file` extracts. +#[derive(Debug)] +pub struct Flattened { + pub recs: Vec<(Id, Rec)>, + pub file: FileInfo, +} + +#[derive(Debug, thiserror::Error)] +pub enum FlattenError { + #[error("missing field: {0}")] + Missing(&'static str), +} + +/// Flatten a full `GET /v1/files/:key` response. +pub fn flatten_file(resp: &Value) -> Result { + let file = FileInfo { + name: str_field(resp, "name").ok_or(FlattenError::Missing("name"))?, + version: str_field(resp, "version").ok_or(FlattenError::Missing("version"))?, + last_modified: str_field(resp, "lastModified").ok_or(FlattenError::Missing("lastModified"))?, + }; + let document = resp.get("document").ok_or(FlattenError::Missing("document"))?; + + let mut recs = Vec::new(); + walk(document, None, 0, None, &mut recs); + Ok(Flattened { recs, file }) +} + +fn str_field(v: &Value, k: &str) -> Option { + v.get(k)?.as_str().map(str::to_string) +} + +/// Depth-first walk. `page_id` is the nearest CANVAS ancestor (None above +/// pages — the record then carries the node's own id). +fn walk( + node: &Value, + parent_id: Option<&str>, + child_index: u32, + page_id: Option<&str>, + out: &mut Vec<(Id, Rec)>, +) { + let Some(id) = node.get("id").and_then(Value::as_str) else { + return; // node without id: skip it and its subtree + }; + let node_type = node + .get("type") + .and_then(Value::as_str) + .unwrap_or("UNKNOWN") + .to_string(); + let own_page = matches!(node_type.as_str(), "DOCUMENT" | "CANVAS"); + let page = if own_page { id } else { page_id.unwrap_or(id) }; + + let mut raw = node.clone(); + if let Some(obj) = raw.as_object_mut() { + obj.remove("children"); + } + + let rec = NodeRec { + id: id.to_string(), + parent_id: parent_id.map(str::to_string), + child_index, + page_id: page.to_string(), + node_type, + name: str_field(node, "name").unwrap_or_default(), + visible: node.get("visible").and_then(Value::as_bool).unwrap_or(true), + text: str_field(node, "characters"), + component_id: None, + component_properties: Vec::new(), + property_definitions: None, + style_refs: Vec::new(), + bound_variables: Vec::new(), + abs_bounds: node.get("absoluteBoundingBox").and_then(|b| { + Some([ + b.get("x")?.as_f64()?, + b.get("y")?.as_f64()?, + b.get("width")?.as_f64()?, + b.get("height")?.as_f64()?, + ]) + }), + raw: serde_json::to_string(&raw).expect("serde_json::Value serializes"), + }; + out.push((Id::Node(id.to_string()), Rec::Node(rec))); + + if let Some(children) = node.get("children").and_then(Value::as_array) { + for (i, child) in children.iter().enumerate() { + walk(child, Some(id), i as u32, Some(page), out); + } + } +} diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs index 7a3a3f6..fddd754 100644 --- a/examples/figmog/src/lib.rs +++ b/examples/figmog/src/lib.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + //! figmog — a fold-backed local mirror of one Figma file. //! //! A sync engine pulls the file when it changes (change detection on the diff --git a/examples/figmog/tests/common/mod.rs b/examples/figmog/tests/common/mod.rs new file mode 100644 index 0000000..e57ce09 --- /dev/null +++ b/examples/figmog/tests/common/mod.rs @@ -0,0 +1,99 @@ +//! Synthetic Figma file fixtures. Deliberately NOT derived from any real +//! file. Shape mirrors GET /v1/files/:key responses. + +use serde_json::{Value, json}; + +/// 12 nodes over 3 pages: a hero frame with a text, a variant'd button +/// instance, an invisible node, a component set (2 variants), a standalone +/// component, and an empty page. Fill/text styles + variable bindings. +pub fn fixture_v1() -> Value { + json!({ + "name": "Fixture", + "version": "100", + "lastModified": "2026-08-01T00:00:00Z", + "document": { + "id": "0:0", "name": "Document", "type": "DOCUMENT", + "children": [ + { "id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [ + { "id": "1:1", "name": "Hero", "type": "FRAME", + "absoluteBoundingBox": {"x": 0.0, "y": 0.0, "width": 800.0, "height": 400.0}, + "layoutMode": "VERTICAL", "paddingLeft": 16.0, + "boundVariables": { "paddingLeft": {"type": "VARIABLE_ALIAS", "id": "VariableID:200"} }, + "fills": [ { "type": "SOLID", + "color": {"r": 0.06, "g": 0.13, "b": 0.2, "a": 1.0}, + "boundVariables": { "color": {"type": "VARIABLE_ALIAS", "id": "VariableID:100"} } } ], + "styles": { "fill": "S:1" }, + "children": [ + { "id": "1:2", "name": "Title", "type": "TEXT", + "characters": "Welcome to the garden", + "style": {"fontFamily": "Basis", "fontSize": 32.0, "fontWeight": 500}, + "styles": { "text": "S:2" }, + "children": [] }, + { "id": "1:3", "name": "Button", "type": "INSTANCE", + "componentId": "2:2", + "componentProperties": { + "Size": {"value": "Large", "type": "VARIANT"}, + "State": {"value": "Default", "type": "VARIANT"}, + "Label": {"value": "Go", "type": "TEXT"}, + "HasIcon": {"value": false, "type": "BOOLEAN"}, + "Icon": {"value": "3:1", "type": "INSTANCE_SWAP"} + }, + "children": [] } + ] }, + { "id": "1:9", "name": "Old badge", "type": "RECTANGLE", + "visible": false, "children": [] } + ] }, + { "id": "0:2", "name": "Components", "type": "CANVAS", "children": [ + { "id": "2:1", "name": "Button", "type": "COMPONENT_SET", + "componentPropertyDefinitions": { + "Size": {"type": "VARIANT", "defaultValue": "Large", "variantOptions": ["Large", "Small"]}, + "State": {"type": "VARIANT", "defaultValue": "Default", "variantOptions": ["Default", "Hover"]}, + "Label": {"type": "TEXT", "defaultValue": "Go"}, + "HasIcon": {"type": "BOOLEAN", "defaultValue": false}, + "Icon": {"type": "INSTANCE_SWAP", "defaultValue": "3:1"} + }, + "children": [ + { "id": "2:2", "name": "Size=Large, State=Default", "type": "COMPONENT", "children": [] }, + { "id": "2:3", "name": "Size=Small, State=Hover", "type": "COMPONENT", "children": [] } + ] }, + { "id": "3:1", "name": "IconStar", "type": "COMPONENT", "children": [] } + ] }, + { "id": "0:3", "name": "Empty", "type": "CANVAS", "children": [] } + ] + }, + "components": { + "2:2": {"key": "key22", "name": "Size=Large, State=Default", "description": "", "componentSetId": "2:1", "remote": false}, + "2:3": {"key": "key23", "name": "Size=Small, State=Hover", "description": "", "componentSetId": "2:1", "remote": false}, + "3:1": {"key": "key31", "name": "IconStar", "description": "a star", "remote": false} + }, + "componentSets": { + "2:1": {"key": "keyset21", "name": "Button", "description": "the button", "remote": false} + }, + "styles": { + "S:1": {"key": "sk1", "name": "Brand/Primary", "styleType": "FILL", "description": "", "remote": false}, + "S:2": {"key": "sk2", "name": "Heading/H1", "styleType": "TEXT", "description": "", "remote": false} + } + }) +} + +/// v1 plus: rename 1:2, delete 1:9, add 1:4, repoint instance 1:3 at the +/// Small variant, bump version. +#[allow(dead_code)] // not every test binary uses v2 +pub fn fixture_v2() -> Value { + let mut v = fixture_v1(); + v["version"] = json!("101"); + v["lastModified"] = json!("2026-08-02T00:00:00Z"); + let page1 = &mut v["document"]["children"][0]; + // delete 1:9 (second child of the canvas) + page1["children"].as_array_mut().unwrap().remove(1); + let hero = &mut page1["children"][0]; + hero["children"][0]["name"] = json!("Headline"); + hero["children"][1]["componentId"] = json!("2:3"); + hero["children"][1]["componentProperties"]["Size"]["value"] = json!("Small"); + hero["children"][1]["componentProperties"]["State"]["value"] = json!("Hover"); + hero["children"].as_array_mut().unwrap().push(json!({ + "id": "1:4", "name": "Subtitle", "type": "TEXT", + "characters": "Planting season", "children": [] + })); + v +} diff --git a/examples/figmog/tests/flatten.rs b/examples/figmog/tests/flatten.rs new file mode 100644 index 0000000..217a45a --- /dev/null +++ b/examples/figmog/tests/flatten.rs @@ -0,0 +1,92 @@ +#![recursion_limit = "256"] + +mod common; + +use figmog::flatten::flatten_file; +use figmog::model::{Id, Rec}; + +fn node(recs: &[(Id, Rec)], id: &str) -> figmog::model::NodeRec { + recs.iter() + .find_map(|(k, r)| match (k, r) { + (Id::Node(n), Rec::Node(rec)) if n == id => Some(rec.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("node {id} not flattened")) +} + +#[test] +fn walks_the_whole_tree() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let node_ids: Vec<&str> = out + .recs + .iter() + .filter_map(|(k, _)| match k { Id::Node(n) => Some(n.as_str()), _ => None }) + .collect(); + assert_eq!( + node_ids, + ["0:0", "0:1", "1:1", "1:2", "1:3", "1:9", "0:2", "2:1", "2:2", "2:3", "3:1", "0:3"], + "depth-first order, all 12 nodes" + ); + assert_eq!(out.file.name, "Fixture"); + assert_eq!(out.file.version, "100"); + assert_eq!(out.file.last_modified, "2026-08-01T00:00:00Z"); +} + +#[test] +fn parent_index_page_attribution() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let title = node(&out.recs, "1:2"); + assert_eq!(title.parent_id.as_deref(), Some("1:1")); + assert_eq!(title.child_index, 0); + assert_eq!(title.page_id, "0:1"); + let button = node(&out.recs, "1:3"); + assert_eq!(button.child_index, 1); + + let root = node(&out.recs, "0:0"); + assert_eq!(root.parent_id, None); + assert_eq!(root.page_id, "0:0"); + let canvas = node(&out.recs, "0:2"); + assert_eq!(canvas.page_id, "0:2"); + let variant = node(&out.recs, "2:2"); + assert_eq!(variant.page_id, "0:2"); +} + +#[test] +fn basic_fields() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let title = node(&out.recs, "1:2"); + assert_eq!(title.node_type, "TEXT"); + assert_eq!(title.name, "Title"); + assert!(title.visible); + assert_eq!(title.text.as_deref(), Some("Welcome to the garden")); + + let hidden = node(&out.recs, "1:9"); + assert!(!hidden.visible); + + let hero = node(&out.recs, "1:1"); + assert_eq!(hero.abs_bounds, Some([0.0, 0.0, 800.0, 400.0])); +} + +#[test] +fn raw_is_canonical_and_childless() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let hero = node(&out.recs, "1:1"); + let raw: serde_json::Value = serde_json::from_str(&hero.raw).unwrap(); + assert!(raw.get("children").is_none()); + assert_eq!(raw["name"], "Hero"); + // canonical: re-serializing the parsed value reproduces the string + assert_eq!(serde_json::to_string(&raw).unwrap(), hero.raw); +} + +#[test] +fn deterministic_bytes() { + let a = flatten_file(&common::fixture_v1()).unwrap(); + let b = flatten_file(&common::fixture_v1()).unwrap(); + let enc = |f: &figmog::flatten::Flattened| postcard::to_allocvec(&f.recs).unwrap(); + assert_eq!(enc(&a), enc(&b)); +} + +#[test] +fn missing_document_errors() { + assert!(flatten_file(&serde_json::json!({"name": "x"})).is_err()); +} From b5ecc0aeb890d96248e94af0913d436a483e55a9 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 18:51:47 -0700 Subject: [PATCH 08/56] feat(figmog): flatten design-system fields and envelope maps Co-Authored-By: Claude Fable 5 --- examples/figmog/src/flatten.rs | 134 +++++++++++++++++++++++++++++-- examples/figmog/tests/flatten.rs | 80 +++++++++++++++++- 2 files changed, 207 insertions(+), 7 deletions(-) diff --git a/examples/figmog/src/flatten.rs b/examples/figmog/src/flatten.rs index d64068d..9b42d8c 100644 --- a/examples/figmog/src/flatten.rs +++ b/examples/figmog/src/flatten.rs @@ -5,8 +5,9 @@ //! byte equality as its change detector. use serde_json::Value; +use std::collections::BTreeMap; -use crate::model::{Id, NodeRec, Rec}; +use crate::model::{ComponentRec, ComponentSetRec, Id, NodeRec, Rec, StyleRec}; /// File-level fields lifted from the response envelope. #[derive(Debug, Clone, PartialEq)] @@ -40,6 +41,52 @@ pub fn flatten_file(resp: &Value) -> Result { let mut recs = Vec::new(); walk(document, None, 0, None, &mut recs); + + let obj_map = |key: &str| -> BTreeMap { + resp.get(key) + .and_then(Value::as_object) + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default() + }; + for (node_id, v) in obj_map("components") { + recs.push(( + Id::Component(node_id.clone()), + Rec::Component(ComponentRec { + node_id, + key: str_field(&v, "key").unwrap_or_default(), + name: str_field(&v, "name").unwrap_or_default(), + description: str_field(&v, "description").unwrap_or_default(), + component_set_id: str_field(&v, "componentSetId"), + remote: v.get("remote").and_then(Value::as_bool).unwrap_or(false), + }), + )); + } + for (node_id, v) in obj_map("componentSets") { + recs.push(( + Id::ComponentSet(node_id.clone()), + Rec::ComponentSet(ComponentSetRec { + node_id, + key: str_field(&v, "key").unwrap_or_default(), + name: str_field(&v, "name").unwrap_or_default(), + description: str_field(&v, "description").unwrap_or_default(), + remote: v.get("remote").and_then(Value::as_bool).unwrap_or(false), + }), + )); + } + for (style_id, v) in obj_map("styles") { + recs.push(( + Id::Style(style_id.clone()), + Rec::Style(StyleRec { + style_id, + key: str_field(&v, "key").unwrap_or_default(), + name: str_field(&v, "name").unwrap_or_default(), + style_type: str_field(&v, "styleType").unwrap_or_default(), + description: str_field(&v, "description").unwrap_or_default(), + remote: v.get("remote").and_then(Value::as_bool).unwrap_or(false), + }), + )); + } + Ok(Flattened { recs, file }) } @@ -72,6 +119,8 @@ fn walk( obj.remove("children"); } + let bound_variables = scan_bound_variables(&raw); + let rec = NodeRec { id: id.to_string(), parent_id: parent_id.map(str::to_string), @@ -81,11 +130,15 @@ fn walk( name: str_field(node, "name").unwrap_or_default(), visible: node.get("visible").and_then(Value::as_bool).unwrap_or(true), text: str_field(node, "characters"), - component_id: None, - component_properties: Vec::new(), - property_definitions: None, - style_refs: Vec::new(), - bound_variables: Vec::new(), + component_id: str_field(node, "componentId"), + component_properties: sorted_map(node.get("componentProperties"), |v| { + v.get("value").map(|val| serde_json::to_string(val).expect("Value serializes")) + }), + property_definitions: node + .get("componentPropertyDefinitions") + .map(|v| serde_json::to_string(v).expect("Value serializes")), + style_refs: sorted_map(node.get("styles"), |v| v.as_str().map(str::to_string)), + bound_variables, abs_bounds: node.get("absoluteBoundingBox").and_then(|b| { Some([ b.get("x")?.as_f64()?, @@ -104,3 +157,72 @@ fn walk( } } } + +/// Turn a JSON object into sorted (key, f(value)) pairs; absent/None entries drop. +fn sorted_map(obj: Option<&Value>, f: impl Fn(&Value) -> Option) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = obj + .and_then(Value::as_object) + .map(|m| { + m.iter() + .filter_map(|(k, v)| Some((k.clone(), f(v)?))) + .collect() + }) + .unwrap_or_default(); + out.sort(); + out +} + +/// Recursively find every `boundVariables` object and emit +/// (pointer-to-resolved-value, variable id) pairs. The binding +/// `…/boundVariables/ = {type: VARIABLE_ALIAS, id}` resolves at the +/// sibling `…/`, which is where Figma bakes the concrete value. +fn scan_bound_variables(node_raw: &Value) -> Vec<(String, String)> { + let mut out = Vec::new(); + scan_bv(node_raw, "", &mut out); + out.sort(); + out.dedup(); + out +} + +fn scan_bv(v: &Value, path: &str, out: &mut Vec<(String, String)>) { + match v { + Value::Object(map) => { + for (k, child) in map { + if k == "boundVariables" { + collect_aliases(child, path, out); + } else { + scan_bv(child, &format!("{path}/{k}"), out); + } + } + } + Value::Array(items) => { + for (i, child) in items.iter().enumerate() { + scan_bv(child, &format!("{path}/{i}"), out); + } + } + _ => {} + } +} + +/// Walk the *inside* of a `boundVariables` object: values are aliases, +/// arrays of aliases, or nested objects of them. +fn collect_aliases(v: &Value, prop_path: &str, out: &mut Vec<(String, String)>) { + match v { + Value::Object(map) => { + let alias = map.get("type").and_then(Value::as_str) == Some("VARIABLE_ALIAS"); + if alias && let Some(id) = map.get("id").and_then(Value::as_str) { + out.push((prop_path.to_string(), id.to_string())); + return; + } + for (k, child) in map { + collect_aliases(child, &format!("{prop_path}/{k}"), out); + } + } + Value::Array(items) => { + for (i, child) in items.iter().enumerate() { + collect_aliases(child, &format!("{prop_path}/{i}"), out); + } + } + _ => {} + } +} diff --git a/examples/figmog/tests/flatten.rs b/examples/figmog/tests/flatten.rs index 217a45a..ec34896 100644 --- a/examples/figmog/tests/flatten.rs +++ b/examples/figmog/tests/flatten.rs @@ -3,7 +3,7 @@ mod common; use figmog::flatten::flatten_file; -use figmog::model::{Id, Rec}; +use figmog::model::{ComponentRec, Id, Rec}; fn node(recs: &[(Id, Rec)], id: &str) -> figmog::model::NodeRec { recs.iter() @@ -14,6 +14,15 @@ fn node(recs: &[(Id, Rec)], id: &str) -> figmog::model::NodeRec { .unwrap_or_else(|| panic!("node {id} not flattened")) } +fn component(recs: &[(Id, Rec)], id: &str) -> ComponentRec { + recs.iter() + .find_map(|(k, r)| match (k, r) { + (Id::Component(n), Rec::Component(rec)) if n == id => Some(rec.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("component {id} not flattened")) +} + #[test] fn walks_the_whole_tree() { let out = flatten_file(&common::fixture_v1()).unwrap(); @@ -90,3 +99,72 @@ fn deterministic_bytes() { fn missing_document_errors() { assert!(flatten_file(&serde_json::json!({"name": "x"})).is_err()); } + +#[test] +fn instance_component_fields() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let button = node(&out.recs, "1:3"); + assert_eq!(button.component_id.as_deref(), Some("2:2")); + // sorted by property name; values are canonical JSON of the `value` field + assert_eq!( + button.component_properties, + vec![ + ("HasIcon".to_string(), "false".to_string()), + ("Icon".to_string(), "\"3:1\"".to_string()), + ("Label".to_string(), "\"Go\"".to_string()), + ("Size".to_string(), "\"Large\"".to_string()), + ("State".to_string(), "\"Default\"".to_string()), + ] + ); +} + +#[test] +fn property_definitions_on_set_and_component() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let set = node(&out.recs, "2:1"); + let defs: serde_json::Value = + serde_json::from_str(set.property_definitions.as_deref().unwrap()).unwrap(); + assert_eq!(defs["Size"]["variantOptions"], serde_json::json!(["Large", "Small"])); + // standalone component without the field -> None + assert_eq!(node(&out.recs, "3:1").property_definitions, None); +} + +#[test] +fn style_refs_extracted_sorted() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + assert_eq!(node(&out.recs, "1:1").style_refs, vec![("fill".to_string(), "S:1".to_string())]); + assert_eq!(node(&out.recs, "1:2").style_refs, vec![("text".to_string(), "S:2".to_string())]); +} + +#[test] +fn bound_variable_scan_finds_all_depths() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let hero = node(&out.recs, "1:1"); + // sorted by pointer; pointer addresses the RESOLVED value location + assert_eq!( + hero.bound_variables, + vec![ + ("/fills/0/color".to_string(), "VariableID:100".to_string()), + ("/paddingLeft".to_string(), "VariableID:200".to_string()), + ] + ); +} + +#[test] +fn envelope_maps_flattened() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let c = component(&out.recs, "2:2"); + assert_eq!(c.key, "key22"); + assert_eq!(c.component_set_id.as_deref(), Some("2:1")); + assert!(!c.remote); + + let styles: Vec = out.recs.iter() + .filter_map(|(_, r)| match r { Rec::Style(s) => Some(s.clone()), _ => None }) + .collect(); + assert_eq!(styles.len(), 2); + assert_eq!(styles[0].style_id, "S:1"); // sorted by style id + assert_eq!(styles[0].style_type, "FILL"); + + let sets = out.recs.iter().filter(|(k, _)| matches!(k, Id::ComponentSet(_))).count(); + assert_eq!(sets, 1); +} From bc17913b3b9116127a0fc882029c69d6f2624b52 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:01:39 -0700 Subject: [PATCH 09/56] feat(figmog): pipeline, sync transaction, churn accounting Co-Authored-By: Claude Fable 5 --- examples/figmog/src/store.rs | 201 +++++++++++++++++++++++++++++++++- examples/figmog/tests/sync.rs | 109 ++++++++++++++++++ 2 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 examples/figmog/tests/sync.rs diff --git a/examples/figmog/src/store.rs b/examples/figmog/src/store.rs index 1c1323a..a05d1cf 100644 --- a/examples/figmog/src/store.rs +++ b/examples/figmog/src/store.rs @@ -1 +1,200 @@ -//! Placeholder — implemented in a later task. +//! Pipeline definition and the sync transaction. +//! +//! The pipeline type contains fn items and so can't be written down; the +//! `figmog_pipeline!` / `open_store!` macros expand it at each use site +//! (main + tests). Everything else here is ordinary generic functions. + +use std::collections::BTreeSet; + +use fold::pipeline::{Keyed, Push}; +use fold::stream::KeyedStream; +use serde::Serialize; + +use crate::flatten::Flattened; +use crate::model::{FileMeta, Id, NodeRec, Rec}; + +// ---- pipeline branch functions (pure; fold requires determinism) ---- + +pub fn node_only(d: &Keyed) -> Option> { + match &d.val { + Rec::Node(n) => Some(Keyed::new(n.id.clone(), n.clone())), + _ => None, + } +} + +pub fn child_edge(d: &Keyed) -> Option> { + let parent = d.val.parent_id.clone()?; + Some(Keyed::new(parent, (d.val.child_index, d.val.id.clone()))) +} + +pub fn text_doc(d: &Keyed) -> Option> { + let mut s = d.val.name.clone(); + if let Some(t) = &d.val.text { + s.push(' '); + s.push_str(t); + } + let s = s.trim().to_string(); + (!s.is_empty()).then(|| Keyed::new(d.val.id.clone(), s)) +} + +pub fn instance_edge(d: &Keyed) -> Option> { + d.val + .component_id + .clone() + .map(|c| Keyed::new(d.val.id.clone(), c)) +} + +pub fn style_edges(d: &Keyed) -> Vec> { + d.val + .style_refs + .iter() + .map(|(_, style_id)| Keyed::new(d.val.id.clone(), style_id.clone())) + .collect() +} + +pub fn variable_edges(d: &Keyed) -> Vec> { + let mut edges: Vec<_> = d + .val + .bound_variables + .iter() + .map(|(_, var_id)| Keyed::new(d.val.id.clone(), var_id.clone())) + .collect(); + edges.dedup_by(|a, b| a.val == b.val); // sorted input: dedup repeated ids + edges +} + +pub fn type_edge(d: &Keyed) -> Keyed { + Keyed::new(d.val.id.clone(), d.val.node_type.clone()) +} + +macro_rules! rec_branch { + ($name:ident, $idvar:ident, $recvar:ident, $rec:ty) => { + pub fn $name(d: &Keyed) -> Option> { + match (&d.key, &d.val) { + (Id::$idvar(k), Rec::$recvar(r)) => Some(Keyed::new(k.clone(), r.clone())), + _ => None, + } + } + }; +} +rec_branch!(component_only, Component, Component, crate::model::ComponentRec); +rec_branch!(component_set_only, ComponentSet, ComponentSet, crate::model::ComponentSetRec); +rec_branch!(style_only, Style, Style, crate::model::StyleRec); +rec_branch!(variable_only, Variable, Variable, crate::model::VariableRec); +rec_branch!(collection_only, VariableCollection, VariableCollection, crate::model::VariableCollectionRec); + +// key is u8(0), not (): () postcard-encodes to zero bytes and the store forbids empty keys +pub fn meta_only(d: &Keyed) -> Option> { + match &d.val { + Rec::Meta(m) => Some(Keyed::new(0u8, m.clone())), + _ => None, + } +} + +/// The full figmog pipeline. Sink names are frozen on-disk schema. +#[macro_export] +macro_rules! figmog_pipeline { + () => {{ + use fold::pipeline::{FilterMap, FlatMap, Map, terminal}; + ( + FilterMap::new( + $crate::store::node_only, + ( + terminal::Table::new("nodes"), + FilterMap::new($crate::store::child_edge, terminal::Multimap::new("children")), + FilterMap::new($crate::store::text_doc, terminal::search::Bm25::new("text")), + FilterMap::new($crate::store::instance_edge, terminal::InvertedIndex::new("instances_of")), + FlatMap::new($crate::store::style_edges, terminal::InvertedIndex::new("styled_by")), + FlatMap::new($crate::store::variable_edges, terminal::InvertedIndex::new("bound_to")), + Map::new($crate::store::type_edge, terminal::InvertedIndex::new("by_type")), + ), + ), + FilterMap::new($crate::store::component_only, terminal::Table::new("components")), + FilterMap::new($crate::store::component_set_only, terminal::Table::new("component_sets")), + FilterMap::new($crate::store::style_only, terminal::Table::new("styles")), + FilterMap::new($crate::store::variable_only, terminal::Table::new("variables")), + FilterMap::new($crate::store::collection_only, terminal::Table::new("variable_collections")), + FilterMap::new($crate::store::meta_only, terminal::Table::new("meta")), + ) + }}; +} + +/// Open (or create) the figmog store at `$path`. +#[macro_export] +macro_rules! open_store { + ($path:expr) => { + ::fold::stream::KeyedStream::<$crate::model::Id, $crate::model::Rec, _>::new( + $path, + $crate::figmog_pipeline!(), + ) + }; +} + +// ---- sync ---- + +/// What one sync did, per record. +#[derive(Debug, Default, PartialEq, Serialize)] +pub struct Churn { + pub added: usize, + pub changed: usize, + pub removed: usize, + pub unchanged: usize, +} + +/// Apply a flattened file in one atomic transaction: upsert every record +/// and the meta row, then remove previously-stored ids that vanished. +/// Variables, collections, and the meta row are exempt from the sweep. +pub fn sync>>( + st: &mut KeyedStream, + prior_sweepable: &BTreeSet, + flattened: &Flattened, + synced_at_unix_ms: u64, +) -> Churn { + let meta = FileMeta { + name: flattened.file.name.clone(), + version: flattened.file.version.clone(), + last_modified: flattened.file.last_modified.clone(), + synced_at_unix_ms, + }; + let live: BTreeSet<&Id> = flattened.recs.iter().map(|(id, _)| id).collect(); + + let mut churn = Churn::default(); + st.wtx(|tx| { + for (id, rec) in &flattened.recs { + match tx.upsert(id, rec) { + None => churn.added += 1, + Some(old) if old == *rec => churn.unchanged += 1, + Some(_) => churn.changed += 1, + } + } + tx.upsert(&Id::Meta, &Rec::Meta(meta)); + for id in prior_sweepable { + if !live.contains(id) { + debug_assert!(!matches!( + id, + Id::Variable(_) | Id::VariableCollection(_) | Id::Meta + )); + if tx.remove(id).is_some() { + churn.removed += 1; + } + } + } + }); + churn +} + +/// Gather the sweepable id set from the four table readers. Call inside +/// `rtx` *before* `sync` (single-writer process: no write races). +pub fn collect_sweepable( + nodes: &fold::pipeline::terminal::TableReader<'_, R, String, NodeRec>, + components: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::ComponentRec>, + component_sets: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::ComponentSetRec>, + styles: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::StyleRec>, +) -> BTreeSet { + let mut out = BTreeSet::new(); + out.extend(nodes.iter().map(|(k, _)| Id::Node(k))); + out.extend(components.iter().map(|(k, _)| Id::Component(k))); + out.extend(component_sets.iter().map(|(k, _)| Id::ComponentSet(k))); + out.extend(styles.iter().map(|(k, _)| Id::Style(k))); + out +} diff --git a/examples/figmog/tests/sync.rs b/examples/figmog/tests/sync.rs new file mode 100644 index 0000000..dcb98d4 --- /dev/null +++ b/examples/figmog/tests/sync.rs @@ -0,0 +1,109 @@ +#![recursion_limit = "256"] + +mod common; + +use std::cell::Cell; +use std::collections::BTreeSet; +use std::rc::Rc; + +use figmog::flatten::flatten_file; +use figmog::model::{Id, Rec}; +use figmog::store::{Churn, sync}; +use fold::pipeline::{Keyed, Map}; + +/// Open a store whose pipeline is fronted by a delta probe: every push +/// into the graph bumps the counter. Zero churn must mean zero pushes. +macro_rules! open_probed { + ($path:expr, $counter:expr) => {{ + let c = $counter.clone(); + ::fold::stream::KeyedStream::::new( + $path, + Map::new( + move |d: &Keyed| { + c.set(c.get() + 1); + d.clone() + }, + figmog::figmog_pipeline!(), + ), + ) + }}; +} + +fn pull( + st: &mut fold::stream::KeyedStream>>, + fixture: &serde_json::Value, +) -> Churn { + // NOTE: `impl Trait` in argument position works here because we only + // use write-path (upsert/remove) APIs; readers stay at the call site. + let flattened = flatten_file(fixture).unwrap(); + let prior = BTreeSet::new(); // overridden by tests that need the sweep + sync(st, &prior, &flattened, 1_000) +} + +#[test] +fn initial_pull_populates_every_sink() { + let dir = tempfile::tempdir().unwrap(); + let counter = Rc::new(Cell::new(0usize)); + let mut st = open_probed!(dir.path().join("db"), counter); + + let churn = pull(&mut st, &common::fixture_v1()); + assert_eq!(churn, Churn { added: 18, changed: 0, removed: 0, unchanged: 0 }); + // 18 records + 1 meta row, all fresh inserts -> 19 pushes + assert_eq!(counter.get(), 19); + + st.rtx(|((nodes, children, text, instances_of, styled_by, bound_to, by_type), + components, component_sets, styles, _vars, _colls, meta)| { + assert_eq!(nodes.iter().count(), 12); + assert_eq!(nodes.get(&"1:2".to_string()).unwrap().name, "Title"); + + let mut kids = children.get(&"1:1".to_string()); + kids.sort(); + assert_eq!(kids, vec![(0, "1:2".to_string()), (1, "1:3".to_string())]); + + let hits = text.search("garden", 5); + assert!(hits.iter().any(|h| h.val == "1:2"), "bm25 finds the title text"); + + assert_eq!(instances_of.search(&"2:2".to_string()), vec!["1:3".to_string()]); + assert_eq!(styled_by.search(&"S:2".to_string()), vec!["1:2".to_string()]); + assert_eq!(bound_to.search(&"VariableID:100".to_string()), vec!["1:1".to_string()]); + + let mut texts = by_type.search(&"TEXT".to_string()); + texts.sort(); + assert_eq!(texts, vec!["1:2".to_string()]); + + assert_eq!(components.iter().count(), 3); + assert_eq!(component_sets.iter().count(), 1); + assert_eq!(styles.iter().count(), 2); + let m = meta.get(&0).unwrap(); + assert_eq!(m.version, "100"); + assert_eq!(m.synced_at_unix_ms, 1_000); + }); +} + +#[test] +fn identical_repull_causes_zero_churn() { + let dir = tempfile::tempdir().unwrap(); + let counter = Rc::new(Cell::new(0usize)); + let mut st = open_probed!(dir.path().join("db"), counter); + + pull(&mut st, &common::fixture_v1()); + counter.set(0); + let churn = pull(&mut st, &common::fixture_v1()); // same synced_at too + assert_eq!(churn, Churn { added: 0, changed: 0, removed: 0, unchanged: 18 }); + assert_eq!(counter.get(), 0, "no delta may enter the graph on an identical re-pull"); +} + +#[test] +fn reopen_resumes_persisted_state() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("db"); + { + let mut st = figmog::open_store!(&db); + let flattened = flatten_file(&common::fixture_v1()).unwrap(); + sync(&mut st, &BTreeSet::new(), &flattened, 1_000); + } + let st = figmog::open_store!(&db); + st.rtx(|((nodes, ..), _, _, _, _, _, _)| { + assert_eq!(nodes.iter().count(), 12); + }); +} From ec95f50ecdac24beecbed941e591c5648c3c4aff Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:11:17 -0700 Subject: [PATCH 10/56] test(figmog): diff churn, sweep exemptions, rollback atomicity Co-Authored-By: Claude Fable 5 --- examples/figmog/tests/sync.rs | 130 ++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/examples/figmog/tests/sync.rs b/examples/figmog/tests/sync.rs index dcb98d4..abc7956 100644 --- a/examples/figmog/tests/sync.rs +++ b/examples/figmog/tests/sync.rs @@ -107,3 +107,133 @@ fn reopen_resumes_persisted_state() { assert_eq!(nodes.iter().count(), 12); }); } + +/// Pull v2 over v1 with the sweep enabled, capturing probe deltas. +fn pull_with_sweep( + st: &mut fold::stream::KeyedStream>>, + fixture: &serde_json::Value, + prior: BTreeSet, + synced_at: u64, +) -> Churn { + let flattened = flatten_file(fixture).unwrap(); + sync(st, &prior, &flattened, synced_at) +} + +#[test] +fn v1_to_v2_minimal_churn_and_index_consistency() { + let dir = tempfile::tempdir().unwrap(); + let counter = Rc::new(Cell::new(0usize)); + let mut st = open_probed!(dir.path().join("db"), counter); + pull(&mut st, &common::fixture_v1()); + + let prior = st.rtx(|((nodes, ..), components, component_sets, styles, _, _, _)| { + figmog::store::collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + counter.set(0); + let churn = pull_with_sweep(&mut st, &common::fixture_v2(), prior, 1_000); + + // v2 has 18 records: 12 nodes (12 - 1:9 + 1:4) + 3 components + 1 set + // + 2 styles. changed: 1:2 (rename), 1:3 (variant repoint). added: 1:4. + // removed: 1:9. unchanged: 18 - 1 - 2 = 15 (meta row is not counted). + assert_eq!(churn, Churn { added: 1, changed: 2, removed: 1, unchanged: 15 }); + // pushes: changed 2×2 + added 1 + removed 1 + meta retract/insert 2 = 8 + assert_eq!(counter.get(), 8); + + st.rtx(|((nodes, children, text, instances_of, _styled, _bound, by_type), + _c, _cs, _s, _v, _vc, meta)| { + // rename re-indexed in bm25 + assert!(text.search("Headline", 5).iter().any(|h| h.val == "1:2")); + assert!(!text.search("Title", 5).iter().any(|h| h.val == "1:2")); + // deleted node gone everywhere + assert!(nodes.get(&"1:9".to_string()).is_none()); + assert!(!by_type.search(&"RECTANGLE".to_string()).contains(&"1:9".to_string())); + let kids = children.get(&"0:1".to_string()); + assert!(!kids.iter().any(|(_, id)| id == "1:9")); + // instance repoint moved the inverted index posting + assert_eq!(instances_of.search(&"2:2".to_string()), Vec::::new()); + assert_eq!(instances_of.search(&"2:3".to_string()), vec!["1:3".to_string()]); + // new node present + assert_eq!(nodes.get(&"1:4".to_string()).unwrap().name, "Subtitle"); + assert!(text.search("Planting", 5).iter().any(|h| h.val == "1:4")); + assert_eq!(meta.get(&0).unwrap().version, "101"); + }); +} + +#[test] +fn sweep_never_touches_variables() { + use figmog::model::{VariableCollectionRec, VariableRec}; + let dir = tempfile::tempdir().unwrap(); + let mut st = figmog::open_store!(dir.path().join("db")); + pull(&mut st, &common::fixture_v1()); + // hand-insert an imported variable, then re-pull with a full sweep set + st.wtx(|tx| { + tx.upsert( + &Id::Variable("VariableID:100".into()), + &Rec::Variable(VariableRec { + id: "VariableID:100".into(), + name: "color/bg".into(), + resolved_type: "COLOR".into(), + collection_id: "VC:1".into(), + values_by_mode: vec![("M:1".into(), "{\"r\":0.06}".into())], + description: String::new(), + scopes: vec![], + }), + ); + tx.upsert( + &Id::VariableCollection("VC:1".into()), + &Rec::VariableCollection(VariableCollectionRec { + id: "VC:1".into(), + name: "core".into(), + modes: vec![("M:1".into(), "light".into())], + default_mode_id: "M:1".into(), + }), + ); + }); + let prior = st.rtx(|((nodes, ..), components, component_sets, styles, _, _, _)| { + figmog::store::collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + pull_with_sweep(&mut st, &common::fixture_v2(), prior, 2_000); + st.rtx(|(_, _, _, _, vars, colls, _)| { + assert!(vars.get(&"VariableID:100".to_string()).is_some()); + assert!(colls.get(&"VC:1".to_string()).is_some()); + }); +} + +#[test] +fn panicking_transaction_rolls_back_entirely() { + let dir = tempfile::tempdir().unwrap(); + let mut st = figmog::open_store!(dir.path().join("db")); + pull(&mut st, &common::fixture_v1()); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + st.wtx(|tx| { + tx.upsert( + &Id::Node("9:9".into()), + &Rec::Node(figmog::model::NodeRec { + id: "9:9".into(), + parent_id: Some("0:1".into()), + child_index: 7, + page_id: "0:1".into(), + node_type: "FRAME".into(), + name: "doomed".into(), + visible: true, + text: None, + component_id: None, + component_properties: vec![], + property_definitions: None, + style_refs: vec![], + bound_variables: vec![], + abs_bounds: None, + raw: "{}".into(), + }), + ); + panic!("mid-transaction failure"); + }) + })); + assert!(result.is_err()); + st.rtx(|((nodes, ..), _, _, _, _, _, meta)| { + assert!(nodes.get(&"9:9".to_string()).is_none(), "aborted upsert must not persist"); + assert_eq!(nodes.iter().count(), 12); + assert_eq!(meta.get(&0).unwrap().version, "100"); + }); +} From a7b7eda7d42f2dfd50eaaecb69baf0979f16bf12 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:15:24 -0700 Subject: [PATCH 11/56] feat(figmog): figma api client behind a testable trait Co-Authored-By: Claude Fable 5 --- examples/figmog/src/api.rs | 141 ++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/examples/figmog/src/api.rs b/examples/figmog/src/api.rs index 1c1323a..3bf0af4 100644 --- a/examples/figmog/src/api.rs +++ b/examples/figmog/src/api.rs @@ -1 +1,140 @@ -//! Placeholder — implemented in a later task. +//! Figma REST client. Everything network-facing sits behind [`FigmaApi`] +//! so the rest of the crate is testable offline. + +use std::time::Duration; + +use serde_json::Value; + +/// Errors surfaced by [`FigmaApi`] implementations. +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("rate limited; retry after {retry_after:?}")] + RateLimited { retry_after: Duration }, + #[error("authentication failed — check FIGMA_TOKEN and file access")] + Auth, + #[error("figma returned {status}: {msg}")] + Http { status: u16, msg: String }, + #[error("network error: {0}")] + Network(String), + #[error("unexpected response shape: {0}")] + Parse(String), +} + +/// Subset of `GET /v1/files/:key/meta` figmog needs. +#[derive(Debug, Clone, PartialEq)] +pub struct FileMetaResp { + pub name: String, + /// "The UTC ISO 8601 time at which the file content was last modified." + pub last_touched_at: String, +} + +/// The two calls figmog makes. `file_meta` is Tier 3 (cheap, poll it); +/// `file` is Tier 1 (expensive, call only on change). +pub trait FigmaApi { + fn file_meta(&self, key: &str) -> Result; + fn file(&self, key: &str) -> Result; +} + +pub(crate) fn parse_meta_response(v: &Value) -> Result { + let file = v.get("file").ok_or_else(|| ApiError::Parse("no `file` object".into()))?; + let get = |k: &str| { + file.get(k) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| ApiError::Parse(format!("meta missing `{k}`"))) + }; + Ok(FileMetaResp { name: get("name")?, last_touched_at: get("last_touched_at")? }) +} + +pub(crate) fn error_from_status(status: u16, retry_after: Option<&str>, msg: String) -> ApiError { + match status { + 429 => ApiError::RateLimited { + retry_after: Duration::from_secs( + retry_after.and_then(|s| s.trim().parse().ok()).unwrap_or(60), + ), + }, + 401 | 403 => ApiError::Auth, + _ => ApiError::Http { status, msg }, + } +} + +/// Blocking `ureq` implementation against api.figma.com. +pub struct UreqApi { + token: String, + base_url: String, +} + +impl UreqApi { + pub fn new(token: String) -> Self { + Self::with_base_url(token, "https://api.figma.com".into()) + } + /// `base_url` override for tests / proxies. + pub fn with_base_url(token: String, base_url: String) -> Self { + UreqApi { token, base_url } + } + + fn get_json(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + match ureq::get(&url).set("X-Figma-Token", &self.token).call() { + Ok(resp) => resp + .into_json() + .map_err(|e| ApiError::Parse(e.to_string())), + Err(ureq::Error::Status(status, resp)) => { + let retry = resp.header("Retry-After").map(str::to_string); + let msg = resp.into_string().unwrap_or_default(); + Err(error_from_status(status, retry.as_deref(), msg)) + } + Err(e) => Err(ApiError::Network(e.to_string())), + } + } +} + +impl FigmaApi for UreqApi { + fn file_meta(&self, key: &str) -> Result { + parse_meta_response(&self.get_json(&format!("/v1/files/{key}/meta"))?) + } + fn file(&self, key: &str) -> Result { + self.get_json(&format!("/v1/files/{key}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_meta_envelope() { + let v = serde_json::json!({ + "file": {"name": "F", "last_touched_at": "2026-08-01T00:00:00Z", "folder_name": "x"} + }); + let m = parse_meta_response(&v).unwrap(); + assert_eq!(m.name, "F"); + assert_eq!(m.last_touched_at, "2026-08-01T00:00:00Z"); + } + + #[test] + fn meta_missing_fields_is_parse_error() { + assert!(matches!( + parse_meta_response(&serde_json::json!({"file": {}})), + Err(ApiError::Parse(_)) + )); + } + + #[test] + fn error_from_status_maps_429_and_403() { + assert!(matches!( + error_from_status(429, Some("30"), "slow down".into()), + ApiError::RateLimited { retry_after } if retry_after == std::time::Duration::from_secs(30) + )); + // absent/garbage Retry-After falls back to 60s + assert!(matches!( + error_from_status(429, None, String::new()), + ApiError::RateLimited { retry_after } if retry_after == std::time::Duration::from_secs(60) + )); + assert!(matches!(error_from_status(403, None, String::new()), ApiError::Auth)); + assert!(matches!( + error_from_status(500, None, "boom".into()), + ApiError::Http { status: 500, .. } + )); + } +} From 3ad219e2f414063d3d3ad63cb89463890a638092 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:18:16 -0700 Subject: [PATCH 12/56] feat(figmog): watch state machine with retry-after and backoff Co-Authored-By: Claude Fable 5 --- examples/figmog/src/watch.rs | 149 ++++++++++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 1 deletion(-) diff --git a/examples/figmog/src/watch.rs b/examples/figmog/src/watch.rs index 1c1323a..df7e13f 100644 --- a/examples/figmog/src/watch.rs +++ b/examples/figmog/src/watch.rs @@ -1 +1,148 @@ -//! Placeholder — implemented in a later task. +//! Polling change detection: a pure state machine the CLI loop drives. +//! No sleeping, no clock — callers act on the returned [`Tick`]. + +use std::time::Duration; + +use crate::api::{ApiError, FigmaApi}; + +const BACKOFF_START: Duration = Duration::from_secs(5); +const BACKOFF_CAP: Duration = Duration::from_secs(300); + +/// Outcome of one poll. +#[derive(Debug)] +pub enum Tick { + /// File unchanged since the last seen `last_touched_at`. + Unchanged, + /// File changed — caller should pull. Carries the new watermark. + Changed { last_touched_at: String }, + /// Transient failure or rate limit — caller should sleep `after` + /// (instead of its normal interval), then tick again. + Wait { after: Duration }, +} + +/// Tracks the last seen content-modification time and failure backoff. +pub struct Watcher { + last_seen: Option, + backoff: Duration, +} + +impl Watcher { + /// `last_seen`: the stored `FileMeta.last_modified`, if any. A spurious + /// mismatch only costs one pull that produces zero churn. + pub fn new(last_seen: Option) -> Self { + Watcher { last_seen, backoff: BACKOFF_START } + } + + pub fn tick(&mut self, api: &dyn FigmaApi, key: &str) -> Tick { + match api.file_meta(key) { + Ok(meta) => { + self.backoff = BACKOFF_START; + if self.last_seen.as_deref() == Some(meta.last_touched_at.as_str()) { + Tick::Unchanged + } else { + self.last_seen = Some(meta.last_touched_at.clone()); + Tick::Changed { last_touched_at: meta.last_touched_at } + } + } + Err(ApiError::RateLimited { retry_after }) => Tick::Wait { after: retry_after }, + Err(_) => { + let after = self.backoff; + self.backoff = (self.backoff * 2).min(BACKOFF_CAP); + Tick::Wait { after } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::{ApiError, FigmaApi, FileMetaResp}; + use std::cell::RefCell; + use std::time::Duration; + + /// Scripted API: pops one response per call; panics if `file` is called. + struct Script(RefCell>>); + impl Script { + fn new(mut responses: Vec>) -> Self { + responses.reverse(); + Script(RefCell::new(responses)) + } + } + impl FigmaApi for Script { + fn file_meta(&self, _key: &str) -> Result { + self.0.borrow_mut().pop().expect("unexpected extra file_meta call") + } + fn file(&self, _key: &str) -> Result { + panic!("watcher must never fetch the file itself"); + } + } + + fn meta(t: &str) -> Result { + Ok(FileMetaResp { name: "F".into(), last_touched_at: t.into() }) + } + + #[test] + fn unchanged_then_changed() { + let api = Script::new(vec![meta("t1"), meta("t1"), meta("t2")]); + let mut w = Watcher::new(Some("t1".into())); + assert!(matches!(w.tick(&api, "k"), Tick::Unchanged)); + assert!(matches!(w.tick(&api, "k"), Tick::Unchanged)); + match w.tick(&api, "k") { + Tick::Changed { last_touched_at } => assert_eq!(last_touched_at, "t2"), + other => panic!("expected Changed, got {other:?}"), + } + } + + #[test] + fn first_tick_with_no_history_is_changed() { + let api = Script::new(vec![meta("t1")]); + let mut w = Watcher::new(None); + assert!(matches!(w.tick(&api, "k"), Tick::Changed { .. })); + } + + #[test] + fn rate_limit_uses_retry_after() { + let api = Script::new(vec![ + Err(ApiError::RateLimited { retry_after: Duration::from_secs(30) }), + meta("t1"), + ]); + let mut w = Watcher::new(Some("t1".into())); + assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(30))); + assert!(matches!(w.tick(&api, "k"), Tick::Unchanged)); + } + + #[test] + fn failures_back_off_exponentially_and_reset_on_success() { + let api = Script::new(vec![ + Err(ApiError::Network("down".into())), + Err(ApiError::Network("down".into())), + Err(ApiError::Network("down".into())), + meta("t1"), + Err(ApiError::Network("down".into())), + ]); + let mut w = Watcher::new(Some("t1".into())); + assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(5))); + assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(10))); + assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(20))); + assert!(matches!(w.tick(&api, "k"), Tick::Unchanged)); + assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(5))); + } + + #[test] + fn backoff_caps_at_five_minutes() { + let mut responses: Vec> = + (0..10).map(|_| Err(ApiError::Network("down".into()))).collect(); + responses.push(meta("t1")); + let api = Script::new(responses); + let mut w = Watcher::new(Some("t1".into())); + let mut last = Duration::ZERO; + for _ in 0..10 { + match w.tick(&api, "k") { + Tick::Wait { after } => last = after, + other => panic!("expected Wait, got {other:?}"), + } + } + assert_eq!(last, Duration::from_secs(300)); + } +} From 36989e7db92902d7af0307351d578adf90427527 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:21:49 -0700 Subject: [PATCH 13/56] feat(figmog): variables export parsing (REST and plugin shapes) Co-Authored-By: Claude Fable 5 --- examples/figmog/src/vars.rs | 85 ++++++++++++++++++- .../tests/fixtures/variables-export.json | 53 ++++++++++++ examples/figmog/tests/vars.rs | 48 +++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 examples/figmog/tests/fixtures/variables-export.json create mode 100644 examples/figmog/tests/vars.rs diff --git a/examples/figmog/src/vars.rs b/examples/figmog/src/vars.rs index 1c1323a..5d8a1ed 100644 --- a/examples/figmog/src/vars.rs +++ b/examples/figmog/src/vars.rs @@ -1 +1,84 @@ -//! Placeholder — implemented in a later task. +//! Variables: authoritative import parsing (this module also hosts the +//! free-plan inference in `infer`). + +use std::collections::BTreeMap; + +use serde_json::Value; + +use crate::model::{Id, Rec, VariableCollectionRec, VariableRec}; + +#[derive(Debug, thiserror::Error)] +pub enum ImportError { + #[error("unrecognized variables export shape: {0}")] + Shape(String), +} + +/// Parse a variables export: either the Enterprise REST `variables/local` +/// response (`{meta: {variables, variableCollections}}`) or the bare +/// object a plugin-console export produces. +pub fn parse_variables_export(v: &Value) -> Result, ImportError> { + let root = v.get("meta").unwrap_or(v); + let variables = root + .get("variables") + .and_then(Value::as_object) + .ok_or_else(|| ImportError::Shape("missing `variables` object".into()))?; + let collections = root + .get("variableCollections") + .and_then(Value::as_object) + .ok_or_else(|| ImportError::Shape("missing `variableCollections` object".into()))?; + + let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); + + let mut recs = Vec::new(); + let sorted: BTreeMap<_, _> = collections.iter().collect(); + for (id, c) in sorted { + let mut modes: Vec<(String, String)> = c + .get("modes") + .and_then(Value::as_array) + .map(|ms| ms.iter().map(|m| (s(m, "modeId"), s(m, "name"))).collect()) + .unwrap_or_default(); + modes.sort(); + recs.push(( + Id::VariableCollection(id.clone()), + Rec::VariableCollection(VariableCollectionRec { + id: id.clone(), + name: s(c, "name"), + modes, + default_mode_id: s(c, "defaultModeId"), + }), + )); + } + let sorted: BTreeMap<_, _> = variables.iter().collect(); + for (id, var) in sorted { + let mut values_by_mode: Vec<(String, String)> = var + .get("valuesByMode") + .and_then(Value::as_object) + .map(|m| { + m.iter() + .map(|(mode, val)| { + (mode.clone(), serde_json::to_string(val).expect("Value serializes")) + }) + .collect() + }) + .unwrap_or_default(); + values_by_mode.sort(); + let scopes: Vec = var + .get("scopes") + .and_then(Value::as_array) + .map(|xs| xs.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + recs.push(( + Id::Variable(id.clone()), + Rec::Variable(VariableRec { + id: id.clone(), + name: s(var, "name"), + resolved_type: s(var, "resolvedType"), + collection_id: s(var, "variableCollectionId"), + values_by_mode, + description: s(var, "description"), + scopes, + }), + )); + } + Ok(recs) +} diff --git a/examples/figmog/tests/fixtures/variables-export.json b/examples/figmog/tests/fixtures/variables-export.json new file mode 100644 index 0000000..f6b5c24 --- /dev/null +++ b/examples/figmog/tests/fixtures/variables-export.json @@ -0,0 +1,53 @@ +{ + "status": 200, + "error": false, + "meta": { + "variables": { + "VariableID:100": { + "id": "VariableID:100", + "name": "color/surface/primary", + "resolvedType": "COLOR", + "variableCollectionId": "VariableCollectionId:1", + "valuesByMode": { + "1:0": { "r": 0.06, "g": 0.13, "b": 0.2, "a": 1.0 }, + "1:1": { "type": "VARIABLE_ALIAS", "id": "VariableID:101" } + }, + "description": "primary surface", + "scopes": ["FRAME_FILL", "SHAPE_FILL"] + }, + "VariableID:101": { + "id": "VariableID:101", + "name": "color/base/ink", + "resolvedType": "COLOR", + "variableCollectionId": "VariableCollectionId:1", + "valuesByMode": { "1:0": { "r": 0.9, "g": 0.9, "b": 0.9, "a": 1.0 }, + "1:1": { "r": 0.1, "g": 0.1, "b": 0.1, "a": 1.0 } }, + "description": "", + "scopes": ["ALL_SCOPES"] + }, + "VariableID:200": { + "id": "VariableID:200", + "name": "space/md", + "resolvedType": "FLOAT", + "variableCollectionId": "VariableCollectionId:2", + "valuesByMode": { "2:0": 16.0 }, + "description": "", + "scopes": ["GAP"] + } + }, + "variableCollections": { + "VariableCollectionId:1": { + "id": "VariableCollectionId:1", + "name": "colors", + "modes": [ {"modeId": "1:0", "name": "light"}, {"modeId": "1:1", "name": "dark"} ], + "defaultModeId": "1:0" + }, + "VariableCollectionId:2": { + "id": "VariableCollectionId:2", + "name": "spacing", + "modes": [ {"modeId": "2:0", "name": "default"} ], + "defaultModeId": "2:0" + } + } + } +} diff --git a/examples/figmog/tests/vars.rs b/examples/figmog/tests/vars.rs new file mode 100644 index 0000000..17bc329 --- /dev/null +++ b/examples/figmog/tests/vars.rs @@ -0,0 +1,48 @@ +#![recursion_limit = "256"] + +mod common; + +use figmog::model::{Id, Rec}; +use figmog::vars::parse_variables_export; + +fn export() -> serde_json::Value { + serde_json::from_str(include_str!("fixtures/variables-export.json")).unwrap() +} + +#[test] +fn parses_rest_shape() { + let recs = parse_variables_export(&export()).unwrap(); + // 2 collections then 3 variables, sorted by id + assert_eq!(recs.len(), 5); + assert!(matches!(&recs[0].0, Id::VariableCollection(id) if id == "VariableCollectionId:1")); + let Rec::VariableCollection(c) = &recs[0].1 else { panic!() }; + assert_eq!(c.modes, vec![("1:0".to_string(), "light".to_string()), ("1:1".to_string(), "dark".to_string())]); + assert_eq!(c.default_mode_id, "1:0"); + + let Rec::Variable(v) = &recs[2].1 else { panic!() }; + assert_eq!(v.id, "VariableID:100"); + assert_eq!(v.resolved_type, "COLOR"); + assert_eq!(v.collection_id, "VariableCollectionId:1"); + // values canonical JSON, sorted by mode id; alias kept as-is + assert_eq!(v.values_by_mode[0].0, "1:0"); + assert!(v.values_by_mode[1].1.contains("VARIABLE_ALIAS")); + assert_eq!(v.scopes, vec!["FRAME_FILL", "SHAPE_FILL"]); +} + +#[test] +fn accepts_bare_shape_and_is_deterministic() { + let bare = export()["meta"].clone(); + let a = parse_variables_export(&bare).unwrap(); + let b = parse_variables_export(&export()).unwrap(); + assert_eq!( + postcard::to_allocvec(&a).unwrap(), + postcard::to_allocvec(&b).unwrap(), + "both shapes produce byte-identical records" + ); +} + +#[test] +fn garbage_is_a_shape_error() { + assert!(parse_variables_export(&serde_json::json!({"nope": 1})).is_err()); + assert!(parse_variables_export(&serde_json::json!(null)).is_err()); +} From a695967ae92deac36f911ad7fca8a800d0a2e15a Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:25:40 -0700 Subject: [PATCH 14/56] feat(figmog): variable inference and style value derivation Co-Authored-By: Claude Fable 5 --- examples/figmog/src/vars.rs | 60 ++++++++++++++++++++++++++++++++++- examples/figmog/tests/vars.rs | 35 ++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/examples/figmog/src/vars.rs b/examples/figmog/src/vars.rs index 5d8a1ed..b85a0f8 100644 --- a/examples/figmog/src/vars.rs +++ b/examples/figmog/src/vars.rs @@ -3,9 +3,10 @@ use std::collections::BTreeMap; +use serde::Serialize; use serde_json::Value; -use crate::model::{Id, Rec, VariableCollectionRec, VariableRec}; +use crate::model::{Id, NodeRec, Rec, VariableCollectionRec, VariableRec}; #[derive(Debug, thiserror::Error)] pub enum ImportError { @@ -82,3 +83,60 @@ pub fn parse_variables_export(v: &Value) -> Result, ImportError> } Ok(recs) } + +/// Everything known about one variable from its usage sites alone. +#[derive(Debug, Serialize)] +pub struct VarUsage { + pub variable_id: String, + /// (node_id, json-pointer of the bound property), sorted. + pub sites: Vec<(String, String)>, + /// Distinct resolved values observed at those sites (canonical JSON), + /// sorted. Usually one value; more indicates multi-mode usage. + pub observed: Vec, +} + +/// Free-plan inference: fold every node's variable bindings into per-variable +/// usage + observed resolved values (the concrete values Figma bakes in +/// next to each binding — default-mode values unless a frame overrides its +/// mode). +pub fn infer_from_nodes<'a>(nodes: impl Iterator) -> Vec { + type VarData = (Vec<(String, String)>, Vec); + let mut by_var: BTreeMap = BTreeMap::new(); + for node in nodes { + let raw: serde_json::Value = match serde_json::from_str(&node.raw) { + Ok(v) => v, + Err(_) => continue, + }; + for (pointer, var_id) in &node.bound_variables { + let entry = by_var.entry(var_id.clone()).or_default(); + entry.0.push((node.id.clone(), pointer.clone())); + if let Some(v) = raw.pointer(pointer) { + entry.1.push(serde_json::to_string(v).expect("Value serializes")); + } + } + } + by_var + .into_iter() + .map(|(variable_id, (mut sites, mut observed))| { + sites.sort(); + observed.sort(); + observed.dedup(); + VarUsage { variable_id, sites, observed } + }) + .collect() +} + +/// Derive a style's definition from one consumer node's raw JSON. +/// Style definitions are not in the file JSON; consumers carry the +/// resolved properties. +pub fn style_value_from_consumer(style_type: &str, consumer_raw: &str) -> Option { + let raw: Value = serde_json::from_str(consumer_raw).ok()?; + let pointer = match style_type { + "TEXT" => "/style", + "FILL" => "/fills", + "EFFECT" => "/effects", + "GRID" => "/layoutGrids", + _ => return None, + }; + raw.pointer(pointer).cloned() +} diff --git a/examples/figmog/tests/vars.rs b/examples/figmog/tests/vars.rs index 17bc329..47a4d30 100644 --- a/examples/figmog/tests/vars.rs +++ b/examples/figmog/tests/vars.rs @@ -46,3 +46,38 @@ fn garbage_is_a_shape_error() { assert!(parse_variables_export(&serde_json::json!({"nope": 1})).is_err()); assert!(parse_variables_export(&serde_json::json!(null)).is_err()); } + +use figmog::flatten::flatten_file; +use figmog::vars::{infer_from_nodes, style_value_from_consumer}; + +#[test] +fn infers_values_and_sites_from_fixture() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let nodes: Vec = out + .recs + .iter() + .filter_map(|(_, r)| match r { Rec::Node(n) => Some(n.clone()), _ => None }) + .collect(); + let usages = infer_from_nodes(nodes.iter()); + + assert_eq!(usages.len(), 2, "two distinct variables bound in fixture"); + let color = usages.iter().find(|u| u.variable_id == "VariableID:100").unwrap(); + assert_eq!(color.sites, vec![("1:1".to_string(), "/fills/0/color".to_string())]); + let observed: serde_json::Value = serde_json::from_str(&color.observed[0]).unwrap(); + assert_eq!(observed["r"], 0.06); + + let pad = usages.iter().find(|u| u.variable_id == "VariableID:200").unwrap(); + assert_eq!(pad.observed, vec!["16.0".to_string()]); +} + +#[test] +fn style_values_come_from_consumers() { + let out = flatten_file(&common::fixture_v1()).unwrap(); + let title = out.recs.iter().find_map(|(k, r)| match (k, r) { + (Id::Node(id), Rec::Node(n)) if id == "1:2" => Some(n.clone()), + _ => None, + }).unwrap(); + let v = style_value_from_consumer("TEXT", &title.raw).unwrap(); + assert_eq!(v["fontSize"], 32.0); + assert!(style_value_from_consumer("FILL", &title.raw).is_none(), "no fills on the text node"); +} From 4abda6218a61cd1c9135a5c329986f00bde80098 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:38:03 -0700 Subject: [PATCH 15/56] feat(figmog): CLI engine commands and core reads (pull/status/pages/tree/get/find) Co-Authored-By: Claude Fable 5 --- examples/figmog/src/cli.rs | 460 ++++++++++++++++++++++++++++++++++- examples/figmog/tests/cli.rs | 89 +++++++ 2 files changed, 546 insertions(+), 3 deletions(-) create mode 100644 examples/figmog/tests/cli.rs diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 21defdb..2044ca3 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -1,6 +1,460 @@ -//! Command-line surface. Real implementation lands with the CLI tasks. +//! Command-line surface. Read commands never touch the network: they open +//! the local store and read one snapshot. + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use clap::{Parser, Subcommand}; +use serde_json::{Value, json}; + +use fold::pipeline::terminal::{InvertedIndexReader, MultimapReader, TableReader}; +use fold::stream::Readable; + +use crate::api::{FigmaApi, UreqApi}; +use crate::flatten::flatten_file; +use crate::ident::{normalize_node_id, parse_file_ref}; +use crate::model::{FileMeta, Id, NodeRec}; +use crate::store::{Churn, collect_sweepable, sync}; +use crate::watch::{Tick, Watcher}; + +#[derive(Parser)] +#[command(name = "figmog", about = "fold-backed local mirror of a Figma file")] +struct Cli { + /// Emit machine-readable JSON on stdout. + #[arg(long, global = true)] + json: bool, + /// Store directory (default: .figmog//db). + #[arg(long, global = true)] + db: Option, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Fetch the file (or read a saved response) and sync the mirror. + Pull { + /// File key or figma.com URL. Optional after the first pull. + file: Option, + /// Ingest a saved GET /v1/files/:key response instead of the network. + #[arg(long)] + from_file: Option, + /// Wipe the store and rebuild from scratch. + #[arg(long)] + fresh: bool, + }, + /// Poll for changes and pull automatically. + Watch { + file: Option, + /// Poll interval in seconds. + #[arg(long, default_value = "10")] + interval: u64, + }, + /// File name, version, last modified, node count. + Status, + /// List pages. + Pages, + /// Subtree outline (default: whole document). + Tree { id: Option, #[arg(long)] depth: Option }, + /// Full raw JSON of one node. + Get { id: String, #[arg(long)] children: bool }, + /// Nodes by type, optionally within one page. + Find { #[arg(long = "type")] node_type: String, #[arg(long)] page: Option }, + /// BM25 search over layer names and text content. + Search { query: String, #[arg(short = 'n', long, default_value = "10")] limit: usize }, + /// Instances of a component (by node id, key, or name). + Instances { target: String }, + /// Design-system inventory: sets, variant axes, standalone components. + Components, + /// Styles with usage counts; --values derives definitions from consumers. + Styles { #[arg(long = "type")] style_type: Option, #[arg(long)] values: bool }, + /// Nodes using a style id or bound to a variable id. + Uses { id: String }, + /// Variables: authoritative if imported, else inferred from bindings. + Vars { id: Option }, + /// Import a variables export (REST or plugin-console shape). + ImportVariables { path: PathBuf }, +} pub fn run() -> i32 { - eprintln!("figmog: not yet implemented"); - 2 + let cli = Cli::parse(); + match dispatch(cli) { + Ok(()) => 0, + Err(e) => { + eprintln!("figmog: {e}"); + 1 + } + } +} + +fn dispatch(cli: Cli) -> Result<(), String> { + let db = resolve_db(&cli)?; + match cli.cmd { + Cmd::Pull { file, from_file, fresh } => cmd_pull(&db, file, from_file, fresh, cli.json), + Cmd::Watch { file, interval } => cmd_watch(&db, file, interval, cli.json), + other => { + // `open_store!`'s pipeline type contains fn items and can't be + // named, so the store-reading dispatch below must live at this + // concrete (non-generic) call site rather than in a helper `fn` + // generic over `P: Push<..>` — `P::Reader<'tx, R>` would be an + // opaque associated type there, and a tuple pattern can't + // destructure an unconstrained associated type. + let st = crate::open_store!(&db.path); + let json = cli.json; + match other { + Cmd::Status => st.rtx(|((nodes, _, _, _, _, _, _), _, _, _, _, _, meta)| { + cmd_status(&nodes, &meta, json) + }), + Cmd::Pages => { + st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| cmd_pages(&nodes, &by_type, json)) + } + Cmd::Tree { id, depth } => st.rtx(|((nodes, children, _, _, _, _, by_type), ..)| { + cmd_tree(&nodes, &children, &by_type, id, depth, json) + }), + Cmd::Get { id, children: with_children } => st.rtx(|((nodes, children, ..), ..)| { + cmd_get(&nodes, &children, id, with_children, json) + }), + Cmd::Find { node_type, page } => st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| { + cmd_find(&nodes, &by_type, node_type, page, json) + }), + Cmd::ImportVariables { .. } => Err("not yet implemented: import-variables".into()), + Cmd::Search { .. } => Err("not yet implemented: search".into()), + Cmd::Instances { .. } => Err("not yet implemented: instances".into()), + Cmd::Components => Err("not yet implemented: components".into()), + Cmd::Styles { .. } => Err("not yet implemented: styles".into()), + Cmd::Uses { .. } => Err("not yet implemented: uses".into()), + Cmd::Vars { .. } => Err("not yet implemented: vars".into()), + Cmd::Pull { .. } | Cmd::Watch { .. } => unreachable!("handled above"), + } + } + } +} + +// ---- config / db resolution ---- + +/// The store to open plus (when known) the file key it mirrors. +struct Db { + path: PathBuf, + key: Option, +} + +const CURRENT_FILE: &str = ".figmog/current"; + +fn resolve_db(cli: &Cli) -> Result { + if let Some(path) = &cli.db { + return Ok(Db { path: path.clone(), key: None }); + } + + // pull/watch with an explicit file ref establish (and remember) the key. + if let Cmd::Pull { file: Some(f), .. } | Cmd::Watch { file: Some(f), .. } = &cli.cmd { + let key = parse_file_ref(f).ok_or_else(|| format!("not a Figma file key or URL: {f}"))?; + write_current(&key)?; + return Ok(Db { path: db_path_for(&key), key: Some(key) }); + } + + let key = std::fs::read_to_string(CURRENT_FILE) + .map_err(|_| "no mirror here — run `figmog pull ` first".to_string())? + .trim() + .to_string(); + if key.is_empty() { + return Err("no mirror here — run `figmog pull ` first".into()); + } + Ok(Db { path: db_path_for(&key), key: Some(key) }) +} + +fn db_path_for(key: &str) -> PathBuf { + PathBuf::from(".figmog").join(key).join("db") +} + +fn write_current(key: &str) -> Result<(), String> { + std::fs::create_dir_all(".figmog").map_err(|e| e.to_string())?; + std::fs::write(CURRENT_FILE, key).map_err(|e| e.to_string()) +} + +fn now_ms() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 +} + +// ---- engine commands ---- + +fn cmd_pull( + db: &Db, + file: Option, + from_file: Option, + fresh: bool, + json: bool, +) -> Result<(), String> { + let resp: Value = match from_file { + Some(path) => { + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("reading {}: {e}", path.display()))?; + serde_json::from_str(&content).map_err(|e| format!("parsing {}: {e}", path.display()))? + } + None => { + let key = db + .key + .clone() + .or_else(|| file.and_then(|f| parse_file_ref(&f))) + .ok_or_else(|| "no file key: pass a file key or figma.com URL".to_string())?; + let token = std::env::var("FIGMA_TOKEN") + .map_err(|_| "FIGMA_TOKEN not set — required for network pulls".to_string())?; + UreqApi::new(token).file(&key).map_err(|e| e.to_string())? + } + }; + + if fresh { + std::fs::remove_dir_all(&db.path).ok(); + } + + let flattened = flatten_file(&resp).map_err(|e| e.to_string())?; + + let mut st = crate::open_store!(&db.path); + let prior: BTreeSet = st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + let churn = sync(&mut st, &prior, &flattened, now_ms()); + + print_churn(&churn, &flattened.file.name, &flattened.file.version, json) +} + +fn print_churn(churn: &Churn, name: &str, version: &str, json: bool) -> Result<(), String> { + if json { + println!("{}", serde_json::to_string(churn).map_err(|e| e.to_string())?); + } else { + println!( + "synced {name} v{version}: +{} ~{} -{} (={} unchanged)", + churn.added, churn.changed, churn.removed, churn.unchanged + ); + } + Ok(()) +} + +fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result<(), String> { + let key = db + .key + .clone() + .or_else(|| file.and_then(|f| parse_file_ref(&f))) + .ok_or_else(|| "no file key: pass a file key or figma.com URL".to_string())?; + let token = std::env::var("FIGMA_TOKEN") + .map_err(|_| "FIGMA_TOKEN not set — required for watch".to_string())?; + let api = UreqApi::new(token); + + if read_watermark(db).is_none() { + cmd_pull(db, Some(key.clone()), None, false, json)?; + } + + let mut stored = read_watermark(db); + let mut watcher = Watcher::new(stored.clone()); + let interval = Duration::from_secs(interval); + + loop { + match watcher.tick(&api, &key) { + Tick::Unchanged => std::thread::sleep(interval), + Tick::Wait { after } => std::thread::sleep(after), + Tick::Changed { .. } => { + match cmd_pull(db, Some(key.clone()), None, false, json) { + Ok(()) => stored = read_watermark(db), + Err(e) => { + eprintln!("figmog: pull failed: {e}"); + // Watcher already advanced its watermark; reset it to + // the last successfully-synced one so the same + // change is re-detected on the next tick. + watcher = Watcher::new(stored.clone()); + } + } + std::thread::sleep(interval); + } + } + } +} + +fn read_watermark(db: &Db) -> Option { + let st = crate::open_store!(&db.path); + st.rtx(|(_, _, _, _, _, _, meta)| meta.get(&0).map(|m| m.last_modified)) +} + +// ---- core reads ---- + +fn cmd_status( + nodes: &TableReader<'_, R, String, NodeRec>, + meta: &TableReader<'_, R, u8, FileMeta>, + json: bool, +) -> Result<(), String> { + let m = meta + .get(&0) + .ok_or_else(|| "no mirror here — run `figmog pull ` first".to_string())?; + let count = nodes.iter().count(); + if json { + let v = json!({ + "name": m.name, + "version": m.version, + "last_modified": m.last_modified, + "synced_at_unix_ms": m.synced_at_unix_ms, + "nodes": count, + }); + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); + } else { + println!("{} v{} — {count} nodes (last modified {})", m.name, m.version, m.last_modified); + } + Ok(()) +} + +fn cmd_pages( + nodes: &TableReader<'_, R, String, NodeRec>, + by_type: &InvertedIndexReader<'_, R, String, String>, + json: bool, +) -> Result<(), String> { + let mut ids = by_type.search(&"CANVAS".to_string()); + ids.sort(); + + let mut pages: Vec<(u32, String, String)> = ids + .into_iter() + .filter_map(|id| nodes.get(&id).map(|n| (n.child_index, n.id, n.name))) + .collect(); + pages.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); + + if json { + let arr: Vec = pages.iter().map(|(_, id, name)| json!({"id": id, "name": name})).collect(); + println!("{}", serde_json::to_string(&arr).map_err(|e| e.to_string())?); + } else { + for (_, id, name) in &pages { + println!("{name} {id}"); + } + } + Ok(()) +} + +/// One level of a `tree` outline; JSON shape `{id, name, type, children}`. +struct TreeNode { + id: String, + name: String, + node_type: String, + children: Vec, +} + +fn build_tree( + nodes: &TableReader<'_, R, String, NodeRec>, + children: &MultimapReader<'_, R, String, (u32, String)>, + node: &NodeRec, + depth: Option, +) -> TreeNode { + let mut kids = Vec::new(); + if depth != Some(0) { + let mut edges = children.get(&node.id); + edges.sort(); + let next_depth = depth.map(|d| d - 1); + for (_, child_id) in edges { + if let Some(child) = nodes.get(&child_id) { + kids.push(build_tree(nodes, children, &child, next_depth)); + } + } + } + TreeNode { id: node.id.clone(), name: node.name.clone(), node_type: node.node_type.clone(), children: kids } +} + +fn tree_to_json(t: &TreeNode) -> Value { + json!({ + "id": t.id, + "name": t.name, + "type": t.node_type, + "children": t.children.iter().map(tree_to_json).collect::>(), + }) +} + +fn print_tree_human(t: &TreeNode, indent: usize) { + println!("{}{} [{}] {}", " ".repeat(indent), t.name, t.node_type, t.id); + for c in &t.children { + print_tree_human(c, indent + 1); + } +} + +fn cmd_tree( + nodes: &TableReader<'_, R, String, NodeRec>, + children: &MultimapReader<'_, R, String, (u32, String)>, + by_type: &InvertedIndexReader<'_, R, String, String>, + id: Option, + depth: Option, + json: bool, +) -> Result<(), String> { + let start = match id { + Some(raw) => normalize_node_id(&raw), + None => { + let mut docs = by_type.search(&"DOCUMENT".to_string()); + docs.sort(); + docs.into_iter().next().ok_or_else(|| "no DOCUMENT node in the mirror".to_string())? + } + }; + let root = nodes.get(&start).ok_or_else(|| format!("no node {start} in the mirror"))?; + let tree = build_tree(nodes, children, &root, depth); + + if json { + println!("{}", serde_json::to_string(&tree_to_json(&tree)).map_err(|e| e.to_string())?); + } else { + print_tree_human(&tree, 0); + } + Ok(()) +} + +fn cmd_get( + nodes: &TableReader<'_, R, String, NodeRec>, + children: &MultimapReader<'_, R, String, (u32, String)>, + id: String, + with_children: bool, + _json: bool, +) -> Result<(), String> { + let id = normalize_node_id(&id); + let node = nodes.get(&id).ok_or_else(|| format!("no node {id} in the mirror"))?; + let mut value: Value = serde_json::from_str(&node.raw).map_err(|e| e.to_string())?; + + if with_children { + let mut edges = children.get(&id); + edges.sort(); + let kids: Vec = edges + .into_iter() + .filter_map(|(_, child_id)| { + nodes.get(&child_id).map(|n| json!({"id": n.id, "name": n.name, "type": n.node_type})) + }) + .collect(); + if let Some(obj) = value.as_object_mut() { + obj.insert("children".to_string(), Value::Array(kids)); + } + } + + // Get's output is always JSON, whether or not --json was passed. + println!("{}", serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?); + Ok(()) +} + +fn cmd_find( + nodes: &TableReader<'_, R, String, NodeRec>, + by_type: &InvertedIndexReader<'_, R, String, String>, + node_type: String, + page: Option, + json: bool, +) -> Result<(), String> { + let mut ids = by_type.search(&node_type); + ids.sort(); + let page = page.as_deref().map(normalize_node_id); + + let mut rows: Vec<(String, String, String)> = ids + .into_iter() + .filter_map(|id| nodes.get(&id)) + .filter(|n| page.as_deref().is_none_or(|p| n.page_id == p)) + .map(|n| (n.id, n.name, n.page_id)) + .collect(); + rows.sort(); + + if json { + let arr: Vec = rows + .iter() + .map(|(id, name, page_id)| json!({"id": id, "name": name, "page_id": page_id})) + .collect(); + println!("{}", serde_json::to_string(&arr).map_err(|e| e.to_string())?); + } else { + for (id, name, page_id) in &rows { + println!("{id} {name} ({page_id})"); + } + } + Ok(()) } diff --git a/examples/figmog/tests/cli.rs b/examples/figmog/tests/cli.rs new file mode 100644 index 0000000..ae15ca9 --- /dev/null +++ b/examples/figmog/tests/cli.rs @@ -0,0 +1,89 @@ +#![recursion_limit = "256"] + +mod common; + +use assert_cmd::Command; + +/// Materialize fixture_v1 into a DB via `pull --from-file` and return the +/// (tempdir, db-arg) pair every read command needs. +fn fixture_db() -> (tempfile::TempDir, String) { + let dir = tempfile::tempdir().unwrap(); + let response = dir.path().join("resp.json"); + std::fs::write(&response, serde_json::to_string(&common::fixture_v1()).unwrap()).unwrap(); + let db = dir.path().join("db").display().to_string(); + Command::cargo_bin("figmog") + .unwrap() + .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db]) + .assert() + .success(); + (dir, db) +} + +#[test] +fn pull_from_file_reports_churn_and_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let response = dir.path().join("resp.json"); + std::fs::write(&response, serde_json::to_string(&common::fixture_v1()).unwrap()).unwrap(); + let db = dir.path().join("db").display().to_string(); + + let out = Command::cargo_bin("figmog").unwrap() + .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db, "--json"]) + .assert().success(); + let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + assert_eq!(v["added"], 18); + + let out = Command::cargo_bin("figmog").unwrap() + .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db, "--json"]) + .assert().success(); + let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + assert_eq!(v["unchanged"], 18); + assert_eq!(v["added"], 0); +} + +#[test] +fn status_pages_tree_get_find() { + let (_dir, db) = fixture_db(); + let run = |args: &[&str]| { + let out = Command::cargo_bin("figmog").unwrap() + .args(args).args(["--db", &db, "--json"]) + .assert().success(); + serde_json::from_slice::(&out.get_output().stdout).unwrap() + }; + + let status = run(&["status"]); + assert_eq!(status["name"], "Fixture"); + assert_eq!(status["version"], "100"); + assert_eq!(status["nodes"], 12); + + let pages = run(&["pages"]); + assert_eq!(pages.as_array().unwrap().len(), 3); + assert_eq!(pages[0]["id"], "0:1"); + assert_eq!(pages[0]["name"], "Page 1"); + + let tree = run(&["tree", "1:1"]); + let kids = tree["children"].as_array().unwrap(); + assert_eq!(kids.len(), 2); + assert_eq!(kids[0]["id"], "1:2"); // numeric child order + + // node-id normalization: URL form accepted + let get = run(&["get", "1-2"]); + assert_eq!(get["name"], "Title"); + assert_eq!(get["characters"], "Welcome to the garden"); + + let texts = run(&["find", "--type", "TEXT"]); + assert_eq!(texts.as_array().unwrap().len(), 1); + assert_eq!(texts[0]["id"], "1:2"); + + let on_page = run(&["find", "--type", "COMPONENT", "--page", "0:2"]); + assert_eq!(on_page.as_array().unwrap().len(), 3); // 2:2, 2:3, 3:1 +} + +#[test] +fn get_unknown_node_fails_cleanly() { + let (_dir, db) = fixture_db(); + Command::cargo_bin("figmog").unwrap() + .args(["get", "99:99", "--db", &db]) + .assert() + .failure() + .code(1); +} From 10c8e1459d0267a36ecc872ac0fe9882381c0e1f Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 19:52:13 -0700 Subject: [PATCH 16/56] feat(figmog): design-system CLI commands (search/instances/components/styles/uses/vars/import-variables) and watch loop Co-Authored-By: Claude Fable 5 --- examples/figmog/src/cli.rs | 458 +++++++++++++++++++++++++++++++++-- examples/figmog/tests/cli.rs | 74 ++++++ 2 files changed, 518 insertions(+), 14 deletions(-) diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 2044ca3..4c34011 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -1,23 +1,31 @@ //! Command-line surface. Read commands never touch the network: they open //! the local store and read one snapshot. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use clap::{Parser, Subcommand}; use serde_json::{Value, json}; +use fold::pipeline::terminal::search::Bm25Reader; use fold::pipeline::terminal::{InvertedIndexReader, MultimapReader, TableReader}; use fold::stream::Readable; use crate::api::{FigmaApi, UreqApi}; use crate::flatten::flatten_file; use crate::ident::{normalize_node_id, parse_file_ref}; -use crate::model::{FileMeta, Id, NodeRec}; +use crate::model::{ + ComponentRec, ComponentSetRec, FileMeta, Id, NodeRec, StyleRec, VariableCollectionRec, + VariableRec, +}; use crate::store::{Churn, collect_sweepable, sync}; use crate::watch::{Tick, Watcher}; +/// Read handle for the pipeline's `text` BM25 sink (its tokenizer type +/// param makes the full type unwieldy at every call site). +type TextReader<'tx, R> = Bm25Reader<'tx, R, String, fn(&str, &mut Vec)>; + #[derive(Parser)] #[command(name = "figmog", about = "fold-backed local mirror of a Figma file")] struct Cli { @@ -93,6 +101,7 @@ fn dispatch(cli: Cli) -> Result<(), String> { match cli.cmd { Cmd::Pull { file, from_file, fresh } => cmd_pull(&db, file, from_file, fresh, cli.json), Cmd::Watch { file, interval } => cmd_watch(&db, file, interval, cli.json), + Cmd::ImportVariables { path } => cmd_import_variables(&db, path, cli.json), other => { // `open_store!`'s pipeline type contains fn items and can't be // named, so the store-reading dispatch below must live at this @@ -118,14 +127,31 @@ fn dispatch(cli: Cli) -> Result<(), String> { Cmd::Find { node_type, page } => st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| { cmd_find(&nodes, &by_type, node_type, page, json) }), - Cmd::ImportVariables { .. } => Err("not yet implemented: import-variables".into()), - Cmd::Search { .. } => Err("not yet implemented: search".into()), - Cmd::Instances { .. } => Err("not yet implemented: instances".into()), - Cmd::Components => Err("not yet implemented: components".into()), - Cmd::Styles { .. } => Err("not yet implemented: styles".into()), - Cmd::Uses { .. } => Err("not yet implemented: uses".into()), - Cmd::Vars { .. } => Err("not yet implemented: vars".into()), - Cmd::Pull { .. } | Cmd::Watch { .. } => unreachable!("handled above"), + Cmd::Search { query, limit } => { + st.rtx(|((nodes, _, text, ..), ..)| cmd_search(&nodes, &text, query, limit, json)) + } + Cmd::Instances { target } => { + st.rtx(|((nodes, _, _, instances_of, ..), components, component_sets, ..)| { + cmd_instances(&nodes, &instances_of, &components, &component_sets, target, json) + }) + } + Cmd::Components => st.rtx(|((nodes, ..), components, component_sets, ..)| { + cmd_components(&component_sets, &components, &nodes, json) + }), + Cmd::Styles { style_type, values } => { + st.rtx(|((nodes, _, _, _, styled_by, ..), _, _, styles, ..)| { + cmd_styles(&styles, &styled_by, &nodes, style_type, values, json) + }) + } + Cmd::Uses { id } => st.rtx(|((nodes, _, _, _, styled_by, bound_to, _), ..)| { + cmd_uses(&nodes, &styled_by, &bound_to, id, json) + }), + Cmd::Vars { id } => st.rtx(|((nodes, ..), _, _, _, variables, variable_collections, _)| { + cmd_vars(&nodes, &variables, &variable_collections, id, json) + }), + Cmd::Pull { .. } | Cmd::Watch { .. } | Cmd::ImportVariables { .. } => { + unreachable!("handled above") + } } } } @@ -185,6 +211,18 @@ fn cmd_pull( fresh: bool, json: bool, ) -> Result<(), String> { + let (churn, name, version) = do_pull(db, file, from_file, fresh)?; + print_churn(&churn, &name, &version, json) +} + +/// The pull mechanics without any printing, so `cmd_watch` can format its +/// own per-tick event lines around the same churn. +fn do_pull( + db: &Db, + file: Option, + from_file: Option, + fresh: bool, +) -> Result<(Churn, String, String), String> { let resp: Value = match from_file { Some(path) => { let content = std::fs::read_to_string(&path) @@ -215,7 +253,7 @@ fn cmd_pull( }); let churn = sync(&mut st, &prior, &flattened, now_ms()); - print_churn(&churn, &flattened.file.name, &flattened.file.version, json) + Ok((churn, flattened.file.name.clone(), flattened.file.version.clone())) } fn print_churn(churn: &Churn, name: &str, version: &str, json: bool) -> Result<(), String> { @@ -251,10 +289,39 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result loop { match watcher.tick(&api, &key) { Tick::Unchanged => std::thread::sleep(interval), - Tick::Wait { after } => std::thread::sleep(after), + Tick::Wait { after } => { + if json { + println!( + "{}", + json!({"event": "waiting", "seconds": after.as_secs()}) + ); + } else { + println!("rate limited, waiting {}s", after.as_secs()); + } + std::thread::sleep(after); + } Tick::Changed { .. } => { - match cmd_pull(db, Some(key.clone()), None, false, json) { - Ok(()) => stored = read_watermark(db), + if json { + println!("{}", json!({"event": "changed"})); + } else { + println!("changed → pulling…"); + } + match do_pull(db, Some(key.clone()), None, false) { + Ok((churn, name, version)) => { + stored = read_watermark(db); + if json { + let mut v = serde_json::to_value(&churn).unwrap_or_default(); + if let Some(obj) = v.as_object_mut() { + obj.insert("event".to_string(), json!("pulled")); + } + println!("{v}"); + } else { + println!( + "synced {name} v{version}: +{} ~{} -{} (={} unchanged)", + churn.added, churn.changed, churn.removed, churn.unchanged + ); + } + } Err(e) => { eprintln!("figmog: pull failed: {e}"); // Watcher already advanced its watermark; reset it to @@ -269,6 +336,29 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result } } +fn cmd_import_variables(db: &Db, path: PathBuf, json: bool) -> Result<(), String> { + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("reading {}: {e}", path.display()))?; + let v: Value = serde_json::from_str(&content) + .map_err(|e| format!("parsing {}: {e}", path.display()))?; + let recs = crate::vars::parse_variables_export(&v).map_err(|e| e.to_string())?; + + let mut st = crate::open_store!(&db.path); + st.wtx(|tx| { + for (id, rec) in &recs { + tx.upsert(id, rec); + } + }); + + let imported = recs.iter().filter(|(id, _)| matches!(id, Id::Variable(_))).count(); + if json { + println!("{}", serde_json::to_string(&json!({"imported": imported})).map_err(|e| e.to_string())?); + } else { + println!("imported {imported} variables"); + } + Ok(()) +} + fn read_watermark(db: &Db) -> Option { let st = crate::open_store!(&db.path); st.rtx(|(_, _, _, _, _, _, meta)| meta.get(&0).map(|m| m.last_modified)) @@ -458,3 +548,343 @@ fn cmd_find( } Ok(()) } + +// ---- design-system reads ---- + +fn cmd_search( + nodes: &TableReader<'_, R, String, NodeRec>, + text: &TextReader<'_, R>, + query: String, + limit: usize, + json: bool, +) -> Result<(), String> { + // BM25's own ranking order is deterministic; keep it (do not re-sort). + let hits = text.search(&query, limit); + let rows: Vec = hits + .iter() + .filter_map(|hit| { + let node = nodes.get(&hit.val)?; + let snippet = node.text.as_ref().map(|t| t.chars().take(80).collect::()); + Some(json!({ + "id": node.id, + "score": hit.score, + "type": node.node_type, + "name": node.name, + "page_id": node.page_id, + "snippet": snippet, + })) + }) + .collect(); + + if json { + println!("{}", serde_json::to_string(&rows).map_err(|e| e.to_string())?); + } else { + for row in &rows { + println!( + "{} {:.3} [{}] {}", + row["id"].as_str().unwrap_or_default(), + row["score"].as_f64().unwrap_or_default(), + row["type"].as_str().unwrap_or_default(), + row["name"].as_str().unwrap_or_default(), + ); + } + } + Ok(()) +} + +/// Resolve a target (node id, component key, or component/set name) to the +/// component node ids it names, in priority order: exact node id, then key, +/// then set name (all variants), then component name (all matches). +fn resolve_component_ids( + components: &TableReader<'_, R, String, ComponentRec>, + component_sets: &TableReader<'_, R, String, ComponentSetRec>, + target: &str, +) -> Vec { + if components.contains(&target.to_string()) { + return vec![target.to_string()]; + } + + let mut ids: Vec = components + .iter() + .filter(|(_, c)| c.key == target) + .map(|(id, _)| id) + .collect(); + if !ids.is_empty() { + return ids; + } + + let set_ids: Vec = component_sets + .iter() + .filter(|(_, s)| s.name == target) + .map(|(id, _)| id) + .collect(); + if !set_ids.is_empty() { + ids = components + .iter() + .filter(|(_, c)| c.component_set_id.as_deref().is_some_and(|s| set_ids.iter().any(|sid| sid == s))) + .map(|(id, _)| id) + .collect(); + return ids; + } + + components.iter().filter(|(_, c)| c.name == target).map(|(id, _)| id).collect() +} + +fn cmd_instances( + nodes: &TableReader<'_, R, String, NodeRec>, + instances_of: &InvertedIndexReader<'_, R, String, String>, + components: &TableReader<'_, R, String, ComponentRec>, + component_sets: &TableReader<'_, R, String, ComponentSetRec>, + target: String, + json: bool, +) -> Result<(), String> { + let target = normalize_node_id(&target); + let component_ids = resolve_component_ids(components, component_sets, &target); + + let mut instance_ids: BTreeSet = BTreeSet::new(); + for cid in &component_ids { + instance_ids.extend(instances_of.search(cid)); + } + + let rows: Vec = instance_ids + .iter() + .filter_map(|id| nodes.get(id)) + .map(|n| json!({"id": n.id, "name": n.name, "page_id": n.page_id, "component_id": n.component_id})) + .collect(); + + if json { + println!("{}", serde_json::to_string(&rows).map_err(|e| e.to_string())?); + } else { + for row in &rows { + println!( + "{} {} ({})", + row["id"].as_str().unwrap_or_default(), + row["name"].as_str().unwrap_or_default(), + row["page_id"].as_str().unwrap_or_default(), + ); + } + } + Ok(()) +} + +fn cmd_components( + component_sets: &TableReader<'_, R, String, ComponentSetRec>, + components: &TableReader<'_, R, String, ComponentRec>, + nodes: &TableReader<'_, R, String, NodeRec>, + json: bool, +) -> Result<(), String> { + let mut sets: Vec<(String, ComponentSetRec)> = component_sets.iter().collect(); + sets.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut all_components: Vec<(String, ComponentRec)> = components.iter().collect(); + all_components.sort_by(|a, b| a.0.cmp(&b.0)); + + let sets_json: Vec = sets + .iter() + .map(|(set_id, set)| { + let variants: Vec = all_components + .iter() + .filter(|(_, c)| c.component_set_id.as_deref() == Some(set_id.as_str())) + .map(|(cid, c)| json!({"node_id": cid, "name": c.name, "key": c.key})) + .collect(); + let property_definitions: Value = nodes + .get(set_id) + .and_then(|n| n.property_definitions.clone()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(Value::Null); + json!({ + "node_id": set_id, + "name": set.name, + "key": set.key, + "variants": variants, + "property_definitions": property_definitions, + }) + }) + .collect(); + + let standalone: Vec = all_components + .iter() + .filter(|(_, c)| c.component_set_id.is_none()) + .map(|(cid, c)| json!({"node_id": cid, "name": c.name, "key": c.key})) + .collect(); + + let out = json!({"sets": sets_json, "components": standalone}); + + if json { + println!("{}", serde_json::to_string(&out).map_err(|e| e.to_string())?); + } else { + for s in &sets_json { + println!( + "{} {} variants", + s["name"].as_str().unwrap_or_default(), + s["variants"].as_array().map(Vec::len).unwrap_or(0), + ); + } + for c in &standalone { + println!("{} {}", c["node_id"].as_str().unwrap_or_default(), c["name"].as_str().unwrap_or_default()); + } + } + Ok(()) +} + +fn cmd_styles( + styles: &TableReader<'_, R, String, StyleRec>, + styled_by: &InvertedIndexReader<'_, R, String, String>, + nodes: &TableReader<'_, R, String, NodeRec>, + style_type: Option, + values: bool, + json: bool, +) -> Result<(), String> { + let mut rows: Vec<(String, StyleRec)> = styles.iter().collect(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + if let Some(t) = &style_type { + rows.retain(|(_, s)| s.style_type.eq_ignore_ascii_case(t)); + } + + let out: Vec = rows + .iter() + .map(|(style_id, s)| { + let mut consumers = styled_by.search(style_id); + consumers.sort(); + let mut obj = json!({ + "style_id": style_id, + "name": s.name, + "key": s.key, + "type": s.style_type, + "uses": consumers.len(), + }); + if values { + let value = consumers + .first() + .and_then(|nid| nodes.get(nid)) + .and_then(|n| crate::vars::style_value_from_consumer(&s.style_type, &n.raw)) + .unwrap_or(Value::Null); + obj["value"] = value; + } + obj + }) + .collect(); + + if json { + println!("{}", serde_json::to_string(&out).map_err(|e| e.to_string())?); + } else { + for row in &out { + println!( + "{} {} [{}] uses={}", + row["style_id"].as_str().unwrap_or_default(), + row["name"].as_str().unwrap_or_default(), + row["type"].as_str().unwrap_or_default(), + row["uses"].as_u64().unwrap_or_default(), + ); + } + } + Ok(()) +} + +fn cmd_uses( + nodes: &TableReader<'_, R, String, NodeRec>, + styled_by: &InvertedIndexReader<'_, R, String, String>, + bound_to: &InvertedIndexReader<'_, R, String, String>, + id: String, + json: bool, +) -> Result<(), String> { + let mut ids = styled_by.search(&id); + if ids.is_empty() { + ids = bound_to.search(&id); + } + ids.sort(); + + let rows: Vec = ids + .iter() + .filter_map(|nid| nodes.get(nid)) + .map(|n| json!({"id": n.id, "name": n.name, "page_id": n.page_id})) + .collect(); + + if json { + println!("{}", serde_json::to_string(&rows).map_err(|e| e.to_string())?); + } else { + for row in &rows { + println!( + "{} {} ({})", + row["id"].as_str().unwrap_or_default(), + row["name"].as_str().unwrap_or_default(), + row["page_id"].as_str().unwrap_or_default(), + ); + } + } + Ok(()) +} + +fn cmd_vars( + nodes: &TableReader<'_, R, String, NodeRec>, + variables: &TableReader<'_, R, String, VariableRec>, + variable_collections: &TableReader<'_, R, String, VariableCollectionRec>, + id: Option, + json: bool, +) -> Result<(), String> { + let owned_nodes: Vec = nodes.iter().map(|(_, n)| n).collect(); + let inferred = crate::vars::infer_from_nodes(owned_nodes.iter()); + let mut inferred_by_id: HashMap = + inferred.into_iter().map(|u| (u.variable_id.clone(), u)).collect(); + + let mut all_ids: BTreeSet = inferred_by_id.keys().cloned().collect(); + all_ids.extend(variables.iter().map(|(k, _)| k)); + if let Some(target) = &id { + all_ids.retain(|v| v == target); + } + + let rows: Vec = all_ids + .iter() + .map(|vid| { + let usage = inferred_by_id.remove(vid); + let (sites, observed) = usage + .map(|u| (u.sites, u.observed)) + .unwrap_or_default(); + + if let Some(var) = variables.get(vid) { + let collection = variable_collections.get(&var.collection_id); + let mut values_by_mode = serde_json::Map::new(); + for (mode_id, val_str) in &var.values_by_mode { + let mode_name = collection + .as_ref() + .and_then(|c| c.modes.iter().find(|(mid, _)| mid == mode_id)) + .map(|(_, name)| name.clone()) + .unwrap_or_else(|| mode_id.clone()); + let val: Value = serde_json::from_str(val_str).unwrap_or(Value::Null); + values_by_mode.insert(mode_name, val); + } + json!({ + "variable_id": vid, + "source": "imported", + "name": var.name, + "resolved_type": var.resolved_type, + "collection": collection.map(|c| c.name), + "values_by_mode": Value::Object(values_by_mode), + "sites": sites, + "observed": observed, + }) + } else { + json!({ + "variable_id": vid, + "source": "inferred", + "sites": sites, + "observed": observed, + }) + } + }) + .collect(); + + if json { + println!("{}", serde_json::to_string(&rows).map_err(|e| e.to_string())?); + } else { + for row in &rows { + println!( + "{} [{}] sites={}", + row["variable_id"].as_str().unwrap_or_default(), + row["source"].as_str().unwrap_or_default(), + row["sites"].as_array().map(Vec::len).unwrap_or(0), + ); + } + } + Ok(()) +} diff --git a/examples/figmog/tests/cli.rs b/examples/figmog/tests/cli.rs index ae15ca9..5e9ca66 100644 --- a/examples/figmog/tests/cli.rs +++ b/examples/figmog/tests/cli.rs @@ -87,3 +87,77 @@ fn get_unknown_node_fails_cleanly() { .failure() .code(1); } + +#[test] +fn search_instances_components_styles_uses_vars() { + let (_dir, db) = fixture_db(); + let run = |args: &[&str]| { + let out = Command::cargo_bin("figmog").unwrap() + .args(args).args(["--db", &db, "--json"]) + .assert().success(); + serde_json::from_slice::(&out.get_output().stdout).unwrap() + }; + + let hits = run(&["search", "garden"]); + assert_eq!(hits[0]["id"], "1:2"); + assert!(hits[0]["score"].as_f64().unwrap() > 0.0); + + // by node id, by key, by set name (=> all variants' instances) + for target in ["2:2", "key22", "Button"] { + let inst = run(&["instances", target]); + assert_eq!(inst[0]["id"], "1:3", "target={target}"); + } + + let comps = run(&["components"]); + let sets = comps["sets"].as_array().unwrap(); + assert_eq!(sets.len(), 1); + assert_eq!(sets[0]["name"], "Button"); + assert_eq!(sets[0]["variants"].as_array().unwrap().len(), 2); + let axes = &sets[0]["property_definitions"]; + assert_eq!(axes["Size"]["variantOptions"], serde_json::json!(["Large", "Small"])); + assert_eq!(comps["components"].as_array().unwrap().len(), 1); // standalone only + assert_eq!(comps["components"][0]["name"], "IconStar"); + + let styles = run(&["styles"]); + assert_eq!(styles.as_array().unwrap().len(), 2); + assert_eq!(styles[0]["style_id"], "S:1"); + assert_eq!(styles[0]["uses"], 1); + + let styles = run(&["styles", "--values"]); + assert_eq!(styles[1]["value"]["fontSize"], 32.0); // S:2 from consumer 1:2 + + let uses = run(&["uses", "S:1"]); + assert_eq!(uses[0]["id"], "1:1"); + let uses = run(&["uses", "VariableID:100"]); + assert_eq!(uses[0]["id"], "1:1"); + + let vars = run(&["vars"]); + let arr = vars.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["variable_id"], "VariableID:100"); + assert_eq!(arr[0]["source"], "inferred"); +} + +#[test] +fn import_variables_upgrades_vars_to_authoritative() { + let (dir, db) = fixture_db(); + let export = dir.path().join("vars.json"); + std::fs::write(&export, include_str!("fixtures/variables-export.json")).unwrap(); + + Command::cargo_bin("figmog").unwrap() + .args(["import-variables", export.to_str().unwrap(), "--db", &db]) + .assert().success(); + + let out = Command::cargo_bin("figmog").unwrap() + .args(["vars", "--db", &db, "--json"]) + .assert().success(); + let vars: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + let v100 = vars.as_array().unwrap().iter() + .find(|v| v["variable_id"] == "VariableID:100").unwrap(); + assert_eq!(v100["source"], "imported"); + assert_eq!(v100["name"], "color/surface/primary"); + assert_eq!(v100["collection"], "colors"); + assert_eq!(v100["values_by_mode"]["light"]["r"], 0.06); + // inference detail still present alongside + assert_eq!(v100["sites"][0][0], "1:1"); +} From eaa38546f5edd172c6971781a9930aaa6041c742 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 20:00:10 -0700 Subject: [PATCH 17/56] docs(figmog): README, plugin export snippet, rustdoc pass Co-Authored-By: Claude Fable 5 --- README.md | 2 + examples/figmog/README.md | 156 ++++++++++++++++++ examples/figmog/src/api.rs | 26 ++- examples/figmog/src/cli.rs | 263 +++++++++++++++++++++++-------- examples/figmog/src/flatten.rs | 11 +- examples/figmog/src/store.rs | 94 +++++++++-- examples/figmog/src/vars.rs | 32 +++- examples/figmog/src/watch.rs | 50 ++++-- examples/figmog/tests/cli.rs | 96 ++++++++--- examples/figmog/tests/flatten.rs | 39 ++++- examples/figmog/tests/sync.rs | 177 +++++++++++++++------ examples/figmog/tests/vars.rs | 53 +++++-- 12 files changed, 800 insertions(+), 199 deletions(-) create mode 100644 examples/figmog/README.md diff --git a/README.md b/README.md index 951a620..2991b30 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ In this directory you'll find a few examples that show bog style databases in va - `timeseries` — weather readings bucketed into hourly and daily aggregates, updated incrementally. `cargo run -p timeseries` - `chat` — a chat backend where fold is the source of truth and every update is broadcast to clients over a websocket. `cargo run -p chat`, then open http://localhost:3000 - `search` — text search three ways over one document stream: BM25 keyword search, HNSW semantic search over ese embeddings, and hybrid rank fusion. A good base for agent memory or document search projects. `cargo run -p search` +- `figmog` — a local mirror of a Figma file: sync once, then search, walk, and + query components/styles/variables with zero API calls. `cargo run -p figmog -- --help` ## More about Bog Bog is a database runtime that makes every attempt to do as much work as possible as early as possible, to make reads incredibly fast. This means compiling queries into functions that eagerly update their output as mutations occur. diff --git a/examples/figmog/README.md b/examples/figmog/README.md new file mode 100644 index 0000000..d1fee86 --- /dev/null +++ b/examples/figmog/README.md @@ -0,0 +1,156 @@ +# figmog + +A fold-backed local mirror of one Figma file: `pull` fetches it once and +keeps materialized indexes in a fold database, so every read after that — +search, tree walks, component/style/variable queries — answers from local +storage in milliseconds, spending zero Figma API calls and hitting zero +rate limits. + +## Quick start + +```console +$ export FIGMA_TOKEN=figd_… # figma.com → settings → security → personal access tokens +$ cargo run -p figmog -- pull "https://www.figma.com/design//" +$ cargo run -p figmog -- search "pricing card" +$ cargo run -p figmog -- watch # keep it fresh in another terminal +``` + +After the first `pull`, figmog remembers the file key (in `.figmog/current` +under the current directory), so every later command — including `watch` +and all the read commands below — can drop the file argument. + +## Commands + +Read commands never touch the network: they open the local store and read +one snapshot. `--json` (global) emits machine-readable JSON on stdout +instead of the human-readable format; `--db ` (global) overrides the +store location (default `.figmog//db`). + +| command | reads | behavior | +|---|---|---| +| `figmog pull [file] [--from-file ] [--fresh]` | — | sync now; prints a churn summary (`+added ~changed -removed`). `file` is optional after the first pull. `--from-file` ingests a saved `GET /v1/files/:key` response instead of the network (offline ingestion, and what keeps the CLI tests hermetic). `--fresh` wipes the store and rebuilds from scratch. | +| `figmog watch [file] [--interval N]` | — | poll loop: cheap metadata check every `N` seconds (default 10), full pull only on an actual change | +| `figmog status` | meta + nodes | file name, version, last modified, node count | +| `figmog pages` | by_type + nodes | list CANVAS pages (id, name) | +| `figmog tree [id] [--depth N]` | children + nodes (+ by_type to find the root) | indented outline: `name [type] id`; root defaults to the DOCUMENT node | +| `figmog get [--children]` | nodes (+ children) | the full `raw` JSON of a node; `--children` inlines one level of child summaries | +| `figmog find --type [--page ]` | by_type + nodes | nodes by type, optional page filter | +| `figmog search [-n N]` | Bm25 + nodes | ranked hits (default 10): score, id, type, name, page, text snippet | +| `figmog instances ` | nodes + instances_of + components + component_sets | resolve the argument to a component (by node id, global key, or a unique component/component-set name — a set name expands to all its variants), list instance nodes | +| `figmog components` | components + component_sets + nodes | design-system inventory: component sets with their variant axes, standalone components | +| `figmog styles [--type ] [--values]` | styles + styled_by (+ nodes) | styles with usage counts; `--values` derives each style's definition from a consumer node (§ below) | +| `figmog uses ` | styled_by / bound_to + nodes | nodes using a style id or bound to a variable id | +| `figmog vars [id]` | nodes + variables + variable_collections | variables: authoritative record if imported, else inferred value(s) + binding sites | +| `figmog import-variables ` | — | upsert variable/collection records from a variables export (see "Variables on a free plan") | + +Node ids accept both `12:34` and `12-34` forms everywhere. Auth is a +personal access token from `FIGMA_TOKEN`. Since `pull`/`watch` are the only +commands that touch the network, everything else works fine with no token +set as long as a store already exists. + +## How sync works + +`watch` polls the cheap `GET /v1/files/:key/meta` endpoint (Tier 3) every +interval and only spends a Tier-1 `GET /v1/files/:key` fetch — the +expensive, rate-limited call — when the file's content-modification +watermark actually changes. Every fetch, whether from `pull` or `watch`, +flows through fold's `KeyedStream` upsert-diff: re-syncing a byte-identical +node is a no-op through the whole pipeline (zero graph churn, zero index +writes), so a spurious trigger or a repeated `pull` costs one Tier-1 fetch +and nothing else. Since the November 2025 rate-limit overhaul, file +endpoints are capped around **10 requests/min on the free (Starter) +plan**, and there is no delta API — this polling design is what makes +that budget workable for an agent that wants to treat the file as live. + +## Variables on a free plan + +The Variables REST endpoints (`variables/local`, `variables/published`) +are Enterprise-only, so figmog never calls them. Variables are supported +through two complementary paths: + +**Path 1 — mirrored bindings + inference (always on, zero setup).** Every +variable-bound property in the file JSON carries a `boundVariables` +reference, and Figma bakes the resolved concrete value into the same node +next to it. figmog scans every node for these bindings at every depth and +inverts them into a `bound_to` index. `figmog vars` aggregates at read +time: for each variable id, every binding site (node + property path) and +the observed value(s) baked in there. This covers each variable's +**default-mode value**; values from a non-default mode appear only where a +frame explicitly overrides its mode. + +**Path 2 — authoritative import (optional).** `figmog import-variables +` upserts full-fidelity variable and collection records: +collections, modes (e.g. light/dark), per-mode values, descriptions, +scopes. It accepts two shapes: the Enterprise REST `variables/local` +response, or the JSON produced by the free-plan escape hatch below — the +Figma Plugin API can read local variables on **any** plan, run from +Figma's own developer console. `figmog vars` prefers an imported +(authoritative) record over inference whenever one exists. + +```js +// Figma → Plugins → Development → Open console, then paste: +(async () => { + const collections = await figma.variables.getLocalVariableCollectionsAsync(); + const variables = await figma.variables.getLocalVariablesAsync(); + const out = { variables: {}, variableCollections: {} }; + for (const c of collections) + out.variableCollections[c.id] = { id: c.id, name: c.name, modes: c.modes, defaultModeId: c.defaultModeId }; + for (const v of variables) + out.variables[v.id] = { id: v.id, name: v.name, resolvedType: v.resolvedType, + variableCollectionId: v.variableCollectionId, + valuesByMode: v.valuesByMode, description: v.description, scopes: v.scopes }; + console.log(JSON.stringify(out)); +})(); +// save the logged JSON, then: figmog import-variables vars.json +``` + +A third source — Figma's MCP servers, which expose `get_variable_defs` — +exists for paid seats only and is deliberately not built into figmog: the +desktop server needs a Dev/Full seat on a paid plan, the remote server +caps Starter users at 6 tool calls a *month*, and the tool is +selection-scoped rather than whole-collection. Anyone with a paid seat can +pipe its output into `import-variables` by hand; figmog itself never +depends on MCP. + +## Manual live check + +Not run in CI (needs a real `FIGMA_TOKEN` and a real file); this is how to +verify it by hand: + +```console +$ export FIGMA_TOKEN=figd_… +$ cargo run -p figmog -- pull +$ cargo run -p figmog -- status +$ cargo run -p figmog -- components +$ cargo run -p figmog -- search "pricing card" +$ cargo run -p figmog -- vars +``` + +`pull` pays the one Tier-1 fetch and prints a churn summary. Every command +after it — `status`, `components`, `search`, `vars` — is a local read: +acceptance is that they return in milliseconds, regardless of how large +the mirrored file is. + +## Limitations + +- **Variables** — inference (Path 1, always on) covers each variable's + default-mode value; a non-default mode's value is visible only where a + frame explicitly overrides that mode. Full per-mode fidelity requires + `import-variables` (Path 2). +- **No image renders** — figmog mirrors document structure and properties, + not rendered pixels; there's no `GET /v1/images` integration. +- **Style definitions are derived, not authoritative** — the file JSON's + `styles` map is metadata only (id, name, type), not the style's actual + properties. `figmog styles --values` derives a definition from one + consumer node's resolved properties (e.g. a text style's `TypeStyle` + from a TEXT node that uses it) — if a style currently has no consumers, + it has no derivable value. +- **Change detection is polling, not webhooks** — `watch` polls the cheap + `last_touched_at` metadata field on an interval; Figma's `FILE_UPDATE` + webhook is unavailable on the free plan and debounced up to 30 minutes + even where it exists, so polling a cheap Tier-3 endpoint is both + simpler and faster. +- **Instance overrides beyond the serialized subtree are not resolved** — + Figma serializes an INSTANCE's overridden children as ordinary nodes + under it, and those mirror like any other node, but overrides that + Figma doesn't materialize into the subtree are not reconstructed. diff --git a/examples/figmog/src/api.rs b/examples/figmog/src/api.rs index 3bf0af4..a0f646e 100644 --- a/examples/figmog/src/api.rs +++ b/examples/figmog/src/api.rs @@ -31,26 +31,35 @@ pub struct FileMetaResp { /// The two calls figmog makes. `file_meta` is Tier 3 (cheap, poll it); /// `file` is Tier 1 (expensive, call only on change). pub trait FigmaApi { + /// `GET /v1/files/:key/meta` — Tier 3, cheap enough to poll. fn file_meta(&self, key: &str) -> Result; + /// `GET /v1/files/:key` — Tier 1, the full document tree. fn file(&self, key: &str) -> Result; } pub(crate) fn parse_meta_response(v: &Value) -> Result { - let file = v.get("file").ok_or_else(|| ApiError::Parse("no `file` object".into()))?; + let file = v + .get("file") + .ok_or_else(|| ApiError::Parse("no `file` object".into()))?; let get = |k: &str| { file.get(k) .and_then(Value::as_str) .map(str::to_string) .ok_or_else(|| ApiError::Parse(format!("meta missing `{k}`"))) }; - Ok(FileMetaResp { name: get("name")?, last_touched_at: get("last_touched_at")? }) + Ok(FileMetaResp { + name: get("name")?, + last_touched_at: get("last_touched_at")?, + }) } pub(crate) fn error_from_status(status: u16, retry_after: Option<&str>, msg: String) -> ApiError { match status { 429 => ApiError::RateLimited { retry_after: Duration::from_secs( - retry_after.and_then(|s| s.trim().parse().ok()).unwrap_or(60), + retry_after + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(60), ), }, 401 | 403 => ApiError::Auth, @@ -65,6 +74,8 @@ pub struct UreqApi { } impl UreqApi { + /// Client against the real `api.figma.com`, authenticated with a + /// personal access token (`FIGMA_TOKEN`). pub fn new(token: String) -> Self { Self::with_base_url(token, "https://api.figma.com".into()) } @@ -76,9 +87,7 @@ impl UreqApi { fn get_json(&self, path: &str) -> Result { let url = format!("{}{}", self.base_url, path); match ureq::get(&url).set("X-Figma-Token", &self.token).call() { - Ok(resp) => resp - .into_json() - .map_err(|e| ApiError::Parse(e.to_string())), + Ok(resp) => resp.into_json().map_err(|e| ApiError::Parse(e.to_string())), Err(ureq::Error::Status(status, resp)) => { let retry = resp.header("Retry-After").map(str::to_string); let msg = resp.into_string().unwrap_or_default(); @@ -131,7 +140,10 @@ mod tests { error_from_status(429, None, String::new()), ApiError::RateLimited { retry_after } if retry_after == std::time::Duration::from_secs(60) )); - assert!(matches!(error_from_status(403, None, String::new()), ApiError::Auth)); + assert!(matches!( + error_from_status(403, None, String::new()), + ApiError::Auth + )); assert!(matches!( error_from_status(500, None, "boom".into()), ApiError::Http { status: 500, .. } diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 4c34011..66c8a40 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -64,19 +64,41 @@ enum Cmd { /// List pages. Pages, /// Subtree outline (default: whole document). - Tree { id: Option, #[arg(long)] depth: Option }, + Tree { + id: Option, + #[arg(long)] + depth: Option, + }, /// Full raw JSON of one node. - Get { id: String, #[arg(long)] children: bool }, + Get { + id: String, + #[arg(long)] + children: bool, + }, /// Nodes by type, optionally within one page. - Find { #[arg(long = "type")] node_type: String, #[arg(long)] page: Option }, + Find { + #[arg(long = "type")] + node_type: String, + #[arg(long)] + page: Option, + }, /// BM25 search over layer names and text content. - Search { query: String, #[arg(short = 'n', long, default_value = "10")] limit: usize }, + Search { + query: String, + #[arg(short = 'n', long, default_value = "10")] + limit: usize, + }, /// Instances of a component (by node id, key, or name). Instances { target: String }, /// Design-system inventory: sets, variant axes, standalone components. Components, /// Styles with usage counts; --values derives definitions from consumers. - Styles { #[arg(long = "type")] style_type: Option, #[arg(long)] values: bool }, + Styles { + #[arg(long = "type")] + style_type: Option, + #[arg(long)] + values: bool, + }, /// Nodes using a style id or bound to a variable id. Uses { id: String }, /// Variables: authoritative if imported, else inferred from bindings. @@ -85,6 +107,8 @@ enum Cmd { ImportVariables { path: PathBuf }, } +/// Parse `argv`, dispatch, and return the process exit code (0 on success, +/// 1 with a one-line `figmog: ` on stderr otherwise). pub fn run() -> i32 { let cli = Cli::parse(); match dispatch(cli) { @@ -99,7 +123,11 @@ pub fn run() -> i32 { fn dispatch(cli: Cli) -> Result<(), String> { let db = resolve_db(&cli)?; match cli.cmd { - Cmd::Pull { file, from_file, fresh } => cmd_pull(&db, file, from_file, fresh, cli.json), + Cmd::Pull { + file, + from_file, + fresh, + } => cmd_pull(&db, file, from_file, fresh, cli.json), Cmd::Watch { file, interval } => cmd_watch(&db, file, interval, cli.json), Cmd::ImportVariables { path } => cmd_import_variables(&db, path, cli.json), other => { @@ -115,26 +143,37 @@ fn dispatch(cli: Cli) -> Result<(), String> { Cmd::Status => st.rtx(|((nodes, _, _, _, _, _, _), _, _, _, _, _, meta)| { cmd_status(&nodes, &meta, json) }), - Cmd::Pages => { - st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| cmd_pages(&nodes, &by_type, json)) + Cmd::Pages => st + .rtx(|((nodes, _, _, _, _, _, by_type), ..)| cmd_pages(&nodes, &by_type, json)), + Cmd::Tree { id, depth } => { + st.rtx(|((nodes, children, _, _, _, _, by_type), ..)| { + cmd_tree(&nodes, &children, &by_type, id, depth, json) + }) } - Cmd::Tree { id, depth } => st.rtx(|((nodes, children, _, _, _, _, by_type), ..)| { - cmd_tree(&nodes, &children, &by_type, id, depth, json) - }), - Cmd::Get { id, children: with_children } => st.rtx(|((nodes, children, ..), ..)| { + Cmd::Get { + id, + children: with_children, + } => st.rtx(|((nodes, children, ..), ..)| { cmd_get(&nodes, &children, id, with_children, json) }), Cmd::Find { node_type, page } => st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| { cmd_find(&nodes, &by_type, node_type, page, json) }), - Cmd::Search { query, limit } => { - st.rtx(|((nodes, _, text, ..), ..)| cmd_search(&nodes, &text, query, limit, json)) - } - Cmd::Instances { target } => { - st.rtx(|((nodes, _, _, instances_of, ..), components, component_sets, ..)| { - cmd_instances(&nodes, &instances_of, &components, &component_sets, target, json) - }) - } + Cmd::Search { query, limit } => st.rtx(|((nodes, _, text, ..), ..)| { + cmd_search(&nodes, &text, query, limit, json) + }), + Cmd::Instances { target } => st.rtx( + |((nodes, _, _, instances_of, ..), components, component_sets, ..)| { + cmd_instances( + &nodes, + &instances_of, + &components, + &component_sets, + target, + json, + ) + }, + ), Cmd::Components => st.rtx(|((nodes, ..), components, component_sets, ..)| { cmd_components(&component_sets, &components, &nodes, json) }), @@ -146,9 +185,11 @@ fn dispatch(cli: Cli) -> Result<(), String> { Cmd::Uses { id } => st.rtx(|((nodes, _, _, _, styled_by, bound_to, _), ..)| { cmd_uses(&nodes, &styled_by, &bound_to, id, json) }), - Cmd::Vars { id } => st.rtx(|((nodes, ..), _, _, _, variables, variable_collections, _)| { - cmd_vars(&nodes, &variables, &variable_collections, id, json) - }), + Cmd::Vars { id } => st.rtx( + |((nodes, ..), _, _, _, variables, variable_collections, _)| { + cmd_vars(&nodes, &variables, &variable_collections, id, json) + }, + ), Cmd::Pull { .. } | Cmd::Watch { .. } | Cmd::ImportVariables { .. } => { unreachable!("handled above") } @@ -169,14 +210,20 @@ const CURRENT_FILE: &str = ".figmog/current"; fn resolve_db(cli: &Cli) -> Result { if let Some(path) = &cli.db { - return Ok(Db { path: path.clone(), key: None }); + return Ok(Db { + path: path.clone(), + key: None, + }); } // pull/watch with an explicit file ref establish (and remember) the key. if let Cmd::Pull { file: Some(f), .. } | Cmd::Watch { file: Some(f), .. } = &cli.cmd { let key = parse_file_ref(f).ok_or_else(|| format!("not a Figma file key or URL: {f}"))?; write_current(&key)?; - return Ok(Db { path: db_path_for(&key), key: Some(key) }); + return Ok(Db { + path: db_path_for(&key), + key: Some(key), + }); } let key = std::fs::read_to_string(CURRENT_FILE) @@ -186,7 +233,10 @@ fn resolve_db(cli: &Cli) -> Result { if key.is_empty() { return Err("no mirror here — run `figmog pull ` first".into()); } - Ok(Db { path: db_path_for(&key), key: Some(key) }) + Ok(Db { + path: db_path_for(&key), + key: Some(key), + }) } fn db_path_for(key: &str) -> PathBuf { @@ -199,7 +249,10 @@ fn write_current(key: &str) -> Result<(), String> { } fn now_ms() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 } // ---- engine commands ---- @@ -227,7 +280,8 @@ fn do_pull( Some(path) => { let content = std::fs::read_to_string(&path) .map_err(|e| format!("reading {}: {e}", path.display()))?; - serde_json::from_str(&content).map_err(|e| format!("parsing {}: {e}", path.display()))? + serde_json::from_str(&content) + .map_err(|e| format!("parsing {}: {e}", path.display()))? } None => { let key = db @@ -253,12 +307,19 @@ fn do_pull( }); let churn = sync(&mut st, &prior, &flattened, now_ms()); - Ok((churn, flattened.file.name.clone(), flattened.file.version.clone())) + Ok(( + churn, + flattened.file.name.clone(), + flattened.file.version.clone(), + )) } fn print_churn(churn: &Churn, name: &str, version: &str, json: bool) -> Result<(), String> { if json { - println!("{}", serde_json::to_string(churn).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string(churn).map_err(|e| e.to_string())? + ); } else { println!( "synced {name} v{version}: +{} ~{} -{} (={} unchanged)", @@ -337,10 +398,10 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result } fn cmd_import_variables(db: &Db, path: PathBuf, json: bool) -> Result<(), String> { - let content = std::fs::read_to_string(&path) - .map_err(|e| format!("reading {}: {e}", path.display()))?; - let v: Value = serde_json::from_str(&content) - .map_err(|e| format!("parsing {}: {e}", path.display()))?; + let content = + std::fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?; + let v: Value = + serde_json::from_str(&content).map_err(|e| format!("parsing {}: {e}", path.display()))?; let recs = crate::vars::parse_variables_export(&v).map_err(|e| e.to_string())?; let mut st = crate::open_store!(&db.path); @@ -350,9 +411,15 @@ fn cmd_import_variables(db: &Db, path: PathBuf, json: bool) -> Result<(), String } }); - let imported = recs.iter().filter(|(id, _)| matches!(id, Id::Variable(_))).count(); + let imported = recs + .iter() + .filter(|(id, _)| matches!(id, Id::Variable(_))) + .count(); if json { - println!("{}", serde_json::to_string(&json!({"imported": imported})).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string(&json!({"imported": imported})).map_err(|e| e.to_string())? + ); } else { println!("imported {imported} variables"); } @@ -385,7 +452,10 @@ fn cmd_status( }); println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { - println!("{} v{} — {count} nodes (last modified {})", m.name, m.version, m.last_modified); + println!( + "{} v{} — {count} nodes (last modified {})", + m.name, m.version, m.last_modified + ); } Ok(()) } @@ -405,8 +475,14 @@ fn cmd_pages( pages.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); if json { - let arr: Vec = pages.iter().map(|(_, id, name)| json!({"id": id, "name": name})).collect(); - println!("{}", serde_json::to_string(&arr).map_err(|e| e.to_string())?); + let arr: Vec = pages + .iter() + .map(|(_, id, name)| json!({"id": id, "name": name})) + .collect(); + println!( + "{}", + serde_json::to_string(&arr).map_err(|e| e.to_string())? + ); } else { for (_, id, name) in &pages { println!("{name} {id}"); @@ -440,7 +516,12 @@ fn build_tree( } } } - TreeNode { id: node.id.clone(), name: node.name.clone(), node_type: node.node_type.clone(), children: kids } + TreeNode { + id: node.id.clone(), + name: node.name.clone(), + node_type: node.node_type.clone(), + children: kids, + } } fn tree_to_json(t: &TreeNode) -> Value { @@ -453,7 +534,13 @@ fn tree_to_json(t: &TreeNode) -> Value { } fn print_tree_human(t: &TreeNode, indent: usize) { - println!("{}{} [{}] {}", " ".repeat(indent), t.name, t.node_type, t.id); + println!( + "{}{} [{}] {}", + " ".repeat(indent), + t.name, + t.node_type, + t.id + ); for c in &t.children { print_tree_human(c, indent + 1); } @@ -472,14 +559,21 @@ fn cmd_tree( None => { let mut docs = by_type.search(&"DOCUMENT".to_string()); docs.sort(); - docs.into_iter().next().ok_or_else(|| "no DOCUMENT node in the mirror".to_string())? + docs.into_iter() + .next() + .ok_or_else(|| "no DOCUMENT node in the mirror".to_string())? } }; - let root = nodes.get(&start).ok_or_else(|| format!("no node {start} in the mirror"))?; + let root = nodes + .get(&start) + .ok_or_else(|| format!("no node {start} in the mirror"))?; let tree = build_tree(nodes, children, &root, depth); if json { - println!("{}", serde_json::to_string(&tree_to_json(&tree)).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string(&tree_to_json(&tree)).map_err(|e| e.to_string())? + ); } else { print_tree_human(&tree, 0); } @@ -494,7 +588,9 @@ fn cmd_get( _json: bool, ) -> Result<(), String> { let id = normalize_node_id(&id); - let node = nodes.get(&id).ok_or_else(|| format!("no node {id} in the mirror"))?; + let node = nodes + .get(&id) + .ok_or_else(|| format!("no node {id} in the mirror"))?; let mut value: Value = serde_json::from_str(&node.raw).map_err(|e| e.to_string())?; if with_children { @@ -503,7 +599,9 @@ fn cmd_get( let kids: Vec = edges .into_iter() .filter_map(|(_, child_id)| { - nodes.get(&child_id).map(|n| json!({"id": n.id, "name": n.name, "type": n.node_type})) + nodes + .get(&child_id) + .map(|n| json!({"id": n.id, "name": n.name, "type": n.node_type})) }) .collect(); if let Some(obj) = value.as_object_mut() { @@ -512,7 +610,10 @@ fn cmd_get( } // Get's output is always JSON, whether or not --json was passed. - println!("{}", serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string_pretty(&value).map_err(|e| e.to_string())? + ); Ok(()) } @@ -540,7 +641,10 @@ fn cmd_find( .iter() .map(|(id, name, page_id)| json!({"id": id, "name": name, "page_id": page_id})) .collect(); - println!("{}", serde_json::to_string(&arr).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string(&arr).map_err(|e| e.to_string())? + ); } else { for (id, name, page_id) in &rows { println!("{id} {name} ({page_id})"); @@ -564,7 +668,10 @@ fn cmd_search( .iter() .filter_map(|hit| { let node = nodes.get(&hit.val)?; - let snippet = node.text.as_ref().map(|t| t.chars().take(80).collect::()); + let snippet = node + .text + .as_ref() + .map(|t| t.chars().take(80).collect::()); Some(json!({ "id": node.id, "score": hit.score, @@ -577,7 +684,10 @@ fn cmd_search( .collect(); if json { - println!("{}", serde_json::to_string(&rows).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string(&rows).map_err(|e| e.to_string())? + ); } else { for row in &rows { println!( @@ -621,13 +731,21 @@ fn resolve_component_ids( if !set_ids.is_empty() { ids = components .iter() - .filter(|(_, c)| c.component_set_id.as_deref().is_some_and(|s| set_ids.iter().any(|sid| sid == s))) + .filter(|(_, c)| { + c.component_set_id + .as_deref() + .is_some_and(|s| set_ids.iter().any(|sid| sid == s)) + }) .map(|(id, _)| id) .collect(); return ids; } - components.iter().filter(|(_, c)| c.name == target).map(|(id, _)| id).collect() + components + .iter() + .filter(|(_, c)| c.name == target) + .map(|(id, _)| id) + .collect() } fn cmd_instances( @@ -653,7 +771,10 @@ fn cmd_instances( .collect(); if json { - println!("{}", serde_json::to_string(&rows).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string(&rows).map_err(|e| e.to_string())? + ); } else { for row in &rows { println!( @@ -711,7 +832,10 @@ fn cmd_components( let out = json!({"sets": sets_json, "components": standalone}); if json { - println!("{}", serde_json::to_string(&out).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string(&out).map_err(|e| e.to_string())? + ); } else { for s in &sets_json { println!( @@ -721,7 +845,11 @@ fn cmd_components( ); } for c in &standalone { - println!("{} {}", c["node_id"].as_str().unwrap_or_default(), c["name"].as_str().unwrap_or_default()); + println!( + "{} {}", + c["node_id"].as_str().unwrap_or_default(), + c["name"].as_str().unwrap_or_default() + ); } } Ok(()) @@ -766,7 +894,10 @@ fn cmd_styles( .collect(); if json { - println!("{}", serde_json::to_string(&out).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string(&out).map_err(|e| e.to_string())? + ); } else { for row in &out { println!( @@ -801,7 +932,10 @@ fn cmd_uses( .collect(); if json { - println!("{}", serde_json::to_string(&rows).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string(&rows).map_err(|e| e.to_string())? + ); } else { for row in &rows { println!( @@ -824,8 +958,10 @@ fn cmd_vars( ) -> Result<(), String> { let owned_nodes: Vec = nodes.iter().map(|(_, n)| n).collect(); let inferred = crate::vars::infer_from_nodes(owned_nodes.iter()); - let mut inferred_by_id: HashMap = - inferred.into_iter().map(|u| (u.variable_id.clone(), u)).collect(); + let mut inferred_by_id: HashMap = inferred + .into_iter() + .map(|u| (u.variable_id.clone(), u)) + .collect(); let mut all_ids: BTreeSet = inferred_by_id.keys().cloned().collect(); all_ids.extend(variables.iter().map(|(k, _)| k)); @@ -837,9 +973,7 @@ fn cmd_vars( .iter() .map(|vid| { let usage = inferred_by_id.remove(vid); - let (sites, observed) = usage - .map(|u| (u.sites, u.observed)) - .unwrap_or_default(); + let (sites, observed) = usage.map(|u| (u.sites, u.observed)).unwrap_or_default(); if let Some(var) = variables.get(vid) { let collection = variable_collections.get(&var.collection_id); @@ -875,7 +1009,10 @@ fn cmd_vars( .collect(); if json { - println!("{}", serde_json::to_string(&rows).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string(&rows).map_err(|e| e.to_string())? + ); } else { for row in &rows { println!( diff --git a/examples/figmog/src/flatten.rs b/examples/figmog/src/flatten.rs index 9b42d8c..a2f23e5 100644 --- a/examples/figmog/src/flatten.rs +++ b/examples/figmog/src/flatten.rs @@ -24,6 +24,7 @@ pub struct Flattened { pub file: FileInfo, } +/// Errors from [`flatten_file`]. #[derive(Debug, thiserror::Error)] pub enum FlattenError { #[error("missing field: {0}")] @@ -35,9 +36,12 @@ pub fn flatten_file(resp: &Value) -> Result { let file = FileInfo { name: str_field(resp, "name").ok_or(FlattenError::Missing("name"))?, version: str_field(resp, "version").ok_or(FlattenError::Missing("version"))?, - last_modified: str_field(resp, "lastModified").ok_or(FlattenError::Missing("lastModified"))?, + last_modified: str_field(resp, "lastModified") + .ok_or(FlattenError::Missing("lastModified"))?, }; - let document = resp.get("document").ok_or(FlattenError::Missing("document"))?; + let document = resp + .get("document") + .ok_or(FlattenError::Missing("document"))?; let mut recs = Vec::new(); walk(document, None, 0, None, &mut recs); @@ -132,7 +136,8 @@ fn walk( text: str_field(node, "characters"), component_id: str_field(node, "componentId"), component_properties: sorted_map(node.get("componentProperties"), |v| { - v.get("value").map(|val| serde_json::to_string(val).expect("Value serializes")) + v.get("value") + .map(|val| serde_json::to_string(val).expect("Value serializes")) }), property_definitions: node .get("componentPropertyDefinitions") diff --git a/examples/figmog/src/store.rs b/examples/figmog/src/store.rs index a05d1cf..b5e98fe 100644 --- a/examples/figmog/src/store.rs +++ b/examples/figmog/src/store.rs @@ -15,6 +15,7 @@ use crate::model::{FileMeta, Id, NodeRec, Rec}; // ---- pipeline branch functions (pure; fold requires determinism) ---- +/// Feeds the `nodes` table: keep only `Rec::Node` records, keyed by node id. pub fn node_only(d: &Keyed) -> Option> { match &d.val { Rec::Node(n) => Some(Keyed::new(n.id.clone(), n.clone())), @@ -22,11 +23,15 @@ pub fn node_only(d: &Keyed) -> Option> { } } +/// Feeds the `children` multimap: parent id -> (child_index, child id). +/// The document root has no `parent_id` and so contributes no edge. pub fn child_edge(d: &Keyed) -> Option> { let parent = d.val.parent_id.clone()?; Some(Keyed::new(parent, (d.val.child_index, d.val.id.clone()))) } +/// Feeds the `text` BM25 sink: node name plus (if TEXT) its `characters`, +/// keyed by node id. Nodes with no searchable text drop out. pub fn text_doc(d: &Keyed) -> Option> { let mut s = d.val.name.clone(); if let Some(t) = &d.val.text { @@ -37,6 +42,8 @@ pub fn text_doc(d: &Keyed) -> Option> { (!s.is_empty()).then(|| Keyed::new(d.val.id.clone(), s)) } +/// Feeds the `instances_of` inverted index: node id -> the component id it +/// instances (INSTANCE nodes only). pub fn instance_edge(d: &Keyed) -> Option> { d.val .component_id @@ -44,6 +51,8 @@ pub fn instance_edge(d: &Keyed) -> Option .map(|c| Keyed::new(d.val.id.clone(), c)) } +/// Feeds the `styled_by` inverted index: node id -> each style id it +/// references (fill, text, effect, grid). pub fn style_edges(d: &Keyed) -> Vec> { d.val .style_refs @@ -52,6 +61,9 @@ pub fn style_edges(d: &Keyed) -> Vec> { .collect() } +/// Feeds the `bound_to` inverted index: node id -> each variable id bound +/// somewhere on it (one edge per distinct variable, even if bound at +/// multiple property paths). pub fn variable_edges(d: &Keyed) -> Vec> { let mut edges: Vec<_> = d .val @@ -63,10 +75,15 @@ pub fn variable_edges(d: &Keyed) -> Vec> edges } +/// Feeds the `by_type` inverted index: node id -> its Figma node type. pub fn type_edge(d: &Keyed) -> Keyed { Keyed::new(d.val.id.clone(), d.val.node_type.clone()) } +/// Defines a `fn(&Keyed) -> Option>` that keeps +/// only the matching `Id`/`Rec` variant pair, keyed by its own id — one such +/// branch per non-node table (`components`, `component_sets`, `styles`, +/// `variables`, `variable_collections`). macro_rules! rec_branch { ($name:ident, $idvar:ident, $recvar:ident, $rec:ty) => { pub fn $name(d: &Keyed) -> Option> { @@ -77,13 +94,30 @@ macro_rules! rec_branch { } }; } -rec_branch!(component_only, Component, Component, crate::model::ComponentRec); -rec_branch!(component_set_only, ComponentSet, ComponentSet, crate::model::ComponentSetRec); +rec_branch!( + component_only, + Component, + Component, + crate::model::ComponentRec +); +rec_branch!( + component_set_only, + ComponentSet, + ComponentSet, + crate::model::ComponentSetRec +); rec_branch!(style_only, Style, Style, crate::model::StyleRec); rec_branch!(variable_only, Variable, Variable, crate::model::VariableRec); -rec_branch!(collection_only, VariableCollection, VariableCollection, crate::model::VariableCollectionRec); +rec_branch!( + collection_only, + VariableCollection, + VariableCollection, + crate::model::VariableCollectionRec +); -// key is u8(0), not (): () postcard-encodes to zero bytes and the store forbids empty keys +/// Feeds the `meta` table: the single [`FileMeta`] row, keyed by `0u8` +/// (not `()`: `()` postcard-encodes to zero bytes and the store forbids +/// empty keys). pub fn meta_only(d: &Keyed) -> Option> { match &d.val { Rec::Meta(m) => Some(Keyed::new(0u8, m.clone())), @@ -101,19 +135,46 @@ macro_rules! figmog_pipeline { $crate::store::node_only, ( terminal::Table::new("nodes"), - FilterMap::new($crate::store::child_edge, terminal::Multimap::new("children")), + FilterMap::new( + $crate::store::child_edge, + terminal::Multimap::new("children"), + ), FilterMap::new($crate::store::text_doc, terminal::search::Bm25::new("text")), - FilterMap::new($crate::store::instance_edge, terminal::InvertedIndex::new("instances_of")), - FlatMap::new($crate::store::style_edges, terminal::InvertedIndex::new("styled_by")), - FlatMap::new($crate::store::variable_edges, terminal::InvertedIndex::new("bound_to")), - Map::new($crate::store::type_edge, terminal::InvertedIndex::new("by_type")), + FilterMap::new( + $crate::store::instance_edge, + terminal::InvertedIndex::new("instances_of"), + ), + FlatMap::new( + $crate::store::style_edges, + terminal::InvertedIndex::new("styled_by"), + ), + FlatMap::new( + $crate::store::variable_edges, + terminal::InvertedIndex::new("bound_to"), + ), + Map::new( + $crate::store::type_edge, + terminal::InvertedIndex::new("by_type"), + ), ), ), - FilterMap::new($crate::store::component_only, terminal::Table::new("components")), - FilterMap::new($crate::store::component_set_only, terminal::Table::new("component_sets")), + FilterMap::new( + $crate::store::component_only, + terminal::Table::new("components"), + ), + FilterMap::new( + $crate::store::component_set_only, + terminal::Table::new("component_sets"), + ), FilterMap::new($crate::store::style_only, terminal::Table::new("styles")), - FilterMap::new($crate::store::variable_only, terminal::Table::new("variables")), - FilterMap::new($crate::store::collection_only, terminal::Table::new("variable_collections")), + FilterMap::new( + $crate::store::variable_only, + terminal::Table::new("variables"), + ), + FilterMap::new( + $crate::store::collection_only, + terminal::Table::new("variable_collections"), + ), FilterMap::new($crate::store::meta_only, terminal::Table::new("meta")), ) }}; @@ -188,7 +249,12 @@ pub fn sync>>( pub fn collect_sweepable( nodes: &fold::pipeline::terminal::TableReader<'_, R, String, NodeRec>, components: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::ComponentRec>, - component_sets: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::ComponentSetRec>, + component_sets: &fold::pipeline::terminal::TableReader< + '_, + R, + String, + crate::model::ComponentSetRec, + >, styles: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::StyleRec>, ) -> BTreeSet { let mut out = BTreeSet::new(); diff --git a/examples/figmog/src/vars.rs b/examples/figmog/src/vars.rs index b85a0f8..c937a3f 100644 --- a/examples/figmog/src/vars.rs +++ b/examples/figmog/src/vars.rs @@ -1,5 +1,5 @@ //! Variables: authoritative import parsing (this module also hosts the -//! free-plan inference in `infer`). +//! free-plan inference in [`infer_from_nodes`]). use std::collections::BTreeMap; @@ -8,6 +8,7 @@ use serde_json::Value; use crate::model::{Id, NodeRec, Rec, VariableCollectionRec, VariableRec}; +/// Errors from [`parse_variables_export`]. #[derive(Debug, thiserror::Error)] pub enum ImportError { #[error("unrecognized variables export shape: {0}")] @@ -28,7 +29,12 @@ pub fn parse_variables_export(v: &Value) -> Result, ImportError> .and_then(Value::as_object) .ok_or_else(|| ImportError::Shape("missing `variableCollections` object".into()))?; - let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); + let s = |v: &Value, k: &str| { + v.get(k) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; let mut recs = Vec::new(); let sorted: BTreeMap<_, _> = collections.iter().collect(); @@ -57,7 +63,10 @@ pub fn parse_variables_export(v: &Value) -> Result, ImportError> .map(|m| { m.iter() .map(|(mode, val)| { - (mode.clone(), serde_json::to_string(val).expect("Value serializes")) + ( + mode.clone(), + serde_json::to_string(val).expect("Value serializes"), + ) }) .collect() }) @@ -66,7 +75,12 @@ pub fn parse_variables_export(v: &Value) -> Result, ImportError> let scopes: Vec = var .get("scopes") .and_then(Value::as_array) - .map(|xs| xs.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .map(|xs| { + xs.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) .unwrap_or_default(); recs.push(( Id::Variable(id.clone()), @@ -111,7 +125,9 @@ pub fn infer_from_nodes<'a>(nodes: impl Iterator) -> Vec(nodes: impl Iterator) -> Vec) -> Self { - Watcher { last_seen, backoff: BACKOFF_START } + Watcher { + last_seen, + backoff: BACKOFF_START, + } } + /// Poll `file_meta` once and classify the result. Never fetches the + /// file itself — that's the caller's job on [`Tick::Changed`]. pub fn tick(&mut self, api: &dyn FigmaApi, key: &str) -> Tick { match api.file_meta(key) { Ok(meta) => { @@ -41,7 +46,9 @@ impl Watcher { Tick::Unchanged } else { self.last_seen = Some(meta.last_touched_at.clone()); - Tick::Changed { last_touched_at: meta.last_touched_at } + Tick::Changed { + last_touched_at: meta.last_touched_at, + } } } Err(ApiError::RateLimited { retry_after }) => Tick::Wait { after: retry_after }, @@ -71,7 +78,10 @@ mod tests { } impl FigmaApi for Script { fn file_meta(&self, _key: &str) -> Result { - self.0.borrow_mut().pop().expect("unexpected extra file_meta call") + self.0 + .borrow_mut() + .pop() + .expect("unexpected extra file_meta call") } fn file(&self, _key: &str) -> Result { panic!("watcher must never fetch the file itself"); @@ -79,7 +89,10 @@ mod tests { } fn meta(t: &str) -> Result { - Ok(FileMetaResp { name: "F".into(), last_touched_at: t.into() }) + Ok(FileMetaResp { + name: "F".into(), + last_touched_at: t.into(), + }) } #[test] @@ -104,11 +117,15 @@ mod tests { #[test] fn rate_limit_uses_retry_after() { let api = Script::new(vec![ - Err(ApiError::RateLimited { retry_after: Duration::from_secs(30) }), + Err(ApiError::RateLimited { + retry_after: Duration::from_secs(30), + }), meta("t1"), ]); let mut w = Watcher::new(Some("t1".into())); - assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(30))); + assert!( + matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(30)) + ); assert!(matches!(w.tick(&api, "k"), Tick::Unchanged)); } @@ -122,17 +139,26 @@ mod tests { Err(ApiError::Network("down".into())), ]); let mut w = Watcher::new(Some("t1".into())); - assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(5))); - assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(10))); - assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(20))); + assert!( + matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(5)) + ); + assert!( + matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(10)) + ); + assert!( + matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(20)) + ); assert!(matches!(w.tick(&api, "k"), Tick::Unchanged)); - assert!(matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(5))); + assert!( + matches!(w.tick(&api, "k"), Tick::Wait { after } if after == Duration::from_secs(5)) + ); } #[test] fn backoff_caps_at_five_minutes() { - let mut responses: Vec> = - (0..10).map(|_| Err(ApiError::Network("down".into()))).collect(); + let mut responses: Vec> = (0..10) + .map(|_| Err(ApiError::Network("down".into()))) + .collect(); responses.push(meta("t1")); let api = Script::new(responses); let mut w = Watcher::new(Some("t1".into())); diff --git a/examples/figmog/tests/cli.rs b/examples/figmog/tests/cli.rs index 5e9ca66..f34f769 100644 --- a/examples/figmog/tests/cli.rs +++ b/examples/figmog/tests/cli.rs @@ -9,11 +9,21 @@ use assert_cmd::Command; fn fixture_db() -> (tempfile::TempDir, String) { let dir = tempfile::tempdir().unwrap(); let response = dir.path().join("resp.json"); - std::fs::write(&response, serde_json::to_string(&common::fixture_v1()).unwrap()).unwrap(); + std::fs::write( + &response, + serde_json::to_string(&common::fixture_v1()).unwrap(), + ) + .unwrap(); let db = dir.path().join("db").display().to_string(); Command::cargo_bin("figmog") .unwrap() - .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db]) + .args([ + "pull", + "--from-file", + response.to_str().unwrap(), + "--db", + &db, + ]) .assert() .success(); (dir, db) @@ -23,18 +33,40 @@ fn fixture_db() -> (tempfile::TempDir, String) { fn pull_from_file_reports_churn_and_is_idempotent() { let dir = tempfile::tempdir().unwrap(); let response = dir.path().join("resp.json"); - std::fs::write(&response, serde_json::to_string(&common::fixture_v1()).unwrap()).unwrap(); + std::fs::write( + &response, + serde_json::to_string(&common::fixture_v1()).unwrap(), + ) + .unwrap(); let db = dir.path().join("db").display().to_string(); - let out = Command::cargo_bin("figmog").unwrap() - .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db, "--json"]) - .assert().success(); + let out = Command::cargo_bin("figmog") + .unwrap() + .args([ + "pull", + "--from-file", + response.to_str().unwrap(), + "--db", + &db, + "--json", + ]) + .assert() + .success(); let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); assert_eq!(v["added"], 18); - let out = Command::cargo_bin("figmog").unwrap() - .args(["pull", "--from-file", response.to_str().unwrap(), "--db", &db, "--json"]) - .assert().success(); + let out = Command::cargo_bin("figmog") + .unwrap() + .args([ + "pull", + "--from-file", + response.to_str().unwrap(), + "--db", + &db, + "--json", + ]) + .assert() + .success(); let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); assert_eq!(v["unchanged"], 18); assert_eq!(v["added"], 0); @@ -44,9 +76,12 @@ fn pull_from_file_reports_churn_and_is_idempotent() { fn status_pages_tree_get_find() { let (_dir, db) = fixture_db(); let run = |args: &[&str]| { - let out = Command::cargo_bin("figmog").unwrap() - .args(args).args(["--db", &db, "--json"]) - .assert().success(); + let out = Command::cargo_bin("figmog") + .unwrap() + .args(args) + .args(["--db", &db, "--json"]) + .assert() + .success(); serde_json::from_slice::(&out.get_output().stdout).unwrap() }; @@ -81,7 +116,8 @@ fn status_pages_tree_get_find() { #[test] fn get_unknown_node_fails_cleanly() { let (_dir, db) = fixture_db(); - Command::cargo_bin("figmog").unwrap() + Command::cargo_bin("figmog") + .unwrap() .args(["get", "99:99", "--db", &db]) .assert() .failure() @@ -92,9 +128,12 @@ fn get_unknown_node_fails_cleanly() { fn search_instances_components_styles_uses_vars() { let (_dir, db) = fixture_db(); let run = |args: &[&str]| { - let out = Command::cargo_bin("figmog").unwrap() - .args(args).args(["--db", &db, "--json"]) - .assert().success(); + let out = Command::cargo_bin("figmog") + .unwrap() + .args(args) + .args(["--db", &db, "--json"]) + .assert() + .success(); serde_json::from_slice::(&out.get_output().stdout).unwrap() }; @@ -114,7 +153,10 @@ fn search_instances_components_styles_uses_vars() { assert_eq!(sets[0]["name"], "Button"); assert_eq!(sets[0]["variants"].as_array().unwrap().len(), 2); let axes = &sets[0]["property_definitions"]; - assert_eq!(axes["Size"]["variantOptions"], serde_json::json!(["Large", "Small"])); + assert_eq!( + axes["Size"]["variantOptions"], + serde_json::json!(["Large", "Small"]) + ); assert_eq!(comps["components"].as_array().unwrap().len(), 1); // standalone only assert_eq!(comps["components"][0]["name"], "IconStar"); @@ -144,16 +186,24 @@ fn import_variables_upgrades_vars_to_authoritative() { let export = dir.path().join("vars.json"); std::fs::write(&export, include_str!("fixtures/variables-export.json")).unwrap(); - Command::cargo_bin("figmog").unwrap() + Command::cargo_bin("figmog") + .unwrap() .args(["import-variables", export.to_str().unwrap(), "--db", &db]) - .assert().success(); + .assert() + .success(); - let out = Command::cargo_bin("figmog").unwrap() + let out = Command::cargo_bin("figmog") + .unwrap() .args(["vars", "--db", &db, "--json"]) - .assert().success(); + .assert() + .success(); let vars: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); - let v100 = vars.as_array().unwrap().iter() - .find(|v| v["variable_id"] == "VariableID:100").unwrap(); + let v100 = vars + .as_array() + .unwrap() + .iter() + .find(|v| v["variable_id"] == "VariableID:100") + .unwrap(); assert_eq!(v100["source"], "imported"); assert_eq!(v100["name"], "color/surface/primary"); assert_eq!(v100["collection"], "colors"); diff --git a/examples/figmog/tests/flatten.rs b/examples/figmog/tests/flatten.rs index ec34896..a832076 100644 --- a/examples/figmog/tests/flatten.rs +++ b/examples/figmog/tests/flatten.rs @@ -29,11 +29,16 @@ fn walks_the_whole_tree() { let node_ids: Vec<&str> = out .recs .iter() - .filter_map(|(k, _)| match k { Id::Node(n) => Some(n.as_str()), _ => None }) + .filter_map(|(k, _)| match k { + Id::Node(n) => Some(n.as_str()), + _ => None, + }) .collect(); assert_eq!( node_ids, - ["0:0", "0:1", "1:1", "1:2", "1:3", "1:9", "0:2", "2:1", "2:2", "2:3", "3:1", "0:3"], + [ + "0:0", "0:1", "1:1", "1:2", "1:3", "1:9", "0:2", "2:1", "2:2", "2:3", "3:1", "0:3" + ], "depth-first order, all 12 nodes" ); assert_eq!(out.file.name, "Fixture"); @@ -124,7 +129,10 @@ fn property_definitions_on_set_and_component() { let set = node(&out.recs, "2:1"); let defs: serde_json::Value = serde_json::from_str(set.property_definitions.as_deref().unwrap()).unwrap(); - assert_eq!(defs["Size"]["variantOptions"], serde_json::json!(["Large", "Small"])); + assert_eq!( + defs["Size"]["variantOptions"], + serde_json::json!(["Large", "Small"]) + ); // standalone component without the field -> None assert_eq!(node(&out.recs, "3:1").property_definitions, None); } @@ -132,8 +140,14 @@ fn property_definitions_on_set_and_component() { #[test] fn style_refs_extracted_sorted() { let out = flatten_file(&common::fixture_v1()).unwrap(); - assert_eq!(node(&out.recs, "1:1").style_refs, vec![("fill".to_string(), "S:1".to_string())]); - assert_eq!(node(&out.recs, "1:2").style_refs, vec![("text".to_string(), "S:2".to_string())]); + assert_eq!( + node(&out.recs, "1:1").style_refs, + vec![("fill".to_string(), "S:1".to_string())] + ); + assert_eq!( + node(&out.recs, "1:2").style_refs, + vec![("text".to_string(), "S:2".to_string())] + ); } #[test] @@ -158,13 +172,22 @@ fn envelope_maps_flattened() { assert_eq!(c.component_set_id.as_deref(), Some("2:1")); assert!(!c.remote); - let styles: Vec = out.recs.iter() - .filter_map(|(_, r)| match r { Rec::Style(s) => Some(s.clone()), _ => None }) + let styles: Vec = out + .recs + .iter() + .filter_map(|(_, r)| match r { + Rec::Style(s) => Some(s.clone()), + _ => None, + }) .collect(); assert_eq!(styles.len(), 2); assert_eq!(styles[0].style_id, "S:1"); // sorted by style id assert_eq!(styles[0].style_type, "FILL"); - let sets = out.recs.iter().filter(|(k, _)| matches!(k, Id::ComponentSet(_))).count(); + let sets = out + .recs + .iter() + .filter(|(k, _)| matches!(k, Id::ComponentSet(_))) + .count(); assert_eq!(sets, 1); } diff --git a/examples/figmog/tests/sync.rs b/examples/figmog/tests/sync.rs index abc7956..d777283 100644 --- a/examples/figmog/tests/sync.rs +++ b/examples/figmog/tests/sync.rs @@ -47,37 +47,66 @@ fn initial_pull_populates_every_sink() { let mut st = open_probed!(dir.path().join("db"), counter); let churn = pull(&mut st, &common::fixture_v1()); - assert_eq!(churn, Churn { added: 18, changed: 0, removed: 0, unchanged: 0 }); + assert_eq!( + churn, + Churn { + added: 18, + changed: 0, + removed: 0, + unchanged: 0 + } + ); // 18 records + 1 meta row, all fresh inserts -> 19 pushes assert_eq!(counter.get(), 19); - st.rtx(|((nodes, children, text, instances_of, styled_by, bound_to, by_type), - components, component_sets, styles, _vars, _colls, meta)| { - assert_eq!(nodes.iter().count(), 12); - assert_eq!(nodes.get(&"1:2".to_string()).unwrap().name, "Title"); + st.rtx( + |( + (nodes, children, text, instances_of, styled_by, bound_to, by_type), + components, + component_sets, + styles, + _vars, + _colls, + meta, + )| { + assert_eq!(nodes.iter().count(), 12); + assert_eq!(nodes.get(&"1:2".to_string()).unwrap().name, "Title"); - let mut kids = children.get(&"1:1".to_string()); - kids.sort(); - assert_eq!(kids, vec![(0, "1:2".to_string()), (1, "1:3".to_string())]); + let mut kids = children.get(&"1:1".to_string()); + kids.sort(); + assert_eq!(kids, vec![(0, "1:2".to_string()), (1, "1:3".to_string())]); - let hits = text.search("garden", 5); - assert!(hits.iter().any(|h| h.val == "1:2"), "bm25 finds the title text"); + let hits = text.search("garden", 5); + assert!( + hits.iter().any(|h| h.val == "1:2"), + "bm25 finds the title text" + ); - assert_eq!(instances_of.search(&"2:2".to_string()), vec!["1:3".to_string()]); - assert_eq!(styled_by.search(&"S:2".to_string()), vec!["1:2".to_string()]); - assert_eq!(bound_to.search(&"VariableID:100".to_string()), vec!["1:1".to_string()]); + assert_eq!( + instances_of.search(&"2:2".to_string()), + vec!["1:3".to_string()] + ); + assert_eq!( + styled_by.search(&"S:2".to_string()), + vec!["1:2".to_string()] + ); + assert_eq!( + bound_to.search(&"VariableID:100".to_string()), + vec!["1:1".to_string()] + ); - let mut texts = by_type.search(&"TEXT".to_string()); - texts.sort(); - assert_eq!(texts, vec!["1:2".to_string()]); + let mut texts = by_type.search(&"TEXT".to_string()); + texts.sort(); + assert_eq!(texts, vec!["1:2".to_string()]); - assert_eq!(components.iter().count(), 3); - assert_eq!(component_sets.iter().count(), 1); - assert_eq!(styles.iter().count(), 2); - let m = meta.get(&0).unwrap(); - assert_eq!(m.version, "100"); - assert_eq!(m.synced_at_unix_ms, 1_000); - }); + assert_eq!(components.iter().count(), 3); + assert_eq!(component_sets.iter().count(), 1); + assert_eq!(styles.iter().count(), 2); + let m = meta.get(&0).unwrap(); + assert_eq!(m.version, "100"); + assert_eq!(m.synced_at_unix_ms, 1_000); + }, + ); } #[test] @@ -89,8 +118,20 @@ fn identical_repull_causes_zero_churn() { pull(&mut st, &common::fixture_v1()); counter.set(0); let churn = pull(&mut st, &common::fixture_v1()); // same synced_at too - assert_eq!(churn, Churn { added: 0, changed: 0, removed: 0, unchanged: 18 }); - assert_eq!(counter.get(), 0, "no delta may enter the graph on an identical re-pull"); + assert_eq!( + churn, + Churn { + added: 0, + changed: 0, + removed: 0, + unchanged: 18 + } + ); + assert_eq!( + counter.get(), + 0, + "no delta may enter the graph on an identical re-pull" + ); } #[test] @@ -126,37 +167,66 @@ fn v1_to_v2_minimal_churn_and_index_consistency() { let mut st = open_probed!(dir.path().join("db"), counter); pull(&mut st, &common::fixture_v1()); - let prior = st.rtx(|((nodes, ..), components, component_sets, styles, _, _, _)| { - figmog::store::collect_sweepable(&nodes, &components, &component_sets, &styles) - }); + let prior = st.rtx( + |((nodes, ..), components, component_sets, styles, _, _, _)| { + figmog::store::collect_sweepable(&nodes, &components, &component_sets, &styles) + }, + ); counter.set(0); let churn = pull_with_sweep(&mut st, &common::fixture_v2(), prior, 1_000); // v2 has 18 records: 12 nodes (12 - 1:9 + 1:4) + 3 components + 1 set // + 2 styles. changed: 1:2 (rename), 1:3 (variant repoint). added: 1:4. // removed: 1:9. unchanged: 18 - 1 - 2 = 15 (meta row is not counted). - assert_eq!(churn, Churn { added: 1, changed: 2, removed: 1, unchanged: 15 }); + assert_eq!( + churn, + Churn { + added: 1, + changed: 2, + removed: 1, + unchanged: 15 + } + ); // pushes: changed 2×2 + added 1 + removed 1 + meta retract/insert 2 = 8 assert_eq!(counter.get(), 8); - st.rtx(|((nodes, children, text, instances_of, _styled, _bound, by_type), - _c, _cs, _s, _v, _vc, meta)| { - // rename re-indexed in bm25 - assert!(text.search("Headline", 5).iter().any(|h| h.val == "1:2")); - assert!(!text.search("Title", 5).iter().any(|h| h.val == "1:2")); - // deleted node gone everywhere - assert!(nodes.get(&"1:9".to_string()).is_none()); - assert!(!by_type.search(&"RECTANGLE".to_string()).contains(&"1:9".to_string())); - let kids = children.get(&"0:1".to_string()); - assert!(!kids.iter().any(|(_, id)| id == "1:9")); - // instance repoint moved the inverted index posting - assert_eq!(instances_of.search(&"2:2".to_string()), Vec::::new()); - assert_eq!(instances_of.search(&"2:3".to_string()), vec!["1:3".to_string()]); - // new node present - assert_eq!(nodes.get(&"1:4".to_string()).unwrap().name, "Subtitle"); - assert!(text.search("Planting", 5).iter().any(|h| h.val == "1:4")); - assert_eq!(meta.get(&0).unwrap().version, "101"); - }); + st.rtx( + |( + (nodes, children, text, instances_of, _styled, _bound, by_type), + _c, + _cs, + _s, + _v, + _vc, + meta, + )| { + // rename re-indexed in bm25 + assert!(text.search("Headline", 5).iter().any(|h| h.val == "1:2")); + assert!(!text.search("Title", 5).iter().any(|h| h.val == "1:2")); + // deleted node gone everywhere + assert!(nodes.get(&"1:9".to_string()).is_none()); + assert!( + !by_type + .search(&"RECTANGLE".to_string()) + .contains(&"1:9".to_string()) + ); + let kids = children.get(&"0:1".to_string()); + assert!(!kids.iter().any(|(_, id)| id == "1:9")); + // instance repoint moved the inverted index posting + assert_eq!( + instances_of.search(&"2:2".to_string()), + Vec::::new() + ); + assert_eq!( + instances_of.search(&"2:3".to_string()), + vec!["1:3".to_string()] + ); + // new node present + assert_eq!(nodes.get(&"1:4".to_string()).unwrap().name, "Subtitle"); + assert!(text.search("Planting", 5).iter().any(|h| h.val == "1:4")); + assert_eq!(meta.get(&0).unwrap().version, "101"); + }, + ); } #[test] @@ -189,9 +259,11 @@ fn sweep_never_touches_variables() { }), ); }); - let prior = st.rtx(|((nodes, ..), components, component_sets, styles, _, _, _)| { - figmog::store::collect_sweepable(&nodes, &components, &component_sets, &styles) - }); + let prior = st.rtx( + |((nodes, ..), components, component_sets, styles, _, _, _)| { + figmog::store::collect_sweepable(&nodes, &components, &component_sets, &styles) + }, + ); pull_with_sweep(&mut st, &common::fixture_v2(), prior, 2_000); st.rtx(|(_, _, _, _, vars, colls, _)| { assert!(vars.get(&"VariableID:100".to_string()).is_some()); @@ -232,7 +304,10 @@ fn panicking_transaction_rolls_back_entirely() { })); assert!(result.is_err()); st.rtx(|((nodes, ..), _, _, _, _, _, meta)| { - assert!(nodes.get(&"9:9".to_string()).is_none(), "aborted upsert must not persist"); + assert!( + nodes.get(&"9:9".to_string()).is_none(), + "aborted upsert must not persist" + ); assert_eq!(nodes.iter().count(), 12); assert_eq!(meta.get(&0).unwrap().version, "100"); }); diff --git a/examples/figmog/tests/vars.rs b/examples/figmog/tests/vars.rs index 47a4d30..f8ec045 100644 --- a/examples/figmog/tests/vars.rs +++ b/examples/figmog/tests/vars.rs @@ -15,11 +15,21 @@ fn parses_rest_shape() { // 2 collections then 3 variables, sorted by id assert_eq!(recs.len(), 5); assert!(matches!(&recs[0].0, Id::VariableCollection(id) if id == "VariableCollectionId:1")); - let Rec::VariableCollection(c) = &recs[0].1 else { panic!() }; - assert_eq!(c.modes, vec![("1:0".to_string(), "light".to_string()), ("1:1".to_string(), "dark".to_string())]); + let Rec::VariableCollection(c) = &recs[0].1 else { + panic!() + }; + assert_eq!( + c.modes, + vec![ + ("1:0".to_string(), "light".to_string()), + ("1:1".to_string(), "dark".to_string()) + ] + ); assert_eq!(c.default_mode_id, "1:0"); - let Rec::Variable(v) = &recs[2].1 else { panic!() }; + let Rec::Variable(v) = &recs[2].1 else { + panic!() + }; assert_eq!(v.id, "VariableID:100"); assert_eq!(v.resolved_type, "COLOR"); assert_eq!(v.collection_id, "VariableCollectionId:1"); @@ -56,28 +66,47 @@ fn infers_values_and_sites_from_fixture() { let nodes: Vec = out .recs .iter() - .filter_map(|(_, r)| match r { Rec::Node(n) => Some(n.clone()), _ => None }) + .filter_map(|(_, r)| match r { + Rec::Node(n) => Some(n.clone()), + _ => None, + }) .collect(); let usages = infer_from_nodes(nodes.iter()); assert_eq!(usages.len(), 2, "two distinct variables bound in fixture"); - let color = usages.iter().find(|u| u.variable_id == "VariableID:100").unwrap(); - assert_eq!(color.sites, vec![("1:1".to_string(), "/fills/0/color".to_string())]); + let color = usages + .iter() + .find(|u| u.variable_id == "VariableID:100") + .unwrap(); + assert_eq!( + color.sites, + vec![("1:1".to_string(), "/fills/0/color".to_string())] + ); let observed: serde_json::Value = serde_json::from_str(&color.observed[0]).unwrap(); assert_eq!(observed["r"], 0.06); - let pad = usages.iter().find(|u| u.variable_id == "VariableID:200").unwrap(); + let pad = usages + .iter() + .find(|u| u.variable_id == "VariableID:200") + .unwrap(); assert_eq!(pad.observed, vec!["16.0".to_string()]); } #[test] fn style_values_come_from_consumers() { let out = flatten_file(&common::fixture_v1()).unwrap(); - let title = out.recs.iter().find_map(|(k, r)| match (k, r) { - (Id::Node(id), Rec::Node(n)) if id == "1:2" => Some(n.clone()), - _ => None, - }).unwrap(); + let title = out + .recs + .iter() + .find_map(|(k, r)| match (k, r) { + (Id::Node(id), Rec::Node(n)) if id == "1:2" => Some(n.clone()), + _ => None, + }) + .unwrap(); let v = style_value_from_consumer("TEXT", &title.raw).unwrap(); assert_eq!(v["fontSize"], 32.0); - assert!(style_value_from_consumer("FILL", &title.raw).is_none(), "no fills on the text node"); + assert!( + style_value_from_consumer("FILL", &title.raw).is_none(), + "no fills on the text node" + ); } From b4a6cd63e899196e8d41224b82a3bfdc6c104e51 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 21:45:07 -0700 Subject: [PATCH 18/56] fix(figmog): final review fixes (watch backoff, json errors, current-key integrity) Closes out the figmog final-wave review: watch's Tier-1 pull failures now honor Retry-After and back off exponentially instead of hammering the budget every interval; --json mode emits {"error":...} on stderr instead of plain text; a failed pull no longer rewrites .figmog/current or leaves a stale mirror pointer behind; watch's Wait line no longer claims "rate limited" for ordinary backoff; find --type is case-insensitive like styles --type; pull --from-file with no established key gets a pull-specific error instead of "run pull first"; and the variable_edges dedup comment now matches what the code actually does. Co-Authored-By: Claude Fable 5 --- examples/figmog/src/cli.rs | 180 ++++++++++++++++++++++++++++++++--- examples/figmog/src/store.rs | 6 +- examples/figmog/src/watch.rs | 4 +- examples/figmog/tests/cli.rs | 58 +++++++++++ 4 files changed, 231 insertions(+), 17 deletions(-) diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 66c8a40..703919c 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -12,7 +12,7 @@ use fold::pipeline::terminal::search::Bm25Reader; use fold::pipeline::terminal::{InvertedIndexReader, MultimapReader, TableReader}; use fold::stream::Readable; -use crate::api::{FigmaApi, UreqApi}; +use crate::api::{ApiError, FigmaApi, UreqApi}; use crate::flatten::flatten_file; use crate::ident::{normalize_node_id, parse_file_ref}; use crate::model::{ @@ -20,7 +20,7 @@ use crate::model::{ VariableRec, }; use crate::store::{Churn, collect_sweepable, sync}; -use crate::watch::{Tick, Watcher}; +use crate::watch::{BACKOFF_CAP, BACKOFF_START, Tick, Watcher}; /// Read handle for the pipeline's `text` BM25 sink (its tokenizer type /// param makes the full type unwieldy at every call site). @@ -111,10 +111,15 @@ enum Cmd { /// 1 with a one-line `figmog: ` on stderr otherwise). pub fn run() -> i32 { let cli = Cli::parse(); + let json = cli.json; match dispatch(cli) { Ok(()) => 0, Err(e) => { - eprintln!("figmog: {e}"); + if json { + eprintln!("{}", json!({"error": e})); + } else { + eprintln!("figmog: {e}"); + } 1 } } @@ -216,10 +221,11 @@ fn resolve_db(cli: &Cli) -> Result { }); } - // pull/watch with an explicit file ref establish (and remember) the key. + // pull/watch with an explicit file ref establish the key for this run. + // `.figmog/current` is only written after a successful sync (see + // `do_pull`), so a failed pull never repoints later commands. if let Cmd::Pull { file: Some(f), .. } | Cmd::Watch { file: Some(f), .. } = &cli.cmd { let key = parse_file_ref(f).ok_or_else(|| format!("not a Figma file key or URL: {f}"))?; - write_current(&key)?; return Ok(Db { path: db_path_for(&key), key: Some(key), @@ -227,11 +233,11 @@ fn resolve_db(cli: &Cli) -> Result { } let key = std::fs::read_to_string(CURRENT_FILE) - .map_err(|_| "no mirror here — run `figmog pull ` first".to_string())? + .map_err(|_| no_mirror_msg(cli))? .trim() .to_string(); if key.is_empty() { - return Err("no mirror here — run `figmog pull ` first".into()); + return Err(no_mirror_msg(cli)); } Ok(Db { path: db_path_for(&key), @@ -239,6 +245,23 @@ fn resolve_db(cli: &Cli) -> Result { }) } +/// `pull --from-file` with neither a file ref nor an established key has +/// nothing to sync into — point the user at `--from-file`'s own +/// requirements rather than the generic "run pull first" message (which +/// would tell a user already running pull to run pull). +fn no_mirror_msg(cli: &Cli) -> String { + if let Cmd::Pull { + from_file: Some(_), + file: None, + .. + } = &cli.cmd + { + "--from-file needs a target mirror: pass the file key/url too, or --db ".into() + } else { + "no mirror here — run `figmog pull ` first".into() + } +} + fn db_path_for(key: &str) -> PathBuf { PathBuf::from(".figmog").join(key).join("db") } @@ -257,6 +280,37 @@ fn now_ms() -> u64 { // ---- engine commands ---- +/// Errors from [`do_pull`]: either a typed API failure (so callers can act +/// on rate limits) or any other pull-mechanics failure. `Display` matches +/// the plain-string messages `do_pull` used to produce, so `cmd_pull`'s +/// user-facing errors are unchanged. +#[derive(Debug)] +enum PullError { + Api(ApiError), + Other(String), +} + +impl std::fmt::Display for PullError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PullError::Api(e) => write!(f, "{e}"), + PullError::Other(s) => write!(f, "{s}"), + } + } +} + +impl From for PullError { + fn from(s: String) -> Self { + PullError::Other(s) + } +} + +impl From for PullError { + fn from(e: ApiError) -> Self { + PullError::Api(e) + } +} + fn cmd_pull( db: &Db, file: Option, @@ -264,18 +318,20 @@ fn cmd_pull( fresh: bool, json: bool, ) -> Result<(), String> { - let (churn, name, version) = do_pull(db, file, from_file, fresh)?; + let (churn, name, version) = do_pull(db, file, from_file, fresh).map_err(|e| e.to_string())?; print_churn(&churn, &name, &version, json) } /// The pull mechanics without any printing, so `cmd_watch` can format its -/// own per-tick event lines around the same churn. +/// own per-tick event lines around the same churn. `.figmog/current` is +/// written only once the sync below has actually happened, so a failed +/// pull never repoints later commands at a nonexistent mirror. fn do_pull( db: &Db, file: Option, from_file: Option, fresh: bool, -) -> Result<(Churn, String, String), String> { +) -> Result<(Churn, String, String), PullError> { let resp: Value = match from_file { Some(path) => { let content = std::fs::read_to_string(&path) @@ -291,7 +347,7 @@ fn do_pull( .ok_or_else(|| "no file key: pass a file key or figma.com URL".to_string())?; let token = std::env::var("FIGMA_TOKEN") .map_err(|_| "FIGMA_TOKEN not set — required for network pulls".to_string())?; - UreqApi::new(token).file(&key).map_err(|e| e.to_string())? + UreqApi::new(token).file(&key)? } }; @@ -307,6 +363,10 @@ fn do_pull( }); let churn = sync(&mut st, &prior, &flattened, now_ms()); + if let Some(key) = &db.key { + write_current(key)?; + } + Ok(( churn, flattened.file.name.clone(), @@ -346,6 +406,9 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result let mut stored = read_watermark(db); let mut watcher = Watcher::new(stored.clone()); let interval = Duration::from_secs(interval); + // Backoff for Tier-1 pull failures, independent of the Watcher's own + // Tier-3 meta-poll backoff — reset on any successful pull. + let mut pull_backoff = BACKOFF_START; loop { match watcher.tick(&api, &key) { @@ -357,7 +420,7 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result json!({"event": "waiting", "seconds": after.as_secs()}) ); } else { - println!("rate limited, waiting {}s", after.as_secs()); + println!("waiting {}s", after.as_secs()); } std::thread::sleep(after); } @@ -370,6 +433,7 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result match do_pull(db, Some(key.clone()), None, false) { Ok((churn, name, version)) => { stored = read_watermark(db); + pull_backoff = BACKOFF_START; if json { let mut v = serde_json::to_value(&churn).unwrap_or_default(); if let Some(obj) = v.as_object_mut() { @@ -382,6 +446,7 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result churn.added, churn.changed, churn.removed, churn.unchanged ); } + std::thread::sleep(interval); } Err(e) => { eprintln!("figmog: pull failed: {e}"); @@ -389,14 +454,34 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result // the last successfully-synced one so the same // change is re-detected on the next tick. watcher = Watcher::new(stored.clone()); + let wait = pull_failure_wait(&e, &mut pull_backoff, interval); + if json { + println!("{}", json!({"event": "waiting", "seconds": wait.as_secs()})); + } else { + println!("waiting {}s", wait.as_secs()); + } + std::thread::sleep(wait); } } - std::thread::sleep(interval); } } } } +/// How long `cmd_watch` should sleep after a failed pull, and advance the +/// per-loop backoff state. `RateLimited` honors `Retry-After` (never less +/// than the normal poll interval); anything else gets the same exponential +/// backoff discipline the [`Watcher`] uses for Tier-3 meta failures. +fn pull_failure_wait(err: &PullError, backoff: &mut Duration, interval: Duration) -> Duration { + if let PullError::Api(ApiError::RateLimited { retry_after }) = err { + interval.max(*retry_after) + } else { + let wait = *backoff; + *backoff = (*backoff * 2).min(BACKOFF_CAP); + wait + } +} + fn cmd_import_variables(db: &Db, path: PathBuf, json: bool) -> Result<(), String> { let content = std::fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?; @@ -624,7 +709,9 @@ fn cmd_find( page: Option, json: bool, ) -> Result<(), String> { - let mut ids = by_type.search(&node_type); + // Figma node types are stored uppercase; normalize so `--type frame` + // matches the same as `--type FRAME`. + let mut ids = by_type.search(&node_type.to_uppercase()); ids.sort(); let page = page.as_deref().map(normalize_node_id); @@ -1025,3 +1112,68 @@ fn cmd_vars( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rate_limited_waits_max_of_interval_and_retry_after() { + let mut backoff = BACKOFF_START; + let err = PullError::Api(ApiError::RateLimited { + retry_after: Duration::from_secs(90), + }); + // retry_after exceeds interval: use retry_after. + let wait = pull_failure_wait(&err, &mut backoff, Duration::from_secs(10)); + assert_eq!(wait, Duration::from_secs(90)); + // rate-limit waits don't consume the exponential-backoff budget. + assert_eq!(backoff, BACKOFF_START); + + let err = PullError::Api(ApiError::RateLimited { + retry_after: Duration::from_secs(3), + }); + let wait = pull_failure_wait(&err, &mut backoff, Duration::from_secs(10)); + assert_eq!(wait, Duration::from_secs(10)); + } + + #[test] + fn other_errors_back_off_exponentially_and_cap() { + let mut backoff = BACKOFF_START; + let interval = Duration::from_secs(10); + let net_err = PullError::Api(ApiError::Network("down".into())); + + let w1 = pull_failure_wait(&net_err, &mut backoff, interval); + assert_eq!(w1, Duration::from_secs(5)); + let w2 = pull_failure_wait(&net_err, &mut backoff, interval); + assert_eq!(w2, Duration::from_secs(10)); + let w3 = pull_failure_wait(&net_err, &mut backoff, interval); + assert_eq!(w3, Duration::from_secs(20)); + + // non-Api errors (e.g. flatten failures) get the same treatment. + let other_err = PullError::Other("bad shape".into()); + let mut backoff2 = BACKOFF_CAP / 2 + Duration::from_secs(1); + let w = pull_failure_wait(&other_err, &mut backoff2, interval); + assert!(w <= BACKOFF_CAP); + assert_eq!(backoff2, BACKOFF_CAP); + } + + #[test] + fn pull_error_display_matches_prior_stringified_messages() { + let e = PullError::Other("FIGMA_TOKEN not set — required for network pulls".into()); + assert_eq!( + e.to_string(), + "FIGMA_TOKEN not set — required for network pulls" + ); + + let e = PullError::Api(ApiError::RateLimited { + retry_after: Duration::from_secs(30), + }); + assert_eq!( + e.to_string(), + ApiError::RateLimited { + retry_after: Duration::from_secs(30) + } + .to_string() + ); + } +} diff --git a/examples/figmog/src/store.rs b/examples/figmog/src/store.rs index b5e98fe..6263b02 100644 --- a/examples/figmog/src/store.rs +++ b/examples/figmog/src/store.rs @@ -71,7 +71,11 @@ pub fn variable_edges(d: &Keyed) -> Vec> .iter() .map(|(_, var_id)| Keyed::new(d.val.id.clone(), var_id.clone())) .collect(); - edges.dedup_by(|a, b| a.val == b.val); // sorted input: dedup repeated ids + // Best-effort adjacent dedup only: `bound_variables` is sorted by + // (pointer, var_id), not by var_id, so equal ids at different pointers + // aren't caught here. Harmless — `InvertedIndex` is set-semantic, so + // any duplicate edges that slip through are absorbed. + edges.dedup_by(|a, b| a.val == b.val); edges } diff --git a/examples/figmog/src/watch.rs b/examples/figmog/src/watch.rs index c19d8b8..933ccd0 100644 --- a/examples/figmog/src/watch.rs +++ b/examples/figmog/src/watch.rs @@ -5,8 +5,8 @@ use std::time::Duration; use crate::api::{ApiError, FigmaApi}; -const BACKOFF_START: Duration = Duration::from_secs(5); -const BACKOFF_CAP: Duration = Duration::from_secs(300); +pub(crate) const BACKOFF_START: Duration = Duration::from_secs(5); +pub(crate) const BACKOFF_CAP: Duration = Duration::from_secs(300); /// Outcome of one poll. #[derive(Debug)] diff --git a/examples/figmog/tests/cli.rs b/examples/figmog/tests/cli.rs index f34f769..ae63ffc 100644 --- a/examples/figmog/tests/cli.rs +++ b/examples/figmog/tests/cli.rs @@ -109,6 +109,10 @@ fn status_pages_tree_get_find() { assert_eq!(texts.as_array().unwrap().len(), 1); assert_eq!(texts[0]["id"], "1:2"); + // `--type` is case-insensitive (Figma types are stored uppercase). + let texts_lower = run(&["find", "--type", "text"]); + assert_eq!(texts_lower, texts); + let on_page = run(&["find", "--type", "COMPONENT", "--page", "0:2"]); assert_eq!(on_page.as_array().unwrap().len(), 3); // 2:2, 2:3, 3:1 } @@ -124,6 +128,25 @@ fn get_unknown_node_fails_cleanly() { .code(1); } +#[test] +fn get_unknown_node_json_error_is_json_on_stderr() { + let (_dir, db) = fixture_db(); + let out = Command::cargo_bin("figmog") + .unwrap() + .args(["get", "99:99", "--db", &db, "--json"]) + .assert() + .failure() + .code(1); + let stderr = out.get_output().stderr.clone(); + let v: serde_json::Value = serde_json::from_slice(&stderr).unwrap_or_else(|e| { + panic!( + "stderr not JSON: {e}\nstderr: {}", + String::from_utf8_lossy(&stderr) + ) + }); + assert!(v["error"].as_str().unwrap().contains("99:99")); +} + #[test] fn search_instances_components_styles_uses_vars() { let (_dir, db) = fixture_db(); @@ -211,3 +234,38 @@ fn import_variables_upgrades_vars_to_authoritative() { // inference detail still present alongside assert_eq!(v100["sites"][0][0], "1:1"); } + +#[test] +fn failed_pull_does_not_persist_current_or_create_store() { + let dir = tempfile::tempdir().unwrap(); + + // A well-formed-looking key (>=10 alnum chars) with no FIGMA_TOKEN set: + // the network pull fails before ever touching the store or writing + // `.figmog/current`. + Command::cargo_bin("figmog") + .unwrap() + .current_dir(dir.path()) + .env_remove("FIGMA_TOKEN") + .args(["pull", "garbagekey123456"]) + .assert() + .failure() + .code(1); + + assert!( + !dir.path().join(".figmog").exists(), + "a failed pull must not create `.figmog` (no current key, no store dir)" + ); + + // A subsequent read command still reports no mirror — not a stale or + // bogus one — and doesn't leave behind an empty store dir either. + let out = Command::cargo_bin("figmog") + .unwrap() + .current_dir(dir.path()) + .args(["status"]) + .assert() + .failure() + .code(1); + let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string(); + assert!(stderr.contains("no mirror here"), "stderr: {stderr}"); + assert!(!dir.path().join(".figmog").exists()); +} From eb43e5211a2207ba6d469d48a403d4b6b1230826 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 21:45:11 -0700 Subject: [PATCH 19/56] docs(figmog): align spec/readme with shipped watermark and budgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §4/§6 now describe FileMeta.last_modified (the file endpoint's lastModified) as what's actually stored, and spell out that watch's change-detection compares it against the meta endpoint's last_touched_at across two different endpoints, with the manual live check extended to verify they agree on a real file. Drops the unimplemented "plus small jitter" note per the no-jitter ruling, and documents the pull-path backoff now applied to Tier-1 pull failures. Also fixes stale naming drift (.figmog/config -> .figmog/current, --interval 10s -> --interval 10) and adds the Tier-3 poll budget and a pull --fresh variables-wipe caveat to the README. Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-15-figmog-build-design.md | 32 +++++++++++++++---- examples/figmog/README.md | 7 ++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index c0099ae..f9c5249 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -144,7 +144,10 @@ id): defined locally), and for components `component_set_id: Option`. `StyleRec` (from `styles`, keyed by style id): `style_id`, `key`, `name`, `style_type`, `description`, `remote`. `FileMeta`: `name`, `version`, -`last_touched_at`, `synced_at_unix_ms`. +`last_modified`, `synced_at_unix_ms` — `last_modified` is the *file* +endpoint's (`GET /v1/files/:key`) `lastModified` field, populated on every +`pull`; see §6 for how this compares against the *meta* endpoint's +`last_touched_at` during `watch`. `VariableRec` / `VariableCollectionRec` (populated by `import-variables` only — see §6a): variable `id`, `name`, `resolved_type` @@ -253,9 +256,22 @@ loop: - A spurious trigger (touch without a real edit) costs one Tier-1 fetch that produces zero churn — the design is self-healing, so the trigger needs to be cheap, not perfect. -- On 429: sleep `Retry-After` seconds (plus small jitter), then resume. - On network errors: exponential backoff capped at 5 min, keep looping — - `watch` must survive laptop sleep and flaky wifi. +- The comparison above is across endpoints: `meta.last_touched_at` comes + from `GET /v1/files/:key/meta` (Tier 3), while the stored watermark + (`FileMeta.last_modified`) was captured from `GET /v1/files/:key`'s + `lastModified` field (Tier 1) on the last `pull`. If the two fields ever + differ in format or precision, every `watch` start on a warm DB costs one + spurious Tier-1 pull that produces zero churn — self-healing within the + run, since the Watcher then keeps `last_touched_at` in memory and stops + re-triggering. See §9's manual live check for confirming the two fields + agree on a real file. +- On 429: sleep `Retry-After` seconds, then resume (single-process poller; + no jitter). On network errors: exponential backoff capped at 5 min, keep + looping — `watch` must survive laptop sleep and flaky wifi. The same + discipline applies if the Tier-1 pull itself fails after a detected + change (429 → `Retry-After`; anything else → the same exponential + backoff), so a persistently failing pull doesn't hammer the Tier-1 + budget. - `watch` performs an initial `pull` if the DB is empty or stale. Auth: personal access token from `FIGMA_TOKEN` (flag `--token` overrides). @@ -319,7 +335,7 @@ deterministic (sorted); `--json` emits machine-readable JSON on stdout. | command | reads | behavior | |---|---|---| | `figmog pull ` | — | sync now; prints churn summary | -| `figmog watch [--interval 10s]` | — | poll loop as above | +| `figmog watch [--interval 10]` | — | poll loop as above | | `figmog pages` | children of root | list CANVAS pages (id, name) | | `figmog tree [id] [--depth N]` | children + nodes | indented outline: `name [type] id`; root defaults to document | | `figmog get [--children]` | nodes (+children) | the full `raw` JSON of a node; `--children` inlines one level of child summaries | @@ -334,7 +350,7 @@ deterministic (sorted); `--json` emits machine-readable JSON on stdout. | `figmog status` | meta | file name, version, last modified, last synced, node count | DB location: `.figmog//` under the current directory (override -`--db`). The CLI stores the last-used file key in `.figmog/config` so read +`--db`). The CLI stores the last-used file key in `.figmog/current` so read commands don't need the file argument every time. ## 8. Rust practices @@ -430,7 +446,9 @@ is used only for local manual verification. - URL/key/node-id argument parsing (`12-34` ⇒ `12:34`, full URLs) 6. **Manual live check** (documented in the crate README, not CI): `FIGMA_TOKEN=… figmog pull `, then `figmog components`, `figmog search`, timing note. Acceptance: read commands return in - milliseconds on the real file. + milliseconds on the real file. Also confirm `last_touched_at` (meta) equals + `lastModified` (file) on the real file, since `watch` compares them + across two different endpoints (§6). Full-feature test run (`cargo test -p figmog`) must pass before the milestone is called done; `-p figmog` doesn't build ese, so iteration is diff --git a/examples/figmog/README.md b/examples/figmog/README.md index d1fee86..be01e4f 100644 --- a/examples/figmog/README.md +++ b/examples/figmog/README.md @@ -61,6 +61,8 @@ and nothing else. Since the November 2025 rate-limit overhaul, file endpoints are capped around **10 requests/min on the free (Starter) plan**, and there is no delta API — this polling design is what makes that budget workable for an agent that wants to treat the file as live. +The Tier-3 meta poll itself is capped around **50 requests/min on +Starter**, well above any sane `--interval`. ## Variables on a free plan @@ -154,3 +156,8 @@ the mirrored file is. Figma serializes an INSTANCE's overridden children as ordinary nodes under it, and those mirror like any other node, but overrides that Figma doesn't materialize into the subtree are not reconstructed. +- **`pull --fresh` wipes imported variables** — `--fresh` deletes the whole + store, including `import-variables` records that normally survive + ordinary pulls (they're exempt from the file-sync sweep, not from a full + wipe). Re-run `import-variables` after a `--fresh` pull if you need + authoritative variable data back. From 4643b53e1c9ada4ca788d79d4f47d6ac67ed5bca Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 22:44:14 -0700 Subject: [PATCH 20/56] =?UTF-8?q?spec(figmog):=20v2=20design=20=E2=80=94?= =?UTF-8?q?=20figmog=20serve=20MCP=20server=20with=20integrated=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-15-figmog-build-design.md | 98 ++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index f9c5249..d2d313d 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -41,8 +41,8 @@ data it needs is mirrored and queryable. ### Non-goals (v1) - Image renders / thumbnails. -- MCP server (v2; it becomes a second binary over the same DB — the schema - is designed so this needs no migration). +- MCP server in v1 (see §11 for the v2 design: a `serve` subcommand with + integrated sync — same store, no schema migration). - Multi-file / team mirroring (the store layout is per-file-key, so this is additive later). - Embeddings / HNSW semantic search (BM25 over names + text is enough for @@ -468,3 +468,97 @@ already fast. the serialized tree are not resolved (documented). - **Branching files:** `branch_data` ignored in v1; mirroring a branch = mirroring its own file key. + +## 11. v2: `figmog serve` — the MCP server + +The point of the whole project: agents talk MCP, so the mirror gets an MCP +face. **Revision of the v1 non-goal sketch:** this is a `serve` subcommand +of the same binary, not a second binary — fjall is single-writer, so a +standalone MCP process would fight `figmog watch` for the store lock. +`figmog serve` is therefore **one process that owns the store**: an MCP +stdio server with the sync loop integrated. Agents get always-fresh reads; +there is nothing else to run. + +### Architecture + +``` +stdin ──▶ reader thread ──▶ mpsc ──▶ main loop ──▶ stdout (responses) + │ recv_timeout(next poll tick) + ├─ on line: JSON-RPC dispatch → query::* + └─ on timeout: Watcher::tick → maybe pull +``` + +- **`query.rs` (refactor):** the read logic currently inlined in the CLI's + `cmd_*` printers moves into pure functions that take readers and return + `serde_json::Value` — `query::status`, `query::pages`, `query::tree`, + `query::node`, `query::find`, `query::search`, `query::instances`, + `query::components`, `query::styles`, `query::uses`, `query::vars`. The + CLI commands become thin printers over `query::*` (this also retires the + deferred "json/human boilerplate" debt); MCP tools call the same + functions. One source of truth for every answer. +- **`mcp.rs`:** minimal JSON-RPC 2.0 over newline-delimited stdio. Handles + `initialize` (echo the client's `protocolVersion`; `capabilities: + {tools: {}}`; `serverInfo {name: "figmog", version}`), + `notifications/initialized` (ignore), `ping`, `tools/list`, + `tools/call`. Everything else → JSON-RPC `-32601`. Malformed JSON → + `-32700` with `id: null`. Logging to stderr only; stdout carries nothing + but protocol frames. +- **`serve.rs`:** the loop above. Store owned by the main thread (no + `Send` requirements on fold types). Poll ticks run only between + requests; a pull blocks request handling for its duration (documented — + seconds at worst, and only when the file actually changed). + +### Tools + +Read tools mirror the CLI one-to-one, each returning the `query::*` JSON +as an MCP text content block. Names and inputs: + +| tool | input schema (all fields optional unless noted) | +|---|---| +| `figma_status` | — | +| `figma_pages` | — | +| `figma_tree` | `id`, `depth` (integer) | +| `figma_get_node` | `id` (required), `children` (bool) | +| `figma_find` | `type` (required), `page` | +| `figma_search` | `query` (required), `limit` (integer, default 10) | +| `figma_instances` | `target` (required) | +| `figma_components` | — | +| `figma_styles` | `type`, `values` (bool) | +| `figma_uses` | `id` (required) | +| `figma_vars` | `id` | +| `figma_sync` | — (forces one pull; returns churn; the only tool that spends rate budget) | + +Tool-level failures (unknown node, no mirror, sync error) return an MCP +result with `isError: true` and the message as text — JSON-RPC errors are +reserved for protocol-level problems. Every tool description states +whether it reads locally (all of them) or spends Figma budget (`figma_sync` +only), so agents can reason about cost. + +### CLI surface + +`figmog serve [file] [--interval N] [--no-watch]` — `--no-watch` disables +the poll loop (offline/fixture use; also what tests run). File/db +resolution identical to the other commands. + +### Testing + +- **Protocol unit tests** (`mcp.rs`): dispatch table over scripted + request values — initialize echo, tools/list shape (12 tools, valid + JSON-Schema inputs), unknown method `-32601`, parse error `-32700`, + tools/call routing incl. `isError` on a bad tool name. +- **`query` equivalence:** the CLI smoke tests keep passing unchanged + after the refactor (the printers now consume `query::*`), proving the + refactor moved logic without changing it. +- **End-to-end serve test** (`tests/serve.rs`): build a fixture DB via + `pull --from-file`, spawn `figmog serve --no-watch --db …` as a child + process, drive initialize → tools/list → several tools/call over + stdin/stdout, assert JSON-RPC ids, tool result contents (e.g. + `figma_search` finds node 1:2), and `isError` for an unknown node. +- **Live check addendum:** point Claude Code at the server + (`claude mcp add figmog -- /figmog serve `) and ask it about + the file. + +### Non-goals (v2) + +MCP resources/prompts capabilities; HTTP/SSE transports; multi-file +serving; auth on the socket (stdio only, inherits process trust). From bc183ab4425fe8abb1a0879c9a42d569b9cbd178 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 22:45:54 -0700 Subject: [PATCH 21/56] plan(figmog): serve/MCP implementation plan (4 tasks) Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-15-figmog-serve.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-15-figmog-serve.md diff --git a/docs/superpowers/plans/2026-08-15-figmog-serve.md b/docs/superpowers/plans/2026-08-15-figmog-serve.md new file mode 100644 index 0000000..a104b3b --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-figmog-serve.md @@ -0,0 +1,137 @@ +# figmog serve (MCP server) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `figmog serve` — an MCP stdio server over the figmog store with integrated background sync, so agents query the mirror through MCP tools with always-fresh data. + +**Architecture:** Spec §11 of `docs/superpowers/specs/2026-08-15-figmog-build-design.md` (read it first — it is the binding authority). Three moves: extract the CLI's read logic into a shared `query` module returning JSON; implement a dependency-free JSON-RPC/MCP protocol core; run one main loop that owns the store, answering requests between poll ticks. + +**Tech Stack:** No new dependencies. JSON-RPC hand-rolled over `serde_json`; threads + `std::sync::mpsc` for the stdin reader; `std::process` in tests. + +**Spec:** docs/superpowers/specs/2026-08-15-figmog-build-design.md (§11) + +## Global Constraints + +- Zero new crates in Cargo.toml. Stdout in serve mode carries ONLY newline-delimited JSON-RPC frames; all logging to stderr. +- All v1 behavior unchanged: every existing test keeps passing without modification (except moves of test-internal imports if a type relocates). The CLI's human/JSON output is byte-identical. +- Determinism rules hold (sorted outputs, no HashMap iteration at boundaries). +- Gates per task: `cargo test -p figmog`, `cargo clippy -p figmog --no-deps -- -D warnings`, `cargo fmt -p figmog --check` all clean. Zero diff to fold/ese/anny. +- If `cargo` is not on PATH, prefix commands with `export PATH="$HOME/.cargo/bin:$PATH" && `. +- Commits end with the `Co-Authored-By: Claude Fable 5 ` trailer. + +--- + +### Task 1: `query.rs` — extract read logic from the CLI + +**Files:** +- Create: `examples/figmog/src/query.rs` +- Modify: `examples/figmog/src/cli.rs`, `examples/figmog/src/lib.rs` (add `pub mod query;`) +- Test: existing `tests/cli.rs` must pass unchanged (that IS the test of this refactor) + +**Interfaces:** +- Produces `pub fn`s in `figmog::query`, each taking the concrete reader types it needs (same signatures style as today's `cmd_*` — generic over `R: Readable`) and returning `Result`: + - `status(nodes, meta) -> Result` — the object today's `cmd_status` builds + - `pages(nodes, by_type) -> Result` — JSON array + - `tree(nodes, children, by_type, id: Option, depth: Option) -> Result` — move `TreeNode`, `build_tree`, `tree_to_json` here; `TreeNode` and `build_tree` stay `pub` so the CLI's human printer can render from the same structure: also expose `pub fn tree_nodes(...) -> Result` and have `tree()` wrap it + - `node(nodes, children, id: String, with_children: bool) -> Result` + - `find(nodes, by_type, node_type: String, page: Option) -> Result` + - `search(text, nodes, query: &str, limit: usize) -> Result` + - `instances(nodes, components, component_sets, instances_of, target: &str) -> Result` (move `resolve_component_ids` here as a private fn) + - `components(nodes, components, component_sets) -> Result` + - `styles(nodes, styles, styled_by, style_type: Option, values: bool) -> Result` + - `uses(nodes, styled_by, bound_to, id: &str) -> Result` + - `vars(nodes, variables, variable_collections, id_filter: Option) -> Result` +- Consumed by: cli.rs `cmd_*` (become printers: call `query::*`, then either `println!("{}", serde_json::to_string(...))` in json mode or render human lines from the returned Value/TreeNode exactly as today), and by Task 3's tool handler. + +- [ ] **Step 1:** Create `query.rs` by MOVING the body logic of each `cmd_*` read function (everything between reader access and printing) plus `TreeNode`/`build_tree`/`tree_to_json`/`resolve_component_ids` out of `cli.rs`. The JSON each function returns is exactly the Value the old code printed in `--json` mode. Human rendering stays in `cli.rs`, rebuilt from the returned Value (or `TreeNode` for tree). Node-id normalization (`normalize_node_id`) stays at the CLI/tool boundary — `query::*` receives already-normalized ids EXCEPT where today's code normalizes internally; preserve today's exact behavior. +- [ ] **Step 2:** Rewrite each `cmd_*` in `cli.rs` as a thin printer over `query::*`. Doc-comment `query.rs` (module: "One source of truth for every read answer — shared by the CLI printers and the MCP tools."). +- [ ] **Step 3:** Run the full suite: `cargo test -p figmog`. Every existing test must pass WITHOUT edits — if a cli test fails, the refactor changed behavior; fix the refactor, not the test. Then clippy + fmt gates. +- [ ] **Step 4:** Commit: `refactor(figmog): extract query layer shared by CLI and MCP` + +--- + +### Task 2: `mcp.rs` — protocol core + +**Files:** +- Create: `examples/figmog/src/mcp.rs` +- Modify: `examples/figmog/src/lib.rs` (add `pub mod mcp;`) +- Test: unit tests in `mcp.rs` + +**Interfaces:** +- Produces: + ```rust + /// One registered tool: metadata for tools/list. + pub struct ToolDef { + pub name: &'static str, + pub description: &'static str, + /// JSON Schema for the tool's arguments. + pub input_schema: serde_json::Value, + } + /// Executes a tools/call. Ok(v) => success content; Err(msg) => isError content. + pub trait ToolHandler { + fn call(&mut self, name: &str, args: &serde_json::Value) -> Result; + } + /// Handle one incoming JSON-RPC message. Returns the response frame to + /// write, or None for notifications (and for malformed input handled + /// via the returned parse-error frame — see below). + pub fn handle_message( + raw: &str, + tools: &[ToolDef], + handler: &mut dyn ToolHandler, + ) -> Option; + pub const SERVER_NAME: &str = "figmog"; + ``` +- Behavior contract (unit-test each): + - Parse failure → `Some({jsonrpc:"2.0", id: null, error:{code:-32700, message:"parse error"}})`. + - `initialize` → result `{protocolVersion: , capabilities: {tools: {}}, serverInfo: {name: "figmog", version: env!("CARGO_PKG_VERSION")}}`. + - `notifications/initialized` (and any method starting `notifications/`) → `None`. + - `ping` → result `{}`. + - `tools/list` → `{tools: [{name, description, inputSchema}...]}` from the `ToolDef` slice, in slice order. + - `tools/call` with `{name, arguments}` → invoke handler; Ok(v) → result `{content: [{type:"text", text: serde_json::to_string(&v)}], isError: false}`; Err(msg) → result `{content:[{type:"text", text: msg}], isError: true}`. Unknown tool name → handler returns Err (Task 3 handler) — but `mcp.rs` itself must also map a `name` missing from `tools` to the same isError shape without calling the handler. + - Any other method with an `id` → error `-32601` "method not found". Requests without `id` (notifications) → `None`. +- [ ] **Step 1:** Write the failing unit tests for every bullet above (scripted `&str` → expected `Value` assertions; a `NullHandler` test double returning `Ok(json!({"ok":true}))` / `Err("boom")` by tool name). +- [ ] **Step 2:** Verify compile failure, implement, iterate to green. Gates. +- [ ] **Step 3:** Commit: `feat(figmog): MCP protocol core (JSON-RPC over stdio frames)` + +--- + +### Task 3: `serve.rs` — the serve loop + CLI wiring + +**Files:** +- Create: `examples/figmog/src/serve.rs` +- Modify: `examples/figmog/src/lib.rs` (add `pub mod serve;`), `examples/figmog/src/cli.rs` (add `Serve` variant + dispatch) + +**Interfaces:** +- Produces: `pub fn run_serve(db: &crate::cli::Db, file: Option, interval: u64, no_watch: bool) -> Result<(), String>` (make `Db` and the small helpers it needs `pub(crate)`; adjust visibility minimally). CLI: `figmog serve [file] [--interval N (default 10)] [--no-watch]`. +- Loop design (spec §11): spawn a thread reading `stdin` lines into an `mpsc::Sender`; main loop owns the store (opened via `open_store!` at this concrete site) and a `Watcher` seeded from the stored watermark; `recv_timeout(until_next_tick)` — on message: `mcp::handle_message` → write response + `\n` to stdout, flush; on timeout (and `!no_watch`): tick → on `Changed` run the pull sequence inline (fetch via `UreqApi`, `flatten_file`, `collect_sweepable` in `rtx`, `store::sync`), honoring the existing `pull_failure_wait` backoff discipline and watcher-reset-on-failure rule; on `Wait{after}` extend the next deadline. Startup: if the store has no meta row and `no_watch` is false, do an initial pull before serving. eprintln! one startup line (name, file key, watch on/off). +- Tool registry: the 12 tools from spec §11's table, descriptions stating "reads the local mirror (no Figma API cost)" vs `figma_sync`'s "fetches from Figma (spends Tier-1 rate budget)". Handler: match tool name → normalize ids (`normalize_node_id` where the arg is a node id) → `st.rtx(|readers| query::*(…))` → the returned Value. `figma_sync` → the inline pull sequence → churn JSON. Unknown args types → Err(msg). +- [ ] **Step 1:** Implement `serve.rs` + wire the CLI variant. Keep every closure at the concrete `open_store!` site (the pipeline type is unnameable — same pattern as `dispatch`). +- [ ] **Step 2:** `cargo test -p figmog` (all green — no new tests yet), clippy, fmt. Manual smoke: `printf '…initialize…\n…tools/list…\n' | cargo run -p figmog -- serve --no-watch --db ` shows two frames on stdout. +- [ ] **Step 3:** Commit: `feat(figmog): figmog serve — MCP stdio server with integrated sync` + +--- + +### Task 4: end-to-end serve test + docs + +**Files:** +- Create: `examples/figmog/tests/serve.rs` +- Modify: `examples/figmog/README.md`, workspace `README.md` (figmog bullet mentions MCP) + +**Interfaces:** none new. + +- [ ] **Step 1:** Write `tests/serve.rs`: build a fixture DB (reuse the `pull --from-file` pattern from `tests/cli.rs` — copy the `fixture_db()` helper or share via `tests/common`), then `std::process::Command` the compiled binary (`assert_cmd::cargo::cargo_bin("figmog")` gives the path) with `serve --no-watch --db `, piped stdio. Write frames, read responses line-by-line with a read timeout guard (wrap reader thread + channel, or set a generous `wait_with_output` after closing stdin — closing stdin must terminate the loop: reader thread sees EOF, sender drops, `recv_timeout` returns Disconnected → clean exit; implement that exit path in Task 3 if missing). Assertions: + - initialize response echoes id 1 and `serverInfo.name == "figmog"` + - `tools/list` returns exactly 12 tools incl. `figma_search` and `figma_sync` + - `tools/call figma_search {query:"garden"}` → `isError:false`, text parses to JSON whose first hit id is `1:2` + - `tools/call figma_get_node {id:"1-2"}` → normalized, `name == "Title"` + - `tools/call figma_get_node {id:"99:99"}` → `isError:true` + - unknown method → error `-32601`; unknown tool → `isError:true` +- [ ] **Step 2:** README: new "Use from agents (MCP)" section — what `serve` is (server + built-in sync, one process), the `claude mcp add figmog -- /target/debug/figmog serve ` snippet (note: build first with `cargo build -p figmog`; or `--db`/`--no-watch` for offline), the 12-tool table (copy spec §11's), the note that only `figma_sync` spends rate budget. Workspace README bullet gains "and an MCP server (`figmog serve`)". +- [ ] **Step 3:** Full gates: `cargo test -p figmog` (now incl. serve e2e), clippy, fmt, `cargo test -p fold`, `cargo doc -p figmog --no-deps`. +- [ ] **Step 4:** Commit: `feat(figmog): serve e2e tests and MCP docs` + +## Self-review checklist + +- Spec §11 coverage: architecture → T3; query refactor → T1; protocol behaviors → T2 (all seven bullets are unit-tested); tools table → T3 registry + T4 README; testing section → T2 unit / T1 equivalence / T4 e2e. Non-goals respected (no resources/prompts, stdio only). +- The T1 refactor is the risk center: its acceptance gate ("existing tests pass unmodified") is what keeps v1 behavior frozen. +- T3's EOF-exit contract is stated in T4 Step 1 because the test depends on it; implementer of T3 must read T4's step (noted in dispatch). From 0b610cb4af975457d027dddf92d49060c16803a4 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 22:52:07 -0700 Subject: [PATCH 22/56] spec+plan(figmog): distinct figmog_* MCP namespace, steering instructions, structural query pack Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-15-figmog-serve.md | 50 ++++++++---- .../specs/2026-08-15-figmog-build-design.md | 78 ++++++++++++++----- 2 files changed, 97 insertions(+), 31 deletions(-) diff --git a/docs/superpowers/plans/2026-08-15-figmog-serve.md b/docs/superpowers/plans/2026-08-15-figmog-serve.md index a104b3b..ca6ea17 100644 --- a/docs/superpowers/plans/2026-08-15-figmog-serve.md +++ b/docs/superpowers/plans/2026-08-15-figmog-serve.md @@ -83,10 +83,10 @@ ``` - Behavior contract (unit-test each): - Parse failure → `Some({jsonrpc:"2.0", id: null, error:{code:-32700, message:"parse error"}})`. - - `initialize` → result `{protocolVersion: , capabilities: {tools: {}}, serverInfo: {name: "figmog", version: env!("CARGO_PKG_VERSION")}}`. + - `initialize` → result `{protocolVersion: , capabilities: {tools: {}}, serverInfo: {name: "figmog", version: env!("CARGO_PKG_VERSION")}, instructions: }`. - `notifications/initialized` (and any method starting `notifications/`) → `None`. - `ping` → result `{}`. - - `tools/list` → `{tools: [{name, description, inputSchema}...]}` from the `ToolDef` slice, in slice order. + - `tools/list` → `{tools: [{name, description, inputSchema}...]}` from the `ToolDef` slice, in slice order. (The protocol core is registry-agnostic; the real 17-tool registry arrives in Task 4.) - `tools/call` with `{name, arguments}` → invoke handler; Ok(v) → result `{content: [{type:"text", text: serde_json::to_string(&v)}], isError: false}`; Err(msg) → result `{content:[{type:"text", text: msg}], isError: true}`. Unknown tool name → handler returns Err (Task 3 handler) — but `mcp.rs` itself must also map a `name` missing from `tools` to the same isError shape without calling the handler. - Any other method with an `id` → error `-32601` "method not found". Requests without `id` (notifications) → `None`. - [ ] **Step 1:** Write the failing unit tests for every bullet above (scripted `&str` → expected `Value` assertions; a `NullHandler` test double returning `Ok(json!({"ok":true}))` / `Err("boom")` by tool name). @@ -95,7 +95,29 @@ --- -### Task 3: `serve.rs` — the serve loop + CLI wiring +### Task 3: structural query pack (query fns + CLI subcommands) + +**Files:** +- Modify: `examples/figmog/src/query.rs`, `examples/figmog/src/cli.rs` +- Test: extend `examples/figmog/tests/cli.rs` + +**Interfaces:** +- Consumes: Task 1's `query.rs` layout and reader-type conventions. +- Produces five new `query::*` functions (same `Result` convention) and five CLI subcommands, per spec §11's "whole-file structural queries" table (the spec table is the authority for inputs/outputs): + - `query::stats(nodes, components, component_sets, styles, variables, by_type)` — counts by node type (from iterating nodes, sorted by type name), counts per page (page_id → n, sorted), table totals, text-node count, max depth (walk parent chains or recurse via children — iterate nodes computing depth by following `parent_id` with memoization-free repeated walks; the file is local, O(n·depth) is fine). + - `query::path(nodes, id)` — follow `parent_id` to the root, then reverse: `[{id, name, type}]` root-first. Unknown id → Err. + - `query::text(nodes, by_type, page: Option)` — `by_type.search("TEXT")`, look up, optional page filter, sorted by id, `[{id, characters, page_id}]` (characters from `NodeRec.text`). + - `query::where_(nodes, pointer: &str, equals: Option, page: Option)` — full scan of `nodes.iter()`; parse each `raw`, `raw.pointer(pointer)`; match = pointer resolves AND (equals absent OR JSON-equal); rows `[{id, name, type, page_id, value}]` sorted by id. Pointer must start with `/` → else Err. + - `query::at(nodes, x: f64, y: f64)` — scan nodes with `abs_bounds = Some([bx, by, w, h])` where `bx <= x < bx+w && by <= y < by+h`; sort by area (w*h) ascending then id; `[{id, name, type, page_id, area}]`. +- CLI: `figmog stats`, `figmog path `, `figmog text [--page ]`, `figmog where --pointer

[--equals ] [--page ]` (`--equals` parsed with `serde_json::from_str`, falling back to treating the bare word as a JSON string so `--equals VERTICAL` works), `figmog at --x N --y N`. All support `--json`; human output follows the existing row-printing conventions; node ids normalized where they're inputs (`path`). + +- [ ] **Step 1 (TDD):** extend `tests/cli.rs` with a test asserting against fixture_v1 facts: `stats` — `by_type.TEXT == 1`, `by_page["0:1"] == 4` (1:1, 1:2, 1:3, 1:9), totals `{components: 3, component_sets: 1, styles: 2}`, `max_depth == 3` (document→canvas→frame→text); `path 1-2` → ids `["0:0","0:1","1:1","1:2"]`; `text` → one row, characters "Welcome to the garden"; `where --pointer /layoutMode --equals VERTICAL` → `["1:1"]`; `where --pointer /style/fontSize --equals 32.0` → `["1:2"]`; `at --x 10 --y 10` → includes `1:1` (bounds 0,0,800×400) and excludes nodes without bounds. Run to verify failure. +- [ ] **Step 2:** implement `query::*` + CLI wiring; iterate to green; full gates. +- [ ] **Step 3:** Commit: `feat(figmog): whole-file structural queries (stats/path/text/where/at)` + +--- + +### Task 4: `serve.rs` — the serve loop + CLI wiring **Files:** - Create: `examples/figmog/src/serve.rs` @@ -104,14 +126,14 @@ **Interfaces:** - Produces: `pub fn run_serve(db: &crate::cli::Db, file: Option, interval: u64, no_watch: bool) -> Result<(), String>` (make `Db` and the small helpers it needs `pub(crate)`; adjust visibility minimally). CLI: `figmog serve [file] [--interval N (default 10)] [--no-watch]`. - Loop design (spec §11): spawn a thread reading `stdin` lines into an `mpsc::Sender`; main loop owns the store (opened via `open_store!` at this concrete site) and a `Watcher` seeded from the stored watermark; `recv_timeout(until_next_tick)` — on message: `mcp::handle_message` → write response + `\n` to stdout, flush; on timeout (and `!no_watch`): tick → on `Changed` run the pull sequence inline (fetch via `UreqApi`, `flatten_file`, `collect_sweepable` in `rtx`, `store::sync`), honoring the existing `pull_failure_wait` backoff discipline and watcher-reset-on-failure rule; on `Wait{after}` extend the next deadline. Startup: if the store has no meta row and `no_watch` is false, do an initial pull before serving. eprintln! one startup line (name, file key, watch on/off). -- Tool registry: the 12 tools from spec §11's table, descriptions stating "reads the local mirror (no Figma API cost)" vs `figma_sync`'s "fetches from Figma (spends Tier-1 rate budget)". Handler: match tool name → normalize ids (`normalize_node_id` where the arg is a node id) → `st.rtx(|readers| query::*(…))` → the returned Value. `figma_sync` → the inline pull sequence → churn JSON. Unknown args types → Err(msg). +- Tool registry: the **17 `figmog_*` tools** from spec §11's two tables (12 core + 5 structural), descriptions stating "reads the local mirror (no Figma API cost)" vs `figmog_sync`'s "fetches from Figma (spends Tier-1 rate budget)". The `initialize` response carries the spec's steering `instructions` text (Task 2 contract). Handler: match tool name → normalize ids (`normalize_node_id` where the arg is a node id) → `st.rtx(|readers| query::*(…))` → the returned Value. `figmog_sync` → the inline pull sequence → churn JSON. Unknown args types → Err(msg). - [ ] **Step 1:** Implement `serve.rs` + wire the CLI variant. Keep every closure at the concrete `open_store!` site (the pipeline type is unnameable — same pattern as `dispatch`). - [ ] **Step 2:** `cargo test -p figmog` (all green — no new tests yet), clippy, fmt. Manual smoke: `printf '…initialize…\n…tools/list…\n' | cargo run -p figmog -- serve --no-watch --db ` shows two frames on stdout. - [ ] **Step 3:** Commit: `feat(figmog): figmog serve — MCP stdio server with integrated sync` --- -### Task 4: end-to-end serve test + docs +### Task 5: end-to-end serve test + docs **Files:** - Create: `examples/figmog/tests/serve.rs` @@ -120,18 +142,20 @@ **Interfaces:** none new. - [ ] **Step 1:** Write `tests/serve.rs`: build a fixture DB (reuse the `pull --from-file` pattern from `tests/cli.rs` — copy the `fixture_db()` helper or share via `tests/common`), then `std::process::Command` the compiled binary (`assert_cmd::cargo::cargo_bin("figmog")` gives the path) with `serve --no-watch --db `, piped stdio. Write frames, read responses line-by-line with a read timeout guard (wrap reader thread + channel, or set a generous `wait_with_output` after closing stdin — closing stdin must terminate the loop: reader thread sees EOF, sender drops, `recv_timeout` returns Disconnected → clean exit; implement that exit path in Task 3 if missing). Assertions: - - initialize response echoes id 1 and `serverInfo.name == "figmog"` - - `tools/list` returns exactly 12 tools incl. `figma_search` and `figma_sync` - - `tools/call figma_search {query:"garden"}` → `isError:false`, text parses to JSON whose first hit id is `1:2` - - `tools/call figma_get_node {id:"1-2"}` → normalized, `name == "Title"` - - `tools/call figma_get_node {id:"99:99"}` → `isError:true` + - initialize response echoes id 1, `serverInfo.name == "figmog"`, and a non-empty `instructions` string mentioning "official Figma MCP" + - `tools/list` returns exactly 17 tools, all named `figmog_*`, incl. `figmog_search`, `figmog_where`, `figmog_sync` + - `tools/call figmog_search {query:"garden"}` → `isError:false`, text parses to JSON whose first hit id is `1:2` + - `tools/call figmog_node {id:"1-2"}` → normalized, `name == "Title"` + - `tools/call figmog_where {pointer:"/layoutMode", equals:"VERTICAL"}` → one row, id `1:1` + - `tools/call figmog_node {id:"99:99"}` → `isError:true` - unknown method → error `-32601`; unknown tool → `isError:true` -- [ ] **Step 2:** README: new "Use from agents (MCP)" section — what `serve` is (server + built-in sync, one process), the `claude mcp add figmog -- /target/debug/figmog serve ` snippet (note: build first with `cargo build -p figmog`; or `--db`/`--no-watch` for offline), the 12-tool table (copy spec §11's), the note that only `figma_sync` spends rate budget. Workspace README bullet gains "and an MCP server (`figmog serve`)". +- [ ] **Step 2:** README: new "Use from agents (MCP)" section — what `serve` is (server + built-in sync, one process), the `claude mcp add figmog -- /target/debug/figmog serve ` snippet (note: build first with `cargo build -p figmog`; or `--db`/`--no-watch` for offline), both tool tables from spec §11 (17 tools), the "radically different from Figma's official MCP" positioning paragraph (namespace, steering instructions, zero capability overlap; only `figmog_sync` spends rate budget), and the five new structural CLI commands added to the command reference table. Workspace README bullet gains "and an MCP server (`figmog serve`)". - [ ] **Step 3:** Full gates: `cargo test -p figmog` (now incl. serve e2e), clippy, fmt, `cargo test -p fold`, `cargo doc -p figmog --no-deps`. - [ ] **Step 4:** Commit: `feat(figmog): serve e2e tests and MCP docs` ## Self-review checklist -- Spec §11 coverage: architecture → T3; query refactor → T1; protocol behaviors → T2 (all seven bullets are unit-tested); tools table → T3 registry + T4 README; testing section → T2 unit / T1 equivalence / T4 e2e. Non-goals respected (no resources/prompts, stdio only). +- Spec §11 coverage: architecture → T4; query refactor → T1; protocol behaviors incl. `instructions` steering → T2 (unit-tested); structural query pack → T3; 17-tool registry → T4 + T5 README; distinct-namespace/steering rule → T2 (initialize) + T4 (names) + T5 (README positioning); testing section → T2 unit / T1 equivalence / T3 cli / T5 e2e. Non-goals respected (no resources/prompts, stdio only; cached-proxy documented as v3, not built). - The T1 refactor is the risk center: its acceptance gate ("existing tests pass unmodified") is what keeps v1 behavior frozen. -- T3's EOF-exit contract is stated in T4 Step 1 because the test depends on it; implementer of T3 must read T4's step (noted in dispatch). +- T4's EOF-exit contract is stated in T5 Step 1 because the test depends on it; implementer of T4 must read T5's step (noted in dispatch). +- Execution order: 1 → 2 → 3 → 4 → 5 (T3 must land before T4 so the registry can bind all 17 tools). diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index d2d313d..b2f1c0c 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -508,6 +508,32 @@ stdin ──▶ reader thread ──▶ mpsc ──▶ main loop ── requests; a pull blocks request handling for its duration (documented — seconds at worst, and only when the file actually changed). +### Relationship to Figma's official MCP server (binding) + +figmog must never be confusable with Figma's official MCP server. Three +enforced distinctions: + +1. **Distinct namespace:** every tool is `figmog_*`. Figma's native tools + are unprefixed (`get_code`, `get_screenshot`, `get_variable_defs`, …); + there is no name collision and no tool on either server that overlaps + the other's capability. figmog ships nothing codegen- or + screenshot-shaped; the native server has nothing query-shaped. +2. **Server-level steering:** the `initialize` result's `instructions` + field carries, verbatim: "figmog is a local, instant, rate-limit-free + mirror of one Figma file. Use figmog tools for ALL structure, search, + components, styles, and variables. Use the official Figma MCP only for + code generation or screenshots — never for reads figmog can answer." +3. **Cost transparency:** every tool description states that it reads the + local mirror at zero API cost; `figmog_sync` alone is labeled as + spending Figma rate budget. + +**v3 direction (documented, not built):** for paid seats with the desktop +Dev Mode server available, figmog could become a *cached proxy* — the only +Figma-facing MCP an agent sees — forwarding codegen/screenshot tools to +the native server and caching responses keyed by (tool, args, file +version). Out of scope until the native server is reachable in a target +environment; on free plans there is nothing to proxy. + ### Tools Read tools mirror the CLI one-to-one, each returning the `query::*` JSON @@ -515,24 +541,39 @@ as an MCP text content block. Names and inputs: | tool | input schema (all fields optional unless noted) | |---|---| -| `figma_status` | — | -| `figma_pages` | — | -| `figma_tree` | `id`, `depth` (integer) | -| `figma_get_node` | `id` (required), `children` (bool) | -| `figma_find` | `type` (required), `page` | -| `figma_search` | `query` (required), `limit` (integer, default 10) | -| `figma_instances` | `target` (required) | -| `figma_components` | — | -| `figma_styles` | `type`, `values` (bool) | -| `figma_uses` | `id` (required) | -| `figma_vars` | `id` | -| `figma_sync` | — (forces one pull; returns churn; the only tool that spends rate budget) | +| `figmog_status` | — | +| `figmog_pages` | — | +| `figmog_tree` | `id`, `depth` (integer) | +| `figmog_node` | `id` (required), `children` (bool) | +| `figmog_find` | `type` (required), `page` | +| `figmog_search` | `query` (required), `limit` (integer, default 10) | +| `figmog_instances` | `target` (required) | +| `figmog_components` | — | +| `figmog_styles` | `type`, `values` (bool) | +| `figmog_uses` | `id` (required) | +| `figmog_vars` | `id` | +| `figmog_sync` | — (forces one pull; returns churn; the only tool that spends rate budget) | + +**Whole-file structural queries** (the local mirror's unfair advantage — +each is a full-file answer no rate-limited API surface could offer; all +are read-only scans/joins over existing sinks, and each gets a matching +CLI subcommand so the CLI/tool one-to-one rule holds): + +| tool / CLI command | input | answer | +|---|---|---| +| `figmog_stats` / `figmog stats` | — | node counts by type and by page, component/set/style/variable totals, text-node count, max tree depth | +| `figmog_path` / `figmog path ` | `id` (required) | ancestor chain root→node as `[{id, name, type}]` | +| `figmog_text` / `figmog text [--page id]` | `page` | every TEXT node's (id, characters, page_id), sorted by id | +| `figmog_where` / `figmog where --pointer /p --equals ` | `pointer` (required, RFC 6901 into node `raw`), `equals` (JSON value; omitted ⇒ "pointer exists"), `page` | matching `[{id, name, type, page_id, value}]`, sorted by id | +| `figmog_at` / `figmog at --x N --y N` | `x`, `y` (required, floats) | nodes whose `abs_bounds` contain the point, sorted by area ascending (deepest/smallest first) | + +`figmog_where`'s `equals` compares the pointed-at value by JSON equality +(numbers per serde_json semantics). Full-node scans are acceptable: they +run against the local store, not Figma. Tool-level failures (unknown node, no mirror, sync error) return an MCP result with `isError: true` and the message as text — JSON-RPC errors are -reserved for protocol-level problems. Every tool description states -whether it reads locally (all of them) or spends Figma budget (`figma_sync` -only), so agents can reason about cost. +reserved for protocol-level problems. ### CLI surface @@ -543,9 +584,10 @@ resolution identical to the other commands. ### Testing - **Protocol unit tests** (`mcp.rs`): dispatch table over scripted - request values — initialize echo, tools/list shape (12 tools, valid - JSON-Schema inputs), unknown method `-32601`, parse error `-32700`, - tools/call routing incl. `isError` on a bad tool name. + request values — initialize echo (incl. the steering `instructions`), + tools/list shape (17 tools, valid JSON-Schema inputs), unknown method + `-32601`, parse error `-32700`, tools/call routing incl. `isError` on a + bad tool name. - **`query` equivalence:** the CLI smoke tests keep passing unchanged after the refactor (the printers now consume `query::*`), proving the refactor moved logic without changing it. From f88ca86fe4229b4eaff87a48602e0f3b72cd29b5 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 22:53:52 -0700 Subject: [PATCH 23/56] refactor(figmog): extract query layer shared by CLI and MCP Co-Authored-By: Claude Fable 5 --- examples/figmog/src/cli.rs | 442 ++++----------------------------- examples/figmog/src/lib.rs | 1 + examples/figmog/src/query.rs | 465 +++++++++++++++++++++++++++++++++++ 3 files changed, 517 insertions(+), 391 deletions(-) create mode 100644 examples/figmog/src/query.rs diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 703919c..21b0817 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -1,31 +1,27 @@ //! Command-line surface. Read commands never touch the network: they open //! the local store and read one snapshot. -use std::collections::{BTreeSet, HashMap}; +use std::collections::BTreeSet; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use clap::{Parser, Subcommand}; use serde_json::{Value, json}; -use fold::pipeline::terminal::search::Bm25Reader; use fold::pipeline::terminal::{InvertedIndexReader, MultimapReader, TableReader}; use fold::stream::Readable; use crate::api::{ApiError, FigmaApi, UreqApi}; use crate::flatten::flatten_file; -use crate::ident::{normalize_node_id, parse_file_ref}; +use crate::ident::parse_file_ref; use crate::model::{ ComponentRec, ComponentSetRec, FileMeta, Id, NodeRec, StyleRec, VariableCollectionRec, VariableRec, }; +use crate::query::{self, TextReader}; use crate::store::{Churn, collect_sweepable, sync}; use crate::watch::{BACKOFF_CAP, BACKOFF_START, Tick, Watcher}; -/// Read handle for the pipeline's `text` BM25 sink (its tokenizer type -/// param makes the full type unwieldy at every call site). -type TextReader<'tx, R> = Bm25Reader<'tx, R, String, fn(&str, &mut Vec)>; - #[derive(Parser)] #[command(name = "figmog", about = "fold-backed local mirror of a Figma file")] struct Cli { @@ -523,23 +519,16 @@ fn cmd_status( meta: &TableReader<'_, R, u8, FileMeta>, json: bool, ) -> Result<(), String> { - let m = meta - .get(&0) - .ok_or_else(|| "no mirror here — run `figmog pull ` first".to_string())?; - let count = nodes.iter().count(); + let v = query::status(nodes, meta)?; if json { - let v = json!({ - "name": m.name, - "version": m.version, - "last_modified": m.last_modified, - "synced_at_unix_ms": m.synced_at_unix_ms, - "nodes": count, - }); println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { println!( - "{} v{} — {count} nodes (last modified {})", - m.name, m.version, m.last_modified + "{} v{} — {} nodes (last modified {})", + v["name"].as_str().unwrap_or_default(), + v["version"].as_str().unwrap_or_default(), + v["nodes"].as_u64().unwrap_or_default(), + v["last_modified"].as_str().unwrap_or_default(), ); } Ok(()) @@ -550,75 +539,22 @@ fn cmd_pages( by_type: &InvertedIndexReader<'_, R, String, String>, json: bool, ) -> Result<(), String> { - let mut ids = by_type.search(&"CANVAS".to_string()); - ids.sort(); - - let mut pages: Vec<(u32, String, String)> = ids - .into_iter() - .filter_map(|id| nodes.get(&id).map(|n| (n.child_index, n.id, n.name))) - .collect(); - pages.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); - + let v = query::pages(nodes, by_type)?; if json { - let arr: Vec = pages - .iter() - .map(|(_, id, name)| json!({"id": id, "name": name})) - .collect(); - println!( - "{}", - serde_json::to_string(&arr).map_err(|e| e.to_string())? - ); + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { - for (_, id, name) in &pages { - println!("{name} {id}"); + for row in v.as_array().into_iter().flatten() { + println!( + "{} {}", + row["name"].as_str().unwrap_or_default(), + row["id"].as_str().unwrap_or_default(), + ); } } Ok(()) } -/// One level of a `tree` outline; JSON shape `{id, name, type, children}`. -struct TreeNode { - id: String, - name: String, - node_type: String, - children: Vec, -} - -fn build_tree( - nodes: &TableReader<'_, R, String, NodeRec>, - children: &MultimapReader<'_, R, String, (u32, String)>, - node: &NodeRec, - depth: Option, -) -> TreeNode { - let mut kids = Vec::new(); - if depth != Some(0) { - let mut edges = children.get(&node.id); - edges.sort(); - let next_depth = depth.map(|d| d - 1); - for (_, child_id) in edges { - if let Some(child) = nodes.get(&child_id) { - kids.push(build_tree(nodes, children, &child, next_depth)); - } - } - } - TreeNode { - id: node.id.clone(), - name: node.name.clone(), - node_type: node.node_type.clone(), - children: kids, - } -} - -fn tree_to_json(t: &TreeNode) -> Value { - json!({ - "id": t.id, - "name": t.name, - "type": t.node_type, - "children": t.children.iter().map(tree_to_json).collect::>(), - }) -} - -fn print_tree_human(t: &TreeNode, indent: usize) { +fn print_tree_human(t: &query::TreeNode, indent: usize) { println!( "{}{} [{}] {}", " ".repeat(indent), @@ -639,28 +575,12 @@ fn cmd_tree( depth: Option, json: bool, ) -> Result<(), String> { - let start = match id { - Some(raw) => normalize_node_id(&raw), - None => { - let mut docs = by_type.search(&"DOCUMENT".to_string()); - docs.sort(); - docs.into_iter() - .next() - .ok_or_else(|| "no DOCUMENT node in the mirror".to_string())? - } - }; - let root = nodes - .get(&start) - .ok_or_else(|| format!("no node {start} in the mirror"))?; - let tree = build_tree(nodes, children, &root, depth); - if json { - println!( - "{}", - serde_json::to_string(&tree_to_json(&tree)).map_err(|e| e.to_string())? - ); + let v = query::tree(nodes, children, by_type, id, depth)?; + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { - print_tree_human(&tree, 0); + let t = query::tree_nodes(nodes, children, by_type, id, depth)?; + print_tree_human(&t, 0); } Ok(()) } @@ -672,28 +592,7 @@ fn cmd_get( with_children: bool, _json: bool, ) -> Result<(), String> { - let id = normalize_node_id(&id); - let node = nodes - .get(&id) - .ok_or_else(|| format!("no node {id} in the mirror"))?; - let mut value: Value = serde_json::from_str(&node.raw).map_err(|e| e.to_string())?; - - if with_children { - let mut edges = children.get(&id); - edges.sort(); - let kids: Vec = edges - .into_iter() - .filter_map(|(_, child_id)| { - nodes - .get(&child_id) - .map(|n| json!({"id": n.id, "name": n.name, "type": n.node_type})) - }) - .collect(); - if let Some(obj) = value.as_object_mut() { - obj.insert("children".to_string(), Value::Array(kids)); - } - } - + let value = query::node(nodes, children, id, with_children)?; // Get's output is always JSON, whether or not --json was passed. println!( "{}", @@ -709,32 +608,17 @@ fn cmd_find( page: Option, json: bool, ) -> Result<(), String> { - // Figma node types are stored uppercase; normalize so `--type frame` - // matches the same as `--type FRAME`. - let mut ids = by_type.search(&node_type.to_uppercase()); - ids.sort(); - let page = page.as_deref().map(normalize_node_id); - - let mut rows: Vec<(String, String, String)> = ids - .into_iter() - .filter_map(|id| nodes.get(&id)) - .filter(|n| page.as_deref().is_none_or(|p| n.page_id == p)) - .map(|n| (n.id, n.name, n.page_id)) - .collect(); - rows.sort(); - + let v = query::find(nodes, by_type, node_type, page)?; if json { - let arr: Vec = rows - .iter() - .map(|(id, name, page_id)| json!({"id": id, "name": name, "page_id": page_id})) - .collect(); - println!( - "{}", - serde_json::to_string(&arr).map_err(|e| e.to_string())? - ); + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { - for (id, name, page_id) in &rows { - println!("{id} {name} ({page_id})"); + for row in v.as_array().into_iter().flatten() { + println!( + "{} {} ({})", + row["id"].as_str().unwrap_or_default(), + row["name"].as_str().unwrap_or_default(), + row["page_id"].as_str().unwrap_or_default(), + ); } } Ok(()) @@ -749,34 +633,11 @@ fn cmd_search( limit: usize, json: bool, ) -> Result<(), String> { - // BM25's own ranking order is deterministic; keep it (do not re-sort). - let hits = text.search(&query, limit); - let rows: Vec = hits - .iter() - .filter_map(|hit| { - let node = nodes.get(&hit.val)?; - let snippet = node - .text - .as_ref() - .map(|t| t.chars().take(80).collect::()); - Some(json!({ - "id": node.id, - "score": hit.score, - "type": node.node_type, - "name": node.name, - "page_id": node.page_id, - "snippet": snippet, - })) - }) - .collect(); - + let v = query::search(text, nodes, &query, limit)?; if json { - println!( - "{}", - serde_json::to_string(&rows).map_err(|e| e.to_string())? - ); + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { - for row in &rows { + for row in v.as_array().into_iter().flatten() { println!( "{} {:.3} [{}] {}", row["id"].as_str().unwrap_or_default(), @@ -789,52 +650,6 @@ fn cmd_search( Ok(()) } -/// Resolve a target (node id, component key, or component/set name) to the -/// component node ids it names, in priority order: exact node id, then key, -/// then set name (all variants), then component name (all matches). -fn resolve_component_ids( - components: &TableReader<'_, R, String, ComponentRec>, - component_sets: &TableReader<'_, R, String, ComponentSetRec>, - target: &str, -) -> Vec { - if components.contains(&target.to_string()) { - return vec![target.to_string()]; - } - - let mut ids: Vec = components - .iter() - .filter(|(_, c)| c.key == target) - .map(|(id, _)| id) - .collect(); - if !ids.is_empty() { - return ids; - } - - let set_ids: Vec = component_sets - .iter() - .filter(|(_, s)| s.name == target) - .map(|(id, _)| id) - .collect(); - if !set_ids.is_empty() { - ids = components - .iter() - .filter(|(_, c)| { - c.component_set_id - .as_deref() - .is_some_and(|s| set_ids.iter().any(|sid| sid == s)) - }) - .map(|(id, _)| id) - .collect(); - return ids; - } - - components - .iter() - .filter(|(_, c)| c.name == target) - .map(|(id, _)| id) - .collect() -} - fn cmd_instances( nodes: &TableReader<'_, R, String, NodeRec>, instances_of: &InvertedIndexReader<'_, R, String, String>, @@ -843,27 +658,11 @@ fn cmd_instances( target: String, json: bool, ) -> Result<(), String> { - let target = normalize_node_id(&target); - let component_ids = resolve_component_ids(components, component_sets, &target); - - let mut instance_ids: BTreeSet = BTreeSet::new(); - for cid in &component_ids { - instance_ids.extend(instances_of.search(cid)); - } - - let rows: Vec = instance_ids - .iter() - .filter_map(|id| nodes.get(id)) - .map(|n| json!({"id": n.id, "name": n.name, "page_id": n.page_id, "component_id": n.component_id})) - .collect(); - + let v = query::instances(nodes, components, component_sets, instances_of, &target)?; if json { - println!( - "{}", - serde_json::to_string(&rows).map_err(|e| e.to_string())? - ); + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { - for row in &rows { + for row in v.as_array().into_iter().flatten() { println!( "{} {} ({})", row["id"].as_str().unwrap_or_default(), @@ -881,57 +680,18 @@ fn cmd_components( nodes: &TableReader<'_, R, String, NodeRec>, json: bool, ) -> Result<(), String> { - let mut sets: Vec<(String, ComponentSetRec)> = component_sets.iter().collect(); - sets.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut all_components: Vec<(String, ComponentRec)> = components.iter().collect(); - all_components.sort_by(|a, b| a.0.cmp(&b.0)); - - let sets_json: Vec = sets - .iter() - .map(|(set_id, set)| { - let variants: Vec = all_components - .iter() - .filter(|(_, c)| c.component_set_id.as_deref() == Some(set_id.as_str())) - .map(|(cid, c)| json!({"node_id": cid, "name": c.name, "key": c.key})) - .collect(); - let property_definitions: Value = nodes - .get(set_id) - .and_then(|n| n.property_definitions.clone()) - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or(Value::Null); - json!({ - "node_id": set_id, - "name": set.name, - "key": set.key, - "variants": variants, - "property_definitions": property_definitions, - }) - }) - .collect(); - - let standalone: Vec = all_components - .iter() - .filter(|(_, c)| c.component_set_id.is_none()) - .map(|(cid, c)| json!({"node_id": cid, "name": c.name, "key": c.key})) - .collect(); - - let out = json!({"sets": sets_json, "components": standalone}); - + let v = query::components(nodes, components, component_sets)?; if json { - println!( - "{}", - serde_json::to_string(&out).map_err(|e| e.to_string())? - ); + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { - for s in &sets_json { + for s in v["sets"].as_array().into_iter().flatten() { println!( "{} {} variants", s["name"].as_str().unwrap_or_default(), s["variants"].as_array().map(Vec::len).unwrap_or(0), ); } - for c in &standalone { + for c in v["components"].as_array().into_iter().flatten() { println!( "{} {}", c["node_id"].as_str().unwrap_or_default(), @@ -950,43 +710,11 @@ fn cmd_styles( values: bool, json: bool, ) -> Result<(), String> { - let mut rows: Vec<(String, StyleRec)> = styles.iter().collect(); - rows.sort_by(|a, b| a.0.cmp(&b.0)); - if let Some(t) = &style_type { - rows.retain(|(_, s)| s.style_type.eq_ignore_ascii_case(t)); - } - - let out: Vec = rows - .iter() - .map(|(style_id, s)| { - let mut consumers = styled_by.search(style_id); - consumers.sort(); - let mut obj = json!({ - "style_id": style_id, - "name": s.name, - "key": s.key, - "type": s.style_type, - "uses": consumers.len(), - }); - if values { - let value = consumers - .first() - .and_then(|nid| nodes.get(nid)) - .and_then(|n| crate::vars::style_value_from_consumer(&s.style_type, &n.raw)) - .unwrap_or(Value::Null); - obj["value"] = value; - } - obj - }) - .collect(); - + let v = query::styles(nodes, styles, styled_by, style_type, values)?; if json { - println!( - "{}", - serde_json::to_string(&out).map_err(|e| e.to_string())? - ); + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { - for row in &out { + for row in v.as_array().into_iter().flatten() { println!( "{} {} [{}] uses={}", row["style_id"].as_str().unwrap_or_default(), @@ -1006,25 +734,11 @@ fn cmd_uses( id: String, json: bool, ) -> Result<(), String> { - let mut ids = styled_by.search(&id); - if ids.is_empty() { - ids = bound_to.search(&id); - } - ids.sort(); - - let rows: Vec = ids - .iter() - .filter_map(|nid| nodes.get(nid)) - .map(|n| json!({"id": n.id, "name": n.name, "page_id": n.page_id})) - .collect(); - + let v = query::uses(nodes, styled_by, bound_to, &id)?; if json { - println!( - "{}", - serde_json::to_string(&rows).map_err(|e| e.to_string())? - ); + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { - for row in &rows { + for row in v.as_array().into_iter().flatten() { println!( "{} {} ({})", row["id"].as_str().unwrap_or_default(), @@ -1043,65 +757,11 @@ fn cmd_vars( id: Option, json: bool, ) -> Result<(), String> { - let owned_nodes: Vec = nodes.iter().map(|(_, n)| n).collect(); - let inferred = crate::vars::infer_from_nodes(owned_nodes.iter()); - let mut inferred_by_id: HashMap = inferred - .into_iter() - .map(|u| (u.variable_id.clone(), u)) - .collect(); - - let mut all_ids: BTreeSet = inferred_by_id.keys().cloned().collect(); - all_ids.extend(variables.iter().map(|(k, _)| k)); - if let Some(target) = &id { - all_ids.retain(|v| v == target); - } - - let rows: Vec = all_ids - .iter() - .map(|vid| { - let usage = inferred_by_id.remove(vid); - let (sites, observed) = usage.map(|u| (u.sites, u.observed)).unwrap_or_default(); - - if let Some(var) = variables.get(vid) { - let collection = variable_collections.get(&var.collection_id); - let mut values_by_mode = serde_json::Map::new(); - for (mode_id, val_str) in &var.values_by_mode { - let mode_name = collection - .as_ref() - .and_then(|c| c.modes.iter().find(|(mid, _)| mid == mode_id)) - .map(|(_, name)| name.clone()) - .unwrap_or_else(|| mode_id.clone()); - let val: Value = serde_json::from_str(val_str).unwrap_or(Value::Null); - values_by_mode.insert(mode_name, val); - } - json!({ - "variable_id": vid, - "source": "imported", - "name": var.name, - "resolved_type": var.resolved_type, - "collection": collection.map(|c| c.name), - "values_by_mode": Value::Object(values_by_mode), - "sites": sites, - "observed": observed, - }) - } else { - json!({ - "variable_id": vid, - "source": "inferred", - "sites": sites, - "observed": observed, - }) - } - }) - .collect(); - + let v = query::vars(nodes, variables, variable_collections, id)?; if json { - println!( - "{}", - serde_json::to_string(&rows).map_err(|e| e.to_string())? - ); + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); } else { - for row in &rows { + for row in v.as_array().into_iter().flatten() { println!( "{} [{}] sites={}", row["variable_id"].as_str().unwrap_or_default(), diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs index fddd754..c72db5c 100644 --- a/examples/figmog/src/lib.rs +++ b/examples/figmog/src/lib.rs @@ -14,6 +14,7 @@ pub mod cli; pub mod flatten; pub mod ident; pub mod model; +pub mod query; pub mod store; pub mod vars; pub mod watch; diff --git a/examples/figmog/src/query.rs b/examples/figmog/src/query.rs new file mode 100644 index 0000000..61a21ef --- /dev/null +++ b/examples/figmog/src/query.rs @@ -0,0 +1,465 @@ +//! One source of truth for every read answer — shared by the CLI printers +//! and the MCP tools. + +use std::collections::{BTreeSet, HashMap}; + +use serde_json::{Value, json}; + +use fold::pipeline::terminal::search::Bm25Reader; +use fold::pipeline::terminal::{InvertedIndexReader, MultimapReader, TableReader}; +use fold::stream::Readable; + +use crate::ident::normalize_node_id; +use crate::model::{ + ComponentRec, ComponentSetRec, FileMeta, NodeRec, StyleRec, VariableCollectionRec, VariableRec, +}; + +/// Read handle for the pipeline's `text` BM25 sink (its tokenizer type +/// param makes the full type unwieldy at every call site). +pub type TextReader<'tx, R> = Bm25Reader<'tx, R, String, fn(&str, &mut Vec)>; + +/// File name, version, last modified, node count. +pub fn status( + nodes: &TableReader<'_, R, String, NodeRec>, + meta: &TableReader<'_, R, u8, FileMeta>, +) -> Result { + let m = meta + .get(&0) + .ok_or_else(|| "no mirror here — run `figmog pull ` first".to_string())?; + let count = nodes.iter().count(); + Ok(json!({ + "name": m.name, + "version": m.version, + "last_modified": m.last_modified, + "synced_at_unix_ms": m.synced_at_unix_ms, + "nodes": count, + })) +} + +/// List pages, ordered by document child index. +pub fn pages( + nodes: &TableReader<'_, R, String, NodeRec>, + by_type: &InvertedIndexReader<'_, R, String, String>, +) -> Result { + let mut ids = by_type.search(&"CANVAS".to_string()); + ids.sort(); + + let mut rows: Vec<(u32, String, String)> = ids + .into_iter() + .filter_map(|id| nodes.get(&id).map(|n| (n.child_index, n.id, n.name))) + .collect(); + rows.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); + + let arr: Vec = rows + .iter() + .map(|(_, id, name)| json!({"id": id, "name": name})) + .collect(); + Ok(Value::Array(arr)) +} + +/// One level of a `tree` outline; JSON shape `{id, name, type, children}`. +pub struct TreeNode { + pub id: String, + pub name: String, + pub node_type: String, + pub children: Vec, +} + +pub fn build_tree( + nodes: &TableReader<'_, R, String, NodeRec>, + children: &MultimapReader<'_, R, String, (u32, String)>, + node: &NodeRec, + depth: Option, +) -> TreeNode { + let mut kids = Vec::new(); + if depth != Some(0) { + let mut edges = children.get(&node.id); + edges.sort(); + let next_depth = depth.map(|d| d - 1); + for (_, child_id) in edges { + if let Some(child) = nodes.get(&child_id) { + kids.push(build_tree(nodes, children, &child, next_depth)); + } + } + } + TreeNode { + id: node.id.clone(), + name: node.name.clone(), + node_type: node.node_type.clone(), + children: kids, + } +} + +fn tree_to_json(t: &TreeNode) -> Value { + json!({ + "id": t.id, + "name": t.name, + "type": t.node_type, + "children": t.children.iter().map(tree_to_json).collect::>(), + }) +} + +/// Resolve the root (default: the DOCUMENT node) and build its outline as a +/// [`TreeNode`], so callers that render for humans can walk the same +/// structure `tree`'s JSON is built from. +pub fn tree_nodes( + nodes: &TableReader<'_, R, String, NodeRec>, + children: &MultimapReader<'_, R, String, (u32, String)>, + by_type: &InvertedIndexReader<'_, R, String, String>, + id: Option, + depth: Option, +) -> Result { + let start = match id { + Some(raw) => normalize_node_id(&raw), + None => { + let mut docs = by_type.search(&"DOCUMENT".to_string()); + docs.sort(); + docs.into_iter() + .next() + .ok_or_else(|| "no DOCUMENT node in the mirror".to_string())? + } + }; + let root = nodes + .get(&start) + .ok_or_else(|| format!("no node {start} in the mirror"))?; + Ok(build_tree(nodes, children, &root, depth)) +} + +/// Subtree outline (default: whole document). +pub fn tree( + nodes: &TableReader<'_, R, String, NodeRec>, + children: &MultimapReader<'_, R, String, (u32, String)>, + by_type: &InvertedIndexReader<'_, R, String, String>, + id: Option, + depth: Option, +) -> Result { + let t = tree_nodes(nodes, children, by_type, id, depth)?; + Ok(tree_to_json(&t)) +} + +/// Full raw JSON of one node, optionally with a `children` summary array. +pub fn node( + nodes: &TableReader<'_, R, String, NodeRec>, + children: &MultimapReader<'_, R, String, (u32, String)>, + id: String, + with_children: bool, +) -> Result { + let id = normalize_node_id(&id); + let n = nodes + .get(&id) + .ok_or_else(|| format!("no node {id} in the mirror"))?; + let mut value: Value = serde_json::from_str(&n.raw).map_err(|e| e.to_string())?; + + if with_children { + let mut edges = children.get(&id); + edges.sort(); + let kids: Vec = edges + .into_iter() + .filter_map(|(_, child_id)| { + nodes + .get(&child_id) + .map(|n| json!({"id": n.id, "name": n.name, "type": n.node_type})) + }) + .collect(); + if let Some(obj) = value.as_object_mut() { + obj.insert("children".to_string(), Value::Array(kids)); + } + } + + Ok(value) +} + +/// Nodes by type, optionally within one page. +pub fn find( + nodes: &TableReader<'_, R, String, NodeRec>, + by_type: &InvertedIndexReader<'_, R, String, String>, + node_type: String, + page: Option, +) -> Result { + // Figma node types are stored uppercase; normalize so `--type frame` + // matches the same as `--type FRAME`. + let mut ids = by_type.search(&node_type.to_uppercase()); + ids.sort(); + let page = page.as_deref().map(normalize_node_id); + + let mut rows: Vec<(String, String, String)> = ids + .into_iter() + .filter_map(|id| nodes.get(&id)) + .filter(|n| page.as_deref().is_none_or(|p| n.page_id == p)) + .map(|n| (n.id, n.name, n.page_id)) + .collect(); + rows.sort(); + + let arr: Vec = rows + .iter() + .map(|(id, name, page_id)| json!({"id": id, "name": name, "page_id": page_id})) + .collect(); + Ok(Value::Array(arr)) +} + +/// BM25 search over layer names and text content. +pub fn search( + text: &TextReader<'_, R>, + nodes: &TableReader<'_, R, String, NodeRec>, + query: &str, + limit: usize, +) -> Result { + // BM25's own ranking order is deterministic; keep it (do not re-sort). + let hits = text.search(query, limit); + let rows: Vec = hits + .iter() + .filter_map(|hit| { + let node = nodes.get(&hit.val)?; + let snippet = node + .text + .as_ref() + .map(|t| t.chars().take(80).collect::()); + Some(json!({ + "id": node.id, + "score": hit.score, + "type": node.node_type, + "name": node.name, + "page_id": node.page_id, + "snippet": snippet, + })) + }) + .collect(); + Ok(Value::Array(rows)) +} + +/// Resolve a target (node id, component key, or component/set name) to the +/// component node ids it names, in priority order: exact node id, then key, +/// then set name (all variants), then component name (all matches). +fn resolve_component_ids( + components: &TableReader<'_, R, String, ComponentRec>, + component_sets: &TableReader<'_, R, String, ComponentSetRec>, + target: &str, +) -> Vec { + if components.contains(&target.to_string()) { + return vec![target.to_string()]; + } + + let mut ids: Vec = components + .iter() + .filter(|(_, c)| c.key == target) + .map(|(id, _)| id) + .collect(); + if !ids.is_empty() { + return ids; + } + + let set_ids: Vec = component_sets + .iter() + .filter(|(_, s)| s.name == target) + .map(|(id, _)| id) + .collect(); + if !set_ids.is_empty() { + ids = components + .iter() + .filter(|(_, c)| { + c.component_set_id + .as_deref() + .is_some_and(|s| set_ids.iter().any(|sid| sid == s)) + }) + .map(|(id, _)| id) + .collect(); + return ids; + } + + components + .iter() + .filter(|(_, c)| c.name == target) + .map(|(id, _)| id) + .collect() +} + +/// Instances of a component (by node id, key, or name). +pub fn instances( + nodes: &TableReader<'_, R, String, NodeRec>, + components: &TableReader<'_, R, String, ComponentRec>, + component_sets: &TableReader<'_, R, String, ComponentSetRec>, + instances_of: &InvertedIndexReader<'_, R, String, String>, + target: &str, +) -> Result { + let target = normalize_node_id(target); + let component_ids = resolve_component_ids(components, component_sets, &target); + + let mut instance_ids: BTreeSet = BTreeSet::new(); + for cid in &component_ids { + instance_ids.extend(instances_of.search(cid)); + } + + let rows: Vec = instance_ids + .iter() + .filter_map(|id| nodes.get(id)) + .map(|n| json!({"id": n.id, "name": n.name, "page_id": n.page_id, "component_id": n.component_id})) + .collect(); + Ok(Value::Array(rows)) +} + +/// Design-system inventory: sets, variant axes, standalone components. +pub fn components( + nodes: &TableReader<'_, R, String, NodeRec>, + components: &TableReader<'_, R, String, ComponentRec>, + component_sets: &TableReader<'_, R, String, ComponentSetRec>, +) -> Result { + let mut sets: Vec<(String, ComponentSetRec)> = component_sets.iter().collect(); + sets.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut all_components: Vec<(String, ComponentRec)> = components.iter().collect(); + all_components.sort_by(|a, b| a.0.cmp(&b.0)); + + let sets_json: Vec = sets + .iter() + .map(|(set_id, set)| { + let variants: Vec = all_components + .iter() + .filter(|(_, c)| c.component_set_id.as_deref() == Some(set_id.as_str())) + .map(|(cid, c)| json!({"node_id": cid, "name": c.name, "key": c.key})) + .collect(); + let property_definitions: Value = nodes + .get(set_id) + .and_then(|n| n.property_definitions.clone()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(Value::Null); + json!({ + "node_id": set_id, + "name": set.name, + "key": set.key, + "variants": variants, + "property_definitions": property_definitions, + }) + }) + .collect(); + + let standalone: Vec = all_components + .iter() + .filter(|(_, c)| c.component_set_id.is_none()) + .map(|(cid, c)| json!({"node_id": cid, "name": c.name, "key": c.key})) + .collect(); + + Ok(json!({"sets": sets_json, "components": standalone})) +} + +/// Styles with usage counts; `values` derives definitions from consumers. +pub fn styles( + nodes: &TableReader<'_, R, String, NodeRec>, + styles: &TableReader<'_, R, String, StyleRec>, + styled_by: &InvertedIndexReader<'_, R, String, String>, + style_type: Option, + values: bool, +) -> Result { + let mut rows: Vec<(String, StyleRec)> = styles.iter().collect(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + if let Some(t) = &style_type { + rows.retain(|(_, s)| s.style_type.eq_ignore_ascii_case(t)); + } + + let out: Vec = rows + .iter() + .map(|(style_id, s)| { + let mut consumers = styled_by.search(style_id); + consumers.sort(); + let mut obj = json!({ + "style_id": style_id, + "name": s.name, + "key": s.key, + "type": s.style_type, + "uses": consumers.len(), + }); + if values { + let value = consumers + .first() + .and_then(|nid| nodes.get(nid)) + .and_then(|n| crate::vars::style_value_from_consumer(&s.style_type, &n.raw)) + .unwrap_or(Value::Null); + obj["value"] = value; + } + obj + }) + .collect(); + Ok(Value::Array(out)) +} + +/// Nodes using a style id or bound to a variable id. +pub fn uses( + nodes: &TableReader<'_, R, String, NodeRec>, + styled_by: &InvertedIndexReader<'_, R, String, String>, + bound_to: &InvertedIndexReader<'_, R, String, String>, + id: &str, +) -> Result { + let id = id.to_string(); + let mut ids = styled_by.search(&id); + if ids.is_empty() { + ids = bound_to.search(&id); + } + ids.sort(); + + let rows: Vec = ids + .iter() + .filter_map(|nid| nodes.get(nid)) + .map(|n| json!({"id": n.id, "name": n.name, "page_id": n.page_id})) + .collect(); + Ok(Value::Array(rows)) +} + +/// Variables: authoritative if imported, else inferred from bindings. +pub fn vars( + nodes: &TableReader<'_, R, String, NodeRec>, + variables: &TableReader<'_, R, String, VariableRec>, + variable_collections: &TableReader<'_, R, String, VariableCollectionRec>, + id_filter: Option, +) -> Result { + let owned_nodes: Vec = nodes.iter().map(|(_, n)| n).collect(); + let inferred = crate::vars::infer_from_nodes(owned_nodes.iter()); + let mut inferred_by_id: HashMap = inferred + .into_iter() + .map(|u| (u.variable_id.clone(), u)) + .collect(); + + let mut all_ids: BTreeSet = inferred_by_id.keys().cloned().collect(); + all_ids.extend(variables.iter().map(|(k, _)| k)); + if let Some(target) = &id_filter { + all_ids.retain(|v| v == target); + } + + let rows: Vec = all_ids + .iter() + .map(|vid| { + let usage = inferred_by_id.remove(vid); + let (sites, observed) = usage.map(|u| (u.sites, u.observed)).unwrap_or_default(); + + if let Some(var) = variables.get(vid) { + let collection = variable_collections.get(&var.collection_id); + let mut values_by_mode = serde_json::Map::new(); + for (mode_id, val_str) in &var.values_by_mode { + let mode_name = collection + .as_ref() + .and_then(|c| c.modes.iter().find(|(mid, _)| mid == mode_id)) + .map(|(_, name)| name.clone()) + .unwrap_or_else(|| mode_id.clone()); + let val: Value = serde_json::from_str(val_str).unwrap_or(Value::Null); + values_by_mode.insert(mode_name, val); + } + json!({ + "variable_id": vid, + "source": "imported", + "name": var.name, + "resolved_type": var.resolved_type, + "collection": collection.map(|c| c.name), + "values_by_mode": Value::Object(values_by_mode), + "sites": sites, + "observed": observed, + }) + } else { + json!({ + "variable_id": vid, + "source": "inferred", + "sites": sites, + "observed": observed, + }) + } + }) + .collect(); + Ok(Value::Array(rows)) +} From 6c8a85aca78910b14870ed2e509df47b37e2721e Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 22:58:47 -0700 Subject: [PATCH 24/56] feat(figmog): MCP protocol core (JSON-RPC over stdio frames) Co-Authored-By: Claude Fable 5 --- examples/figmog/src/lib.rs | 1 + examples/figmog/src/mcp.rs | 427 +++++++++++++++++++++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 examples/figmog/src/mcp.rs diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs index c72db5c..d1daf81 100644 --- a/examples/figmog/src/lib.rs +++ b/examples/figmog/src/lib.rs @@ -13,6 +13,7 @@ pub mod api; pub mod cli; pub mod flatten; pub mod ident; +pub mod mcp; pub mod model; pub mod query; pub mod store; diff --git a/examples/figmog/src/mcp.rs b/examples/figmog/src/mcp.rs new file mode 100644 index 0000000..b94b7c6 --- /dev/null +++ b/examples/figmog/src/mcp.rs @@ -0,0 +1,427 @@ +//! MCP protocol core — pure JSON-RPC dispatch over stdio frames. +//! +//! This module owns no I/O and no store: [`handle_message`] takes one raw +//! JSON-RPC message, a registry of [`ToolDef`]s, and a [`ToolHandler`], and +//! returns the response frame to write (or `None` for notifications). The +//! serve loop that actually reads/writes stdio lives in a later task. + +use serde_json::{Value, json}; + +/// figmog is a local, instant, rate-limit-free mirror of one Figma file. Use +/// figmog tools for ALL structure, search, components, styles, and +/// variables. Use the official Figma MCP only for code generation or +/// screenshots — never for reads figmog can answer. +/// +/// This is the exact steering text carried verbatim in the `initialize` +/// result's `instructions` field — see build design §11 "Relationship to +/// Figma's official MCP server", point 2. +const INSTRUCTIONS: &str = "figmog is a local, instant, rate-limit-free mirror of one Figma file. Use figmog tools for ALL structure, search, components, styles, and variables. Use the official Figma MCP only for code generation or screenshots — never for reads figmog can answer."; + +/// The default MCP protocol version echoed when a client's `initialize` +/// request omits `protocolVersion`. +const DEFAULT_PROTOCOL_VERSION: &str = "2025-06-18"; + +/// This server's name, reported in `initialize`'s `serverInfo.name`. +pub const SERVER_NAME: &str = "figmog"; + +/// One registered tool: metadata for `tools/list`. +pub struct ToolDef { + pub name: &'static str, + pub description: &'static str, + /// JSON Schema for the tool's arguments. + pub input_schema: Value, +} + +/// Executes a `tools/call`. `Ok(v)` becomes success content; `Err(msg)` +/// becomes `isError` content. +pub trait ToolHandler { + fn call(&mut self, name: &str, args: &Value) -> Result; +} + +/// Handle one incoming JSON-RPC message. Returns the response frame to +/// write, or `None` for notifications (a message with no `id`, or whose +/// method starts with `notifications/`). +pub fn handle_message( + raw: &str, + tools: &[ToolDef], + handler: &mut dyn ToolHandler, +) -> Option { + let msg: Value = match serde_json::from_str(raw) { + Ok(v) => v, + Err(_) => { + return Some(json!({ + "jsonrpc": "2.0", + "id": Value::Null, + "error": {"code": -32700, "message": "parse error"}, + })); + } + }; + + let id = msg.get("id").cloned(); + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let params = msg.get("params").cloned().unwrap_or(Value::Null); + + // Notifications: no `id`, or method under the `notifications/` namespace. + if id.is_none() || method.starts_with("notifications/") { + return None; + } + let id = id.unwrap(); + + let result = match method { + "initialize" => Some(initialize_result(¶ms)), + "ping" => Some(json!({})), + "tools/list" => Some(tools_list_result(tools)), + "tools/call" => Some(tools_call_result(¶ms, tools, handler)), + _ => None, + }; + + match result { + Some(result) => Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + })), + None => Some(json!({ + "jsonrpc": "2.0", + "id": id, + "error": {"code": -32601, "message": "method not found"}, + })), + } +} + +fn initialize_result(params: &Value) -> Value { + let protocol_version = params + .get("protocolVersion") + .and_then(Value::as_str) + .unwrap_or(DEFAULT_PROTOCOL_VERSION); + json!({ + "protocolVersion": protocol_version, + "capabilities": {"tools": {}}, + "serverInfo": { + "name": SERVER_NAME, + "version": env!("CARGO_PKG_VERSION"), + }, + "instructions": INSTRUCTIONS, + }) +} + +fn tools_list_result(tools: &[ToolDef]) -> Value { + let list: Vec = tools + .iter() + .map(|t| { + json!({ + "name": t.name, + "description": t.description, + "inputSchema": t.input_schema, + }) + }) + .collect(); + json!({"tools": list}) +} + +fn tools_call_result(params: &Value, tools: &[ToolDef], handler: &mut dyn ToolHandler) -> Value { + let name = match params.get("name").and_then(Value::as_str) { + Some(name) => name, + None => return error_content("missing required field: name"), + }; + + if !tools.iter().any(|t| t.name == name) { + return error_content(&format!("unknown tool: {name}")); + } + + let args = params.get("arguments").cloned().unwrap_or(json!({})); + match handler.call(name, &args) { + Ok(v) => json!({ + "content": [{"type": "text", "text": serde_json::to_string(&v).unwrap()}], + "isError": false, + }), + Err(msg) => error_content(&msg), + } +} + +fn error_content(msg: &str) -> Value { + json!({ + "content": [{"type": "text", "text": msg}], + "isError": true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A `ToolHandler` test double: returns `Ok({"ok":true})` for a tool + /// named `"ok"`, `Err("boom")` for a tool named `"err"`, and panics for + /// any other name (the dispatch contract guarantees unknown names never + /// reach the handler). + struct FakeHandler; + + impl ToolHandler for FakeHandler { + fn call(&mut self, name: &str, _args: &Value) -> Result { + match name { + "ok" => Ok(json!({"ok": true})), + "err" => Err("boom".to_string()), + other => panic!("handler should not be called for {other}"), + } + } + } + + fn fake_tools() -> Vec { + vec![ + ToolDef { + name: "ok", + description: "always succeeds", + input_schema: json!({"type": "object"}), + }, + ToolDef { + name: "err", + description: "always fails", + input_schema: json!({"type": "object"}), + }, + ] + } + + #[test] + fn parse_failure_returns_dash_32700_with_null_id() { + let resp = handle_message("not json", &[], &mut FakeHandler).unwrap(); + assert_eq!( + resp, + json!({ + "jsonrpc": "2.0", + "id": null, + "error": {"code": -32700, "message": "parse error"}, + }) + ); + } + + #[test] + fn initialize_echoes_client_protocol_version_and_carries_instructions_verbatim() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2024-11-05"}, + }) + .to_string(); + let resp = handle_message(&raw, &[], &mut FakeHandler).unwrap(); + assert_eq!( + resp, + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "figmog", "version": env!("CARGO_PKG_VERSION")}, + "instructions": "figmog is a local, instant, rate-limit-free mirror of one Figma file. Use figmog tools for ALL structure, search, components, styles, and variables. Use the official Figma MCP only for code generation or screenshots — never for reads figmog can answer.", + }, + }) + ); + } + + #[test] + fn initialize_defaults_protocol_version_when_absent() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {}, + }) + .to_string(); + let resp = handle_message(&raw, &[], &mut FakeHandler).unwrap(); + assert_eq!(resp["result"]["protocolVersion"], json!("2025-06-18")); + } + + #[test] + fn notifications_initialized_returns_none() { + let raw = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }) + .to_string(); + assert_eq!(handle_message(&raw, &[], &mut FakeHandler), None); + } + + #[test] + fn any_notifications_namespaced_method_returns_none_even_with_id() { + // Per the brief, method starting "notifications/" is a notification + // regardless of whether an `id` happens to be present. + let raw = json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "notifications/whatever", + }) + .to_string(); + assert_eq!(handle_message(&raw, &[], &mut FakeHandler), None); + } + + #[test] + fn message_with_no_id_returns_none() { + let raw = json!({ + "jsonrpc": "2.0", + "method": "ping", + }) + .to_string(); + assert_eq!(handle_message(&raw, &[], &mut FakeHandler), None); + } + + #[test] + fn ping_returns_empty_result() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "ping", + }) + .to_string(); + let resp = handle_message(&raw, &[], &mut FakeHandler).unwrap(); + assert_eq!(resp, json!({"jsonrpc": "2.0", "id": 1, "result": {}})); + } + + #[test] + fn tools_list_reflects_fake_slice_in_order() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + }) + .to_string(); + let tools = fake_tools(); + let resp = handle_message(&raw, &tools, &mut FakeHandler).unwrap(); + assert_eq!( + resp, + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [ + {"name": "ok", "description": "always succeeds", "inputSchema": {"type": "object"}}, + {"name": "err", "description": "always fails", "inputSchema": {"type": "object"}}, + ], + }, + }) + ); + } + + #[test] + fn tools_call_success_wraps_handler_ok_as_text_content() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "ok", "arguments": {}}, + }) + .to_string(); + let tools = fake_tools(); + let resp = handle_message(&raw, &tools, &mut FakeHandler).unwrap(); + assert_eq!( + resp, + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"type": "text", "text": "{\"ok\":true}"}], + "isError": false, + }, + }) + ); + } + + #[test] + fn tools_call_handler_error_becomes_is_error_content() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "err", "arguments": {}}, + }) + .to_string(); + let tools = fake_tools(); + let resp = handle_message(&raw, &tools, &mut FakeHandler).unwrap(); + assert_eq!( + resp, + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"type": "text", "text": "boom"}], + "isError": true, + }, + }) + ); + } + + #[test] + fn tools_call_unknown_tool_is_error_without_calling_handler() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "nonexistent", "arguments": {}}, + }) + .to_string(); + let tools = fake_tools(); + // FakeHandler panics if called with an unregistered name, so a clean + // result here proves the handler was never invoked. + let resp = handle_message(&raw, &tools, &mut FakeHandler).unwrap(); + assert_eq!( + resp, + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"type": "text", "text": "unknown tool: nonexistent"}], + "isError": true, + }, + }) + ); + } + + #[test] + fn tools_call_missing_name_is_error_without_calling_handler() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"arguments": {}}, + }) + .to_string(); + let tools = fake_tools(); + let resp = handle_message(&raw, &tools, &mut FakeHandler).unwrap(); + assert_eq!(resp["result"]["isError"], json!(true)); + assert_eq!(resp["result"]["content"][0]["type"], json!("text")); + // A clear message, not a panic or empty string. + let text = resp["result"]["content"][0]["text"].as_str().unwrap(); + assert!(!text.is_empty()); + } + + #[test] + fn unknown_method_with_id_returns_dash_32601() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "totally/bogus", + }) + .to_string(); + let resp = handle_message(&raw, &[], &mut FakeHandler).unwrap(); + assert_eq!( + resp, + json!({ + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -32601, "message": "method not found"}, + }) + ); + } + + #[test] + fn id_echoed_as_string_when_client_sends_string_id() { + let raw = json!({ + "jsonrpc": "2.0", + "id": "abc-123", + "method": "ping", + }) + .to_string(); + let resp = handle_message(&raw, &[], &mut FakeHandler).unwrap(); + assert_eq!(resp["id"], json!("abc-123")); + } +} From 5377fd3164be8af913a785ba2e3e6a773f18836d Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 23:01:18 -0700 Subject: [PATCH 25/56] =?UTF-8?q?spec+plan(figmog):=20v3=20cached=20proxy?= =?UTF-8?q?=20=E2=80=94=20figmog=20as=20the=20single=20Figma=20MCP=20(paid?= =?UTF-8?q?-seat=20pivot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-15-figmog-serve.md | 48 +++++- .../specs/2026-08-15-figmog-build-design.md | 143 ++++++++++++++---- 2 files changed, 163 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/plans/2026-08-15-figmog-serve.md b/docs/superpowers/plans/2026-08-15-figmog-serve.md index ca6ea17..2e6e264 100644 --- a/docs/superpowers/plans/2026-08-15-figmog-serve.md +++ b/docs/superpowers/plans/2026-08-15-figmog-serve.md @@ -153,9 +153,55 @@ - [ ] **Step 3:** Full gates: `cargo test -p figmog` (now incl. serve e2e), clippy, fmt, `cargo test -p fold`, `cargo doc -p figmog --no-deps`. - [ ] **Step 4:** Commit: `feat(figmog): serve e2e tests and MCP docs` +### Task 6: `upstream.rs` — native-server MCP client + +**Files:** +- Create: `examples/figmog/src/upstream.rs`; Modify: `src/lib.rs` +- Test: unit tests in-file + one in-process HTTP fake test + +**Interfaces:** per spec §12 "Upstream client": `UpstreamError` (thiserror: Unreachable(String), Protocol(String)), `trait UpstreamMcp { initialize, tools, call }`, `HttpUpstream::new(url: String)`, and `pub struct FakeUpstream` (test-support, `#[cfg(test)]`-adjacent: put it behind `pub` in the module so tests/serve.rs can script it — a Vec of (name, schema) + a closure or queued results for `call`). + +- [ ] **Step 1 (TDD):** unit tests: handshake sequence frames (initialize → notifications/initialized → tools/list) built correctly; `call` builds a valid tools/call frame; `application/json` and single-event `text/event-stream` (`data: {...}\n\n`) bodies both parse; `Mcp-Session-Id` response header echoed on subsequent requests; error mapping. In-process HTTP fake: bind `TcpListener` on port 0, spawn a thread answering scripted HTTP responses, point `HttpUpstream` at it, drive initialize+tools/list+call. +- [ ] **Step 2:** implement; gates; commit `feat(figmog): upstream MCP client for the Figma desktop server`. + +--- + +### Task 7: proxy cache records + eviction + +**Files:** +- Modify: `examples/figmog/src/model.rs` (Id::ProxyCache, Rec::ProxyCache per spec §12), `src/store.rs` (proxy_cache Table branch in `figmog_pipeline!`; `cache_branch` fn; extend `sync` signature with `evict_cache_before_version: Option<&str>` OR a separate small `evict_stale_cache` helper — pick the design that keeps `sync`'s churn accounting untouched and document it), `src/query.rs` or new `src/cache.rs` (key hashing = stable hash of tool+canonical args — use a simple FNV/deterministic hex of bytes, no new deps; lookup/store helpers taking readers/tx) +- Test: extend `tests/sync.rs` (cache rows survive no-change pulls, evicted on version change; existing churn numbers UNAFFECTED — cache rows must not perturb the probe counts of existing tests, which don't create cache rows) + +- [ ] **Step 1 (TDD):** tests: store a cache row via a wtx helper; identical re-pull keeps it (version unchanged); v1→v2 pull evicts it; imported variables still survive both. +- [ ] **Step 2:** implement (model variants, pipeline sink `proxy_cache`, helpers); ALL existing tests must stay green with unchanged assertions; gates; commit `feat(figmog): version-keyed proxy response cache`. + +--- + +### Task 8: serve proxy integration + CLI `tools`/`call` + steering v3 + +**Files:** +- Modify: `src/serve.rs` (startup probe via `HttpUpstream` unless `--no-upstream`; registry merge per spec §12; route tools/call local-first-else-upstream; cacheable rule (get_*/list_* + explicit node id in args — detect via any arg key in {"nodeId","node_id","id"} with a string value); cache lookup before forward, store after; non-get/list success → immediate meta poll; `figmog_status` gains `upstream` field), `src/mcp.rs` (ONLY the steering-text constant → spec §11 point 3's new verbatim text; update its unit test accordingly — this supersedes the v2 text Task 2 shipped), `src/cli.rs` (`figmog tools`, `figmog call [--args json]`, `--upstream `, `--no-upstream` on serve) +- Test: unit tests for merge/routing/cacheable-rule with `FakeUpstream`; extend `tests/serve.rs`: one e2e with the in-process HTTP fake upstream (proxied tool listed + round-trips + second identical call served from cache without hitting the fake — assert via the fake's call counter exposed through a side-channel file or header count endpoint) + +- [ ] **Step 1 (TDD):** write the failing tests; **Step 2:** implement; gates; commit `feat(figmog): cached proxy — figmog as the single Figma MCP`. + +--- + +### Task 9: Enterprise variables in pull (opportunistic) + +**Files:** +- Modify: `src/api.rs` (`fn variables_local(&self, key) -> Result, ApiError>` on the trait — `Ok(None)` on 403/404), `src/cli.rs` + `src/serve.rs` pull paths (on Some: `parse_variables_export` → extend `flattened.recs` AND the sweepable set with variable/collection ids for THIS pull, per spec §12), README (variables section: Enterprise auto-sync first, plugin export as fallback) +- Test: sync-level test with a fake api returning the fixture export: pull twice → zero churn on variables; remove one variable from the fake's second response → it is swept; 403 fake → import-variables records still survive pulls (v1 behavior intact) + +- [ ] **Step 1 (TDD):** failing tests; **Step 2:** implement; gates; commit `feat(figmog): opportunistic Enterprise variables sync in pull`. + +--- + ## Self-review checklist - Spec §11 coverage: architecture → T4; query refactor → T1; protocol behaviors incl. `instructions` steering → T2 (unit-tested); structural query pack → T3; 17-tool registry → T4 + T5 README; distinct-namespace/steering rule → T2 (initialize) + T4 (names) + T5 (README positioning); testing section → T2 unit / T1 equivalence / T3 cli / T5 e2e. Non-goals respected (no resources/prompts, stdio only; cached-proxy documented as v3, not built). - The T1 refactor is the risk center: its acceptance gate ("existing tests pass unmodified") is what keeps v1 behavior frozen. - T4's EOF-exit contract is stated in T5 Step 1 because the test depends on it; implementer of T4 must read T5's step (noted in dispatch). -- Execution order: 1 → 2 → 3 → 4 → 5 (T3 must land before T4 so the registry can bind all 17 tools). +- Execution order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 (T3 before T4 so the registry binds all 17 local tools; T6+T7 before T8; T5's e2e baseline exists before T8 extends it). +- v3 (spec §12) coverage: upstream client → T6; cache records/eviction → T7; registry merge, routing, cacheable rule, steering v3 text (supersedes the v2 text T2 shipped — T8 updates the constant and its unit test), CLI tools/call → T8; Enterprise variables → T9. v3 non-goals respected (no remote-server OAuth proxying, no mid-session re-attach, no selection-call caching). +- T2 shipped the v2 steering text against the then-current spec; the v3 text lands in T8 by design — reviewers should not flag the interim drift (ledgered). diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index b2f1c0c..545c40c 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -508,31 +508,31 @@ stdin ──▶ reader thread ──▶ mpsc ──▶ main loop ── requests; a pull blocks request handling for its duration (documented — seconds at worst, and only when the file actually changed). -### Relationship to Figma's official MCP server (binding) - -figmog must never be confusable with Figma's official MCP server. Three -enforced distinctions: - -1. **Distinct namespace:** every tool is `figmog_*`. Figma's native tools - are unprefixed (`get_code`, `get_screenshot`, `get_variable_defs`, …); - there is no name collision and no tool on either server that overlaps - the other's capability. figmog ships nothing codegen- or - screenshot-shaped; the native server has nothing query-shaped. -2. **Server-level steering:** the `initialize` result's `instructions` - field carries, verbatim: "figmog is a local, instant, rate-limit-free - mirror of one Figma file. Use figmog tools for ALL structure, search, - components, styles, and variables. Use the official Figma MCP only for - code generation or screenshots — never for reads figmog can answer." -3. **Cost transparency:** every tool description states that it reads the - local mirror at zero API cost; `figmog_sync` alone is labeled as - spending Figma rate budget. - -**v3 direction (documented, not built):** for paid seats with the desktop -Dev Mode server available, figmog could become a *cached proxy* — the only -Figma-facing MCP an agent sees — forwarding codegen/screenshot tools to -the native server and caching responses keyed by (tool, args, file -version). Out of scope until the native server is reachable in a target -environment; on free plans there is nothing to proxy. +### Relationship to Figma's official MCP server (binding; revised for v3) + +**Positioning (v3 decision): figmog is the ONLY Figma MCP an agent +connects to** — a cached proxy in front of Figma's native desktop server, +plus the local mirror's own query tools. Within the one server, the +namespace rule keeps every call unambiguous: + +1. **Native-named tools are always proxied.** Tools discovered from the + upstream desktop server (`get_design_context`, `get_screenshot`, + `get_metadata`, `get_variable_defs`, …) are re-exposed verbatim and + answered by the upstream (through the cache) with native semantics and + output formats — figmog never impersonates them with its own data. +2. **`figmog_*` tools are always local.** The mirror's query tools answer + instantly from the store at zero API cost. +3. **Server-level steering:** the `initialize` result's `instructions` + field carries, verbatim: "figmog is your Figma server: a local, + instant mirror of one Figma file plus a cached proxy to Figma's native + capabilities. Call figmog for everything Figma-related. figmog_* tools + answer from the local mirror at zero API cost; native-named tools + (get_*, …) go to Figma, cached by file version where possible." + +This targets **paid Dev/Full seats** (the desktop server's requirement). +The free-plan-only paths (plugin-console variables export, inference as +primary) remain in the code as fallbacks but are no longer the design +center. ### Tools @@ -602,5 +602,94 @@ resolution identical to the other commands. ### Non-goals (v2) -MCP resources/prompts capabilities; HTTP/SSE transports; multi-file -serving; auth on the socket (stdio only, inherits process trust). +MCP resources/prompts capabilities; HTTP/SSE transports for *our* server; +multi-file serving; auth on the socket (stdio only, inherits process +trust). + +## 12. v3: the cached proxy + +figmog becomes the only Figma MCP an agent sees: local `figmog_*` tools +plus a verbatim passthrough of the native desktop server's tools, with a +version-keyed response cache. Targets paid Dev/Full seats; requires the +Figma desktop app's Dev Mode MCP server (streamable HTTP at +`http://127.0.0.1:3845/mcp` by default). + +### Upstream client (`upstream.rs`) + +- `trait UpstreamMcp { fn initialize(&mut self) -> Result<(), UpstreamError>; fn tools(&self) -> &[Value]; fn call(&mut self, name: &str, args: &Value) -> Result; }` + plus `HttpUpstream` (ureq POST of JSON-RPC frames; accept both + `application/json` bodies and single-event `text/event-stream` + responses, extracting the `data:` JSON; carry the + `Mcp-Session-Id` header if the server issues one) and a scripted fake + for tests. `--upstream ` overrides the default; + `--no-upstream` disables proxying entirely. +- Startup: probe + MCP handshake; on failure, serve local tools only, + log one stderr line, and report `upstream: "unreachable"` in + `figmog_status`. No mid-session re-probe in v3 (restart to attach — + documented). + +### Registry merge + +`tools/list` = the 17 local `figmog_*` tools followed by every upstream +tool verbatim (name, description, inputSchema passed through; description +prefixed "[via Figma desktop] "). Name collisions are impossible by the +namespace rule; if an upstream tool ever arrives named `figmog_*`, drop +it and log. `tools/call` routes by name: local registry first, else +upstream. + +### Cache + +- New record kind: `Id::ProxyCache(String /*key hash*/)`, + `Rec::ProxyCache { key_hash, tool, args_canonical, file_version, + content: String /*canonical JSON of the MCP result content*/ }`, stored + through the same stream into a `proxy_cache` Table sink. +- **Cacheable** = tool name starts `get_` or `list_` AND the arguments + contain an explicit node id (selection-based calls are invisible to the + cache and always forwarded). Key = hash(tool + canonical args); a hit + requires `file_version == current FileMeta.version`. +- **Eviction:** during `sync`, when the file version changes, stale + `ProxyCache` rows (any whose `file_version` differs from the incoming + version) join the sweep. Manually imported variables remain + sweep-exempt; the cache is not. +- **Writes:** any non-cacheable upstream call that is not `get_`/`list_` + (e.g. `add_code_connect_map`, `send_code_connect_mappings`) is + forwarded uncached and, on success, triggers an immediate meta poll so + upstream-originated edits reach the mirror without waiting for the + next tick. + +### CLI parity (1:1 via mechanism) + +The engine exposes everything; the CLI can invoke anything: +- `figmog tools` — the merged tool list (local + upstream, with source + and cacheability flags). +- `figmog call [--args '']` — invoke any tool by name + through the same dispatch the MCP server uses (local tools included). +Bespoke subcommands for upstream tools are deliberately NOT added — +Figma's tool list churns; the generic mechanism is the stable 1:1 +surface. + +### Enterprise variables (opportunistic) + +`pull` additionally calls `GET /v1/files/:key/variables/local` (Tier 2/ +Enterprise): on success its records flow through +`parse_variables_export` into the same sync **and become sweepable for +that pull** (API-provided variables are file state); on 403/404 the call +is skipped silently and the v1 behavior (import/inference, sweep-exempt) +holds unchanged. + +### Testing + +- Upstream client unit tests against a scripted fake; one in-process HTTP + fake (std `TcpListener` serving canned JSON-RPC responses, no new + deps) exercising `HttpUpstream` end to end incl. the SSE-style body. +- Registry merge + routing + cache hit/miss/eviction unit tests (fake + upstream, fixture store; assert the second identical `get_*` call with + a nodeId never reaches the fake, and a version bump evicts). +- e2e: serve with `--no-upstream` keeps the v2 behavior (existing tests); + one e2e with the in-process HTTP fake upstream asserts a proxied tool + appears in tools/list and round-trips. + +### Non-goals (v3) + +Proxying the remote server (OAuth); mid-session upstream re-attach / +`listChanged` notifications; caching selection-based calls; multi-file. From 857b92c2c70dfacc68d84f0ae515a1c4b6d3bb32 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 23:08:09 -0700 Subject: [PATCH 26/56] feat(figmog): whole-file structural queries (stats/path/text/where/at) Co-Authored-By: Claude Fable 5 --- examples/figmog/README.md | 5 + examples/figmog/src/cli.rs | 205 +++++++++++++++++++++++++++++++++++ examples/figmog/src/query.rs | 193 ++++++++++++++++++++++++++++++++- examples/figmog/tests/cli.rs | 67 ++++++++++++ 4 files changed, 469 insertions(+), 1 deletion(-) diff --git a/examples/figmog/README.md b/examples/figmog/README.md index be01e4f..b4da5df 100644 --- a/examples/figmog/README.md +++ b/examples/figmog/README.md @@ -42,6 +42,11 @@ store location (default `.figmog//db`). | `figmog uses ` | styled_by / bound_to + nodes | nodes using a style id or bound to a variable id | | `figmog vars [id]` | nodes + variables + variable_collections | variables: authoritative record if imported, else inferred value(s) + binding sites | | `figmog import-variables ` | — | upsert variable/collection records from a variables export (see "Variables on a free plan") | +| `figmog stats` | nodes + by_type + components + component_sets + styles + variables | node counts by type and by page, component/set/style/variable totals, text-node count, max tree depth — whole-file structural queries the API can't offer at all | +| `figmog path ` | nodes | ancestor chain root→node: `[{id, name, type}]` | +| `figmog text [--page ]` | by_type + nodes | every TEXT node's `(id, characters, page_id)`, sorted by id | +| `figmog where --pointer

[--equals ] [--page ]` | nodes | nodes whose raw JSON matches an RFC 6901 `pointer`, optionally filtered by `equals` (parsed as JSON, falling back to a bare string so `--equals VERTICAL` works) | +| `figmog at --x N --y N` | nodes | nodes whose absolute bounds contain the point, sorted by area ascending (deepest/smallest first) | Node ids accept both `12:34` and `12-34` forms everywhere. Auth is a personal access token from `FIGMA_TOKEN`. Since `pull`/`watch` are the only diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 21b0817..5ac7180 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -101,6 +101,33 @@ enum Cmd { Vars { id: Option }, /// Import a variables export (REST or plugin-console shape). ImportVariables { path: PathBuf }, + /// Node counts by type and page, table totals, text-node count, max tree depth. + Stats, + /// Ancestor chain root→node for one id. + Path { id: String }, + /// Every TEXT node's (id, characters, page_id), optionally scoped to one page. + Text { + #[arg(long)] + page: Option, + }, + /// Nodes whose raw JSON matches an RFC 6901 pointer, optionally by value. + Where { + /// RFC 6901 pointer into the node's raw JSON, e.g. /layoutMode. + #[arg(long)] + pointer: String, + /// JSON value to match; parsed as JSON, falling back to a bare string. + #[arg(long)] + equals: Option, + #[arg(long)] + page: Option, + }, + /// Nodes whose absolute bounds contain a point, sorted by area ascending. + At { + #[arg(long)] + x: f64, + #[arg(long)] + y: f64, + }, } /// Parse `argv`, dispatch, and return the process exit code (0 on success, @@ -191,6 +218,36 @@ fn dispatch(cli: Cli) -> Result<(), String> { cmd_vars(&nodes, &variables, &variable_collections, id, json) }, ), + Cmd::Stats => st.rtx( + |( + (nodes, _, _, _, _, _, by_type), + components, + component_sets, + styles, + variables, + .., + )| { + cmd_stats( + &nodes, + &components, + &component_sets, + &styles, + &variables, + &by_type, + json, + ) + }, + ), + Cmd::Path { id } => st.rtx(|((nodes, ..), ..)| cmd_path(&nodes, id, json)), + Cmd::Text { page } => st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| { + cmd_text(&nodes, &by_type, page, json) + }), + Cmd::Where { + pointer, + equals, + page, + } => st.rtx(|((nodes, ..), ..)| cmd_where(&nodes, pointer, equals, page, json)), + Cmd::At { x, y } => st.rtx(|((nodes, ..), ..)| cmd_at(&nodes, x, y, json)), Cmd::Pull { .. } | Cmd::Watch { .. } | Cmd::ImportVariables { .. } => { unreachable!("handled above") } @@ -773,6 +830,154 @@ fn cmd_vars( Ok(()) } +// ---- whole-file structural queries ---- + +/// `--equals `: parse as JSON, falling back to treating the bare word +/// as a JSON string (so `--equals VERTICAL` works without quoting). +fn parse_equals(raw: &str) -> Value { + serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string())) +} + +#[allow(clippy::too_many_arguments)] +fn cmd_stats( + nodes: &TableReader<'_, R, String, NodeRec>, + components: &TableReader<'_, R, String, ComponentRec>, + component_sets: &TableReader<'_, R, String, ComponentSetRec>, + styles: &TableReader<'_, R, String, StyleRec>, + variables: &TableReader<'_, R, String, VariableRec>, + by_type: &InvertedIndexReader<'_, R, String, String>, + json: bool, +) -> Result<(), String> { + let v = query::stats( + nodes, + components, + component_sets, + styles, + variables, + by_type, + )?; + if json { + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); + } else { + println!( + "{} nodes, max depth {}, {} text nodes", + v["by_type"] + .as_object() + .map(|m| m.values().filter_map(Value::as_u64).sum::()) + .unwrap_or_default(), + v["max_depth"].as_u64().unwrap_or_default(), + v["text_nodes"].as_u64().unwrap_or_default(), + ); + println!( + "totals: components={} component_sets={} styles={} variables={}", + v["totals"]["components"].as_u64().unwrap_or_default(), + v["totals"]["component_sets"].as_u64().unwrap_or_default(), + v["totals"]["styles"].as_u64().unwrap_or_default(), + v["totals"]["variables"].as_u64().unwrap_or_default(), + ); + println!("by type:"); + for (t, n) in v["by_type"].as_object().into_iter().flatten() { + println!(" {t} {n}"); + } + println!("by page:"); + for (p, n) in v["by_page"].as_object().into_iter().flatten() { + println!(" {p} {n}"); + } + } + Ok(()) +} + +fn cmd_path( + nodes: &TableReader<'_, R, String, NodeRec>, + id: String, + json: bool, +) -> Result<(), String> { + let v = query::path(nodes, id)?; + if json { + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); + } else { + for row in v.as_array().into_iter().flatten() { + println!( + "{} [{}] {}", + row["id"].as_str().unwrap_or_default(), + row["type"].as_str().unwrap_or_default(), + row["name"].as_str().unwrap_or_default(), + ); + } + } + Ok(()) +} + +fn cmd_text( + nodes: &TableReader<'_, R, String, NodeRec>, + by_type: &InvertedIndexReader<'_, R, String, String>, + page: Option, + json: bool, +) -> Result<(), String> { + let v = query::text(nodes, by_type, page)?; + if json { + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); + } else { + for row in v.as_array().into_iter().flatten() { + println!( + "{} ({}) {}", + row["id"].as_str().unwrap_or_default(), + row["page_id"].as_str().unwrap_or_default(), + row["characters"].as_str().unwrap_or_default(), + ); + } + } + Ok(()) +} + +fn cmd_where( + nodes: &TableReader<'_, R, String, NodeRec>, + pointer: String, + equals: Option, + page: Option, + json: bool, +) -> Result<(), String> { + let equals = equals.as_deref().map(parse_equals); + let v = query::where_(nodes, &pointer, equals, page)?; + if json { + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); + } else { + for row in v.as_array().into_iter().flatten() { + println!( + "{} {} ({}) {}", + row["id"].as_str().unwrap_or_default(), + row["name"].as_str().unwrap_or_default(), + row["page_id"].as_str().unwrap_or_default(), + row["value"], + ); + } + } + Ok(()) +} + +fn cmd_at( + nodes: &TableReader<'_, R, String, NodeRec>, + x: f64, + y: f64, + json: bool, +) -> Result<(), String> { + let v = query::at(nodes, x, y)?; + if json { + println!("{}", serde_json::to_string(&v).map_err(|e| e.to_string())?); + } else { + for row in v.as_array().into_iter().flatten() { + println!( + "{} {} [{}] area={}", + row["id"].as_str().unwrap_or_default(), + row["name"].as_str().unwrap_or_default(), + row["type"].as_str().unwrap_or_default(), + row["area"].as_f64().unwrap_or_default(), + ); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/examples/figmog/src/query.rs b/examples/figmog/src/query.rs index 61a21ef..9e5435c 100644 --- a/examples/figmog/src/query.rs +++ b/examples/figmog/src/query.rs @@ -1,7 +1,7 @@ //! One source of truth for every read answer — shared by the CLI printers //! and the MCP tools. -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use serde_json::{Value, json}; @@ -463,3 +463,194 @@ pub fn vars( .collect(); Ok(Value::Array(rows)) } + +// ---- whole-file structural queries ---- +// +// The local mirror's unfair advantage: full-file scans/joins no +// rate-limited API surface could offer, all answered from the local store. + +/// Depth of `id` counting the root as 0, by walking `parent_id` up to the +/// root. The file is local, so an O(depth) walk per node is fine. +fn depth_of(nodes: &TableReader<'_, R, String, NodeRec>, id: &str) -> usize { + let mut depth = 0; + let mut current = id.to_string(); + while let Some(n) = nodes.get(¤t) { + match n.parent_id { + Some(parent) => { + depth += 1; + current = parent; + } + None => break, + } + } + depth +} + +/// Node counts by type and by page, table totals, text-node count, max tree +/// depth. +#[allow(clippy::too_many_arguments)] +pub fn stats( + nodes: &TableReader<'_, R, String, NodeRec>, + components: &TableReader<'_, R, String, ComponentRec>, + component_sets: &TableReader<'_, R, String, ComponentSetRec>, + styles: &TableReader<'_, R, String, StyleRec>, + variables: &TableReader<'_, R, String, VariableRec>, + by_type: &InvertedIndexReader<'_, R, String, String>, +) -> Result { + let all: Vec = nodes.iter().map(|(_, n)| n).collect(); + + let mut by_type_counts: BTreeMap = BTreeMap::new(); + let mut by_page_counts: BTreeMap = BTreeMap::new(); + let mut max_depth = 0usize; + for n in &all { + *by_type_counts.entry(n.node_type.clone()).or_insert(0) += 1; + // DOCUMENT/CANVAS nodes are pages/roots, not page contents. + if n.node_type != "DOCUMENT" && n.node_type != "CANVAS" { + *by_page_counts.entry(n.page_id.clone()).or_insert(0) += 1; + } + max_depth = max_depth.max(depth_of(nodes, &n.id)); + } + + let text_nodes = by_type.search(&"TEXT".to_string()).len(); + + Ok(json!({ + "by_type": by_type_counts, + "by_page": by_page_counts, + "totals": { + "components": components.iter().count(), + "component_sets": component_sets.iter().count(), + "styles": styles.iter().count(), + "variables": variables.iter().count(), + }, + "text_nodes": text_nodes, + "max_depth": max_depth, + })) +} + +/// Ancestor chain root→node, as `[{id, name, type}]`. Unknown id → Err. +pub fn path( + nodes: &TableReader<'_, R, String, NodeRec>, + id: String, +) -> Result { + let id = normalize_node_id(&id); + let mut chain: Vec = Vec::new(); + let mut current = id.clone(); + loop { + let n = nodes + .get(¤t) + .ok_or_else(|| format!("no node {current} in the mirror"))?; + let parent = n.parent_id.clone(); + chain.push(n); + match parent { + Some(p) => current = p, + None => break, + } + } + chain.reverse(); + + let arr: Vec = chain + .iter() + .map(|n| json!({"id": n.id, "name": n.name, "type": n.node_type})) + .collect(); + Ok(Value::Array(arr)) +} + +/// Every TEXT node's `(id, characters, page_id)`, optionally scoped to one +/// page, sorted by id. +pub fn text( + nodes: &TableReader<'_, R, String, NodeRec>, + by_type: &InvertedIndexReader<'_, R, String, String>, + page: Option, +) -> Result { + let mut ids = by_type.search(&"TEXT".to_string()); + ids.sort(); + let page = page.as_deref().map(normalize_node_id); + + let mut rows: Vec<(String, String, String)> = ids + .into_iter() + .filter_map(|id| nodes.get(&id)) + .filter(|n| page.as_deref().is_none_or(|p| n.page_id == p)) + .map(|n| (n.id, n.text.clone().unwrap_or_default(), n.page_id)) + .collect(); + rows.sort(); + + let arr: Vec = rows + .iter() + .map(|(id, characters, page_id)| { + json!({"id": id, "characters": characters, "page_id": page_id}) + }) + .collect(); + Ok(Value::Array(arr)) +} + +/// Nodes whose `raw` JSON matches an RFC 6901 pointer, optionally by value +/// and/or scoped to one page. Rows `[{id, name, type, page_id, value}]`, +/// sorted by id. +pub fn where_( + nodes: &TableReader<'_, R, String, NodeRec>, + pointer: &str, + equals: Option, + page: Option, +) -> Result { + if !pointer.starts_with('/') { + return Err(format!( + "pointer must be an RFC 6901 pointer starting with '/': {pointer}" + )); + } + let page = page.as_deref().map(normalize_node_id); + + let mut rows: Vec<(String, String, String, String, Value)> = Vec::new(); + for (_, n) in nodes.iter() { + if !page.as_deref().is_none_or(|p| n.page_id == p) { + continue; + } + let raw: Value = serde_json::from_str(&n.raw).map_err(|e| e.to_string())?; + let Some(v) = raw.pointer(pointer) else { + continue; + }; + if equals.as_ref().is_some_and(|want| v != want) { + continue; + } + rows.push((n.id, n.name, n.node_type, n.page_id, v.clone())); + } + rows.sort_by(|a, b| a.0.cmp(&b.0)); + + let arr: Vec = rows + .into_iter() + .map(|(id, name, node_type, page_id, value)| { + json!({"id": id, "name": name, "type": node_type, "page_id": page_id, "value": value}) + }) + .collect(); + Ok(Value::Array(arr)) +} + +/// Nodes whose `abs_bounds` contain `(x, y)`, sorted by area ascending +/// (deepest/smallest first) then id. Rows `[{id, name, type, page_id, area}]`. +pub fn at( + nodes: &TableReader<'_, R, String, NodeRec>, + x: f64, + y: f64, +) -> Result { + let mut rows: Vec<(f64, String, String, String, String)> = Vec::new(); + for (_, n) in nodes.iter() { + let Some([bx, by, w, h]) = n.abs_bounds else { + continue; + }; + if bx <= x && x < bx + w && by <= y && y < by + h { + rows.push((w * h, n.id, n.name, n.node_type, n.page_id)); + } + } + rows.sort_by(|a, b| { + a.0.partial_cmp(&b.0) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.1.cmp(&b.1)) + }); + + let arr: Vec = rows + .into_iter() + .map(|(area, id, name, node_type, page_id)| { + json!({"id": id, "name": name, "type": node_type, "page_id": page_id, "area": area}) + }) + .collect(); + Ok(Value::Array(arr)) +} diff --git a/examples/figmog/tests/cli.rs b/examples/figmog/tests/cli.rs index ae63ffc..0f5a4d3 100644 --- a/examples/figmog/tests/cli.rs +++ b/examples/figmog/tests/cli.rs @@ -203,6 +203,73 @@ fn search_instances_components_styles_uses_vars() { assert_eq!(arr[0]["source"], "inferred"); } +#[test] +fn stats_path_text_where_at() { + let (_dir, db) = fixture_db(); + let run = |args: &[&str]| { + let out = Command::cargo_bin("figmog") + .unwrap() + .args(args) + .args(["--db", &db, "--json"]) + .assert() + .success(); + serde_json::from_slice::(&out.get_output().stdout).unwrap() + }; + + let stats = run(&["stats"]); + assert_eq!(stats["by_type"]["TEXT"], 1); + assert_eq!(stats["by_page"]["0:1"], 4); // 1:1, 1:2, 1:3, 1:9 + assert_eq!(stats["totals"]["components"], 3); + assert_eq!(stats["totals"]["component_sets"], 1); + assert_eq!(stats["totals"]["styles"], 2); + assert_eq!(stats["max_depth"], 3); // document -> canvas -> frame -> text + + let path = run(&["path", "1-2"]); + let ids: Vec<&str> = path + .as_array() + .unwrap() + .iter() + .map(|r| r["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec!["0:0", "0:1", "1:1", "1:2"]); + + let text = run(&["text"]); + let rows = text.as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["id"], "1:2"); + assert_eq!(rows[0]["characters"], "Welcome to the garden"); + + let where_layout = run(&["where", "--pointer", "/layoutMode", "--equals", "VERTICAL"]); + let ids: Vec<&str> = where_layout + .as_array() + .unwrap() + .iter() + .map(|r| r["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec!["1:1"]); + + let where_font = run(&["where", "--pointer", "/style/fontSize", "--equals", "32.0"]); + let ids: Vec<&str> = where_font + .as_array() + .unwrap() + .iter() + .map(|r| r["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec!["1:2"]); + + let at = run(&["at", "--x", "10", "--y", "10"]); + let ids: Vec<&str> = at + .as_array() + .unwrap() + .iter() + .map(|r| r["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&"1:1"), "ids={ids:?}"); + // nodes without abs_bounds (e.g. the DOCUMENT/CANVAS/TEXT nodes here) + // never appear. + assert!(!ids.contains(&"1:2"), "ids={ids:?}"); +} + #[test] fn import_variables_upgrades_vars_to_authoritative() { let (dir, db) = fixture_db(); From d012dff9dd89d71a13b22b49948ce653b240ba95 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 23:15:12 -0700 Subject: [PATCH 27/56] fix(figmog): guard depth_of/path against parent_id cycles Co-Authored-By: Claude Fable 5 --- examples/figmog/src/query.rs | 16 ++++++++++- examples/figmog/tests/cli.rs | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/examples/figmog/src/query.rs b/examples/figmog/src/query.rs index 9e5435c..2571df1 100644 --- a/examples/figmog/src/query.rs +++ b/examples/figmog/src/query.rs @@ -470,11 +470,19 @@ pub fn vars( // rate-limited API surface could offer, all answered from the local store. /// Depth of `id` counting the root as 0, by walking `parent_id` up to the -/// root. The file is local, so an O(depth) walk per node is fine. +/// root. The file is local, so an O(depth) walk per node is fine. Guards +/// against a corrupted store with a `parent_id` cycle: once an id repeats, +/// stop and report the depth counted so far rather than looping forever — +/// this runs inside `figmog serve`'s single-threaded loop, where a hang +/// would stall the whole server. fn depth_of(nodes: &TableReader<'_, R, String, NodeRec>, id: &str) -> usize { let mut depth = 0; let mut current = id.to_string(); + let mut visited: BTreeSet = BTreeSet::new(); while let Some(n) = nodes.get(¤t) { + if !visited.insert(current.clone()) { + break; // parent cycle: depth-so-far is the best available answer + } match n.parent_id { Some(parent) => { depth += 1; @@ -528,14 +536,20 @@ pub fn stats( } /// Ancestor chain root→node, as `[{id, name, type}]`. Unknown id → Err. +/// A `parent_id` cycle (a corrupted store) is also an `Err` rather than an +/// infinite loop — see [`depth_of`]'s doc comment for why that matters here. pub fn path( nodes: &TableReader<'_, R, String, NodeRec>, id: String, ) -> Result { let id = normalize_node_id(&id); let mut chain: Vec = Vec::new(); + let mut visited: BTreeSet = BTreeSet::new(); let mut current = id.clone(); loop { + if !visited.insert(current.clone()) { + return Err(format!("parent cycle detected at {current}")); + } let n = nodes .get(¤t) .ok_or_else(|| format!("no node {current} in the mirror"))?; diff --git a/examples/figmog/tests/cli.rs b/examples/figmog/tests/cli.rs index 0f5a4d3..c79ed46 100644 --- a/examples/figmog/tests/cli.rs +++ b/examples/figmog/tests/cli.rs @@ -270,6 +270,60 @@ fn stats_path_text_where_at() { assert!(!ids.contains(&"1:2"), "ids={ids:?}"); } +/// A corrupted store with a `parent_id` cycle must not hang `path` or +/// `stats` — both walk `parent_id` chains, and both become MCP tool bodies +/// inside `figmog serve`'s single-threaded loop, where a hang would stall +/// the whole server. Hand-upsert two nodes whose parents point at each +/// other (same hand-upsert pattern as `tests/sync.rs`), bypassing +/// `flatten`/`sync` entirely so the cycle can't be prevented upstream. +#[test] +fn parent_cycle_does_not_hang_path_or_stats() { + use figmog::model::{Id, NodeRec, Rec}; + + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("db"); + let mut st = figmog::open_store!(&db); + let node = |id: &str, parent: &str| NodeRec { + id: id.into(), + parent_id: Some(parent.into()), + child_index: 0, + page_id: "0:1".into(), + node_type: "FRAME".into(), + name: id.into(), + visible: true, + text: None, + component_id: None, + component_properties: vec![], + property_definitions: None, + style_refs: vec![], + bound_variables: vec![], + abs_bounds: None, + raw: "{}".into(), + }; + st.wtx(|tx| { + tx.upsert(&Id::Node("A:1".into()), &Rec::Node(node("A:1", "B:1"))); + tx.upsert(&Id::Node("B:1".into()), &Rec::Node(node("B:1", "A:1"))); + }); + drop(st); // release the store lock before the child process opens it + + let db = db.display().to_string(); + + let out = Command::cargo_bin("figmog") + .unwrap() + .args(["path", "A:1", "--db", &db]) + .assert() + .failure() + .code(1); + let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string(); + assert!(stderr.contains("cycle"), "stderr: {stderr}"); + + Command::cargo_bin("figmog") + .unwrap() + .args(["stats", "--db", &db, "--json"]) + .assert() + .success(); +} + #[test] fn import_variables_upgrades_vars_to_authoritative() { let (dir, db) = fixture_db(); From 40b42b6c4e0764e6691ded2c72470fc3aed95039 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 23:31:48 -0700 Subject: [PATCH 28/56] =?UTF-8?q?feat(figmog):=20figmog=20serve=20?= =?UTF-8?q?=E2=80=94=20MCP=20stdio=20server=20with=20integrated=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- examples/figmog/src/cli.rs | 50 +++- examples/figmog/src/lib.rs | 1 + examples/figmog/src/mcp.rs | 14 + examples/figmog/src/serve.rs | 505 +++++++++++++++++++++++++++++++++++ 4 files changed, 559 insertions(+), 11 deletions(-) create mode 100644 examples/figmog/src/serve.rs diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 5ac7180..23b82c7 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -55,6 +55,19 @@ enum Cmd { #[arg(long, default_value = "10")] interval: u64, }, + /// MCP stdio server: `figmog_*` tools over the local mirror, with the + /// sync loop built in (one process owns the store). + Serve { + /// File key or figma.com URL. Optional after the first pull, or + /// with `--no-watch` and `--db` for a read-only, offline server. + file: Option, + /// Poll interval in seconds. + #[arg(long, default_value = "10")] + interval: u64, + /// Disable the poll loop (offline/fixture use). + #[arg(long)] + no_watch: bool, + }, /// File name, version, last modified, node count. Status, /// List pages. @@ -158,6 +171,11 @@ fn dispatch(cli: Cli) -> Result<(), String> { } => cmd_pull(&db, file, from_file, fresh, cli.json), Cmd::Watch { file, interval } => cmd_watch(&db, file, interval, cli.json), Cmd::ImportVariables { path } => cmd_import_variables(&db, path, cli.json), + Cmd::Serve { + file, + interval, + no_watch, + } => crate::serve::run_serve(&db, file, interval, no_watch), other => { // `open_store!`'s pipeline type contains fn items and can't be // named, so the store-reading dispatch below must live at this @@ -248,7 +266,10 @@ fn dispatch(cli: Cli) -> Result<(), String> { page, } => st.rtx(|((nodes, ..), ..)| cmd_where(&nodes, pointer, equals, page, json)), Cmd::At { x, y } => st.rtx(|((nodes, ..), ..)| cmd_at(&nodes, x, y, json)), - Cmd::Pull { .. } | Cmd::Watch { .. } | Cmd::ImportVariables { .. } => { + Cmd::Pull { .. } + | Cmd::Watch { .. } + | Cmd::ImportVariables { .. } + | Cmd::Serve { .. } => { unreachable!("handled above") } } @@ -259,9 +280,9 @@ fn dispatch(cli: Cli) -> Result<(), String> { // ---- config / db resolution ---- /// The store to open plus (when known) the file key it mirrors. -struct Db { - path: PathBuf, - key: Option, +pub(crate) struct Db { + pub(crate) path: PathBuf, + pub(crate) key: Option, } const CURRENT_FILE: &str = ".figmog/current"; @@ -277,7 +298,10 @@ fn resolve_db(cli: &Cli) -> Result { // pull/watch with an explicit file ref establish the key for this run. // `.figmog/current` is only written after a successful sync (see // `do_pull`), so a failed pull never repoints later commands. - if let Cmd::Pull { file: Some(f), .. } | Cmd::Watch { file: Some(f), .. } = &cli.cmd { + if let Cmd::Pull { file: Some(f), .. } + | Cmd::Watch { file: Some(f), .. } + | Cmd::Serve { file: Some(f), .. } = &cli.cmd + { let key = parse_file_ref(f).ok_or_else(|| format!("not a Figma file key or URL: {f}"))?; return Ok(Db { path: db_path_for(&key), @@ -319,12 +343,12 @@ fn db_path_for(key: &str) -> PathBuf { PathBuf::from(".figmog").join(key).join("db") } -fn write_current(key: &str) -> Result<(), String> { +pub(crate) fn write_current(key: &str) -> Result<(), String> { std::fs::create_dir_all(".figmog").map_err(|e| e.to_string())?; std::fs::write(CURRENT_FILE, key).map_err(|e| e.to_string()) } -fn now_ms() -> u64 { +pub(crate) fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -338,7 +362,7 @@ fn now_ms() -> u64 { /// the plain-string messages `do_pull` used to produce, so `cmd_pull`'s /// user-facing errors are unchanged. #[derive(Debug)] -enum PullError { +pub(crate) enum PullError { Api(ApiError), Other(String), } @@ -379,7 +403,7 @@ fn cmd_pull( /// own per-tick event lines around the same churn. `.figmog/current` is /// written only once the sync below has actually happened, so a failed /// pull never repoints later commands at a nonexistent mirror. -fn do_pull( +pub(crate) fn do_pull( db: &Db, file: Option, from_file: Option, @@ -525,7 +549,11 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result /// per-loop backoff state. `RateLimited` honors `Retry-After` (never less /// than the normal poll interval); anything else gets the same exponential /// backoff discipline the [`Watcher`] uses for Tier-3 meta failures. -fn pull_failure_wait(err: &PullError, backoff: &mut Duration, interval: Duration) -> Duration { +pub(crate) fn pull_failure_wait( + err: &PullError, + backoff: &mut Duration, + interval: Duration, +) -> Duration { if let PullError::Api(ApiError::RateLimited { retry_after }) = err { interval.max(*retry_after) } else { @@ -564,7 +592,7 @@ fn cmd_import_variables(db: &Db, path: PathBuf, json: bool) -> Result<(), String Ok(()) } -fn read_watermark(db: &Db) -> Option { +pub(crate) fn read_watermark(db: &Db) -> Option { let st = crate::open_store!(&db.path); st.rtx(|(_, _, _, _, _, _, meta)| meta.get(&0).map(|m| m.last_modified)) } diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs index d1daf81..986be7c 100644 --- a/examples/figmog/src/lib.rs +++ b/examples/figmog/src/lib.rs @@ -16,6 +16,7 @@ pub mod ident; pub mod mcp; pub mod model; pub mod query; +pub mod serve; pub mod store; pub mod vars; pub mod watch; diff --git a/examples/figmog/src/mcp.rs b/examples/figmog/src/mcp.rs index b94b7c6..48668d4 100644 --- a/examples/figmog/src/mcp.rs +++ b/examples/figmog/src/mcp.rs @@ -38,6 +38,20 @@ pub trait ToolHandler { fn call(&mut self, name: &str, args: &Value) -> Result; } +/// Adapts a closure to [`ToolHandler`]. `figmog serve`'s store handle has +/// an unnameable type (the `open_store!` pipeline contains fn items), so +/// it can't be held in a named struct field generic over the store's +/// pipeline type; wrapping a closure that captures the store by unique +/// reference sidesteps that entirely — the closure's environment can hold +/// whatever concrete type it was defined against. +pub struct FnHandler(pub F); + +impl Result> ToolHandler for FnHandler { + fn call(&mut self, name: &str, args: &Value) -> Result { + (self.0)(name, args) + } +} + /// Handle one incoming JSON-RPC message. Returns the response frame to /// write, or `None` for notifications (a message with no `id`, or whose /// method starts with `notifications/`). diff --git a/examples/figmog/src/serve.rs b/examples/figmog/src/serve.rs new file mode 100644 index 0000000..e1bae25 --- /dev/null +++ b/examples/figmog/src/serve.rs @@ -0,0 +1,505 @@ +//! `figmog serve` — an MCP stdio server with the sync loop built in. +//! +//! One process owns the store (build design §11): a reader thread turns +//! stdin lines into an `mpsc` channel; the main loop owns the [`fold`] +//! store and answers JSON-RPC requests between poll ticks. Every +//! `figmog_*` tool is a thin wrapper over the same `query::*` functions +//! the CLI prints — one source of truth for every answer. `figmog_sync` +//! and the background poll loop share the same pull mechanics +//! (`flatten_file` → `collect_sweepable` → `store::sync`) and the same +//! failure-backoff discipline as `figmog watch` (see `cli::pull_failure_wait`). +//! +//! Every `rtx`/`wtx` call against the store has to live at this concrete, +//! non-generic call site: `open_store!`'s pipeline type contains fn items +//! and can't be named, so it can't be threaded through a helper `fn` +//! generic over `P: Push<..>` (see the identical note in `cli::dispatch`). +//! The [`mcp::ToolHandler`] the loop hands to [`mcp::handle_message`] is +//! therefore a closure — wrapped in [`mcp::FnHandler`] — defined right +//! here, capturing the store by unique reference. + +use std::collections::BTreeSet; +use std::io::{BufRead, Write}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use crate::api::{FigmaApi, UreqApi}; +use crate::cli::{ + Db, PullError, do_pull, now_ms, pull_failure_wait, read_watermark, write_current, +}; +use crate::flatten::flatten_file; +use crate::ident::parse_file_ref; +use crate::mcp::{self, FnHandler, ToolDef}; +use crate::model::Id; +use crate::query; +use crate::store::{self, collect_sweepable}; +use crate::watch::{BACKOFF_START, Tick, Watcher}; + +/// Run the MCP stdio server against `db`, serving `figmog_*` tools and — +/// unless `no_watch` — pulling inline whenever the file changes. +/// +/// `file` resolves the mirrored key the same way `pull`/`watch` do (a +/// `--db` override alone is enough for a read-only, offline server; a key +/// is only required once network access is actually needed: `!no_watch`, +/// or a `figmog_sync` tool call). +pub(crate) fn run_serve( + db: &Db, + file: Option, + interval: u64, + no_watch: bool, +) -> Result<(), String> { + let key: Option = db + .key + .clone() + .or_else(|| file.and_then(|f| parse_file_ref(&f))); + + let interval_dur = Duration::from_secs(interval); + let api: Option = if no_watch { + None + } else { + let resolved = key + .clone() + .ok_or_else(|| "no file key: pass a file key or figma.com URL".to_string())?; + let token = std::env::var("FIGMA_TOKEN") + .map_err(|_| "FIGMA_TOKEN not set — required for watch".to_string())?; + if read_watermark(db).is_none() { + do_pull(db, Some(resolved), None, false).map_err(|e| e.to_string())?; + } + Some(UreqApi::new(token)) + }; + + eprintln!( + "{} serving {} (watch {})", + mcp::SERVER_NAME, + key.as_deref().unwrap_or(""), + if no_watch { "off" } else { "on" } + ); + + // Reader thread: stdin lines -> mpsc. EOF (or any read error) drops + // `tx`, which is how the main loop learns to exit (`recv`/`recv_timeout` + // return `Disconnected`). + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + for line in std::io::stdin().lock().lines() { + match line { + Ok(l) => { + if tx.send(l).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let mut st = crate::open_store!(&db.path); + let mut stored: Option = + st.rtx(|(_, _, _, _, _, _, meta)| meta.get(&0).map(|m| m.last_modified)); + let mut watcher = Watcher::new(stored.clone()); + let mut pull_backoff = BACKOFF_START; + let tools = tool_registry(); + let mut next_deadline = Instant::now() + interval_dur; + + loop { + let incoming = if no_watch { + // No ticking to do, so a disconnect (stdin EOF) is the only + // thing `recv` can report besides a line — exit clean rather + // than falling into the (watch-only) timeout branch below. + match rx.recv() { + Ok(line) => Some(line), + Err(mpsc::RecvError) => return Ok(()), + } + } else { + let wait = next_deadline.saturating_duration_since(Instant::now()); + match rx.recv_timeout(wait) { + Ok(line) => Some(line), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()), + } + }; + + let Some(line) = incoming else { + // Timeout with watch enabled: poll, and pull inline on change. + let api_ref = api + .as_ref() + .expect("api is Some whenever watch is enabled, the only way to reach a timeout"); + let watch_key = key + .as_deref() + .expect("key is resolved above whenever watch is enabled"); + match watcher.tick(api_ref, watch_key) { + Tick::Unchanged => next_deadline = Instant::now() + interval_dur, + Tick::Wait { after } => next_deadline = Instant::now() + after, + Tick::Changed { .. } => { + let pull_result: Result = (|| { + let resp = api_ref.file(watch_key)?; + let flattened = flatten_file(&resp).map_err(|e| e.to_string())?; + let prior: BTreeSet = + st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + Ok(store::sync(&mut st, &prior, &flattened, now_ms())) + })(); + match pull_result { + Ok(_churn) => { + stored = st.rtx(|(_, _, _, _, _, _, meta)| { + meta.get(&0).map(|m| m.last_modified) + }); + pull_backoff = BACKOFF_START; + if let Some(k) = &db.key { + let _ = write_current(k); + } + eprintln!("figmog: synced"); + next_deadline = Instant::now() + interval_dur; + } + Err(e) => { + eprintln!("figmog: pull failed: {e}"); + // Reset to the last successfully-synced watermark + // so the same change is re-detected next tick — + // same discipline as `cmd_watch`. + watcher = Watcher::new(stored.clone()); + let wait = pull_failure_wait(&e, &mut pull_backoff, interval_dur); + next_deadline = Instant::now() + wait; + } + } + } + } + continue; + }; + + let mut handler = FnHandler(|name: &str, args: &Value| -> Result { + match name { + "figmog_status" => st.rtx(|((nodes, _, _, _, _, _, _), _, _, _, _, _, meta)| { + query::status(&nodes, &meta) + }), + "figmog_pages" => { + st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| query::pages(&nodes, &by_type)) + } + "figmog_tree" => { + let id = arg_str(args, "id"); + let depth = arg_usize(args, "depth"); + st.rtx(|((nodes, children, _, _, _, _, by_type), ..)| { + query::tree(&nodes, &children, &by_type, id, depth) + }) + } + "figmog_node" => { + let id = require_str(args, "id")?; + let with_children = arg_bool(args, "children"); + st.rtx(|((nodes, children, ..), ..)| { + query::node(&nodes, &children, id, with_children) + }) + } + "figmog_find" => { + let node_type = require_str(args, "type")?; + let page = arg_str(args, "page"); + st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| { + query::find(&nodes, &by_type, node_type, page) + }) + } + "figmog_search" => { + let q = require_str(args, "query")?; + let limit = arg_usize(args, "limit").unwrap_or(10); + st.rtx(|((nodes, _, text, ..), ..)| query::search(&text, &nodes, &q, limit)) + } + "figmog_instances" => { + let target = require_str(args, "target")?; + st.rtx( + |((nodes, _, _, instances_of, ..), components, component_sets, ..)| { + query::instances( + &nodes, + &components, + &component_sets, + &instances_of, + &target, + ) + }, + ) + } + "figmog_components" => st.rtx(|((nodes, ..), components, component_sets, ..)| { + query::components(&nodes, &components, &component_sets) + }), + "figmog_styles" => { + let style_type = arg_str(args, "type"); + let values = arg_bool(args, "values"); + st.rtx(|((nodes, _, _, _, styled_by, ..), _, _, styles, ..)| { + query::styles(&nodes, &styles, &styled_by, style_type, values) + }) + } + "figmog_uses" => { + let id = require_str(args, "id")?; + st.rtx(|((nodes, _, _, _, styled_by, bound_to, _), ..)| { + query::uses(&nodes, &styled_by, &bound_to, &id) + }) + } + "figmog_vars" => { + let id = arg_str(args, "id"); + st.rtx( + |((nodes, ..), _, _, _, variables, variable_collections, _)| { + query::vars(&nodes, &variables, &variable_collections, id) + }, + ) + } + "figmog_sync" => { + let sync_key = key.clone().ok_or_else(|| { + "no file key: pass a file key or figma.com URL".to_string() + })?; + let token = std::env::var("FIGMA_TOKEN").map_err(|_| { + "FIGMA_TOKEN not set — required for figmog_sync".to_string() + })?; + let sync_api = UreqApi::new(token); + let pull_result: Result = (|| { + let resp = sync_api.file(&sync_key)?; + let flattened = flatten_file(&resp).map_err(|e| e.to_string())?; + let prior: BTreeSet = + st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + Ok(store::sync(&mut st, &prior, &flattened, now_ms())) + })(); + let churn = pull_result.map_err(|e| e.to_string())?; + stored = + st.rtx(|(_, _, _, _, _, _, meta)| meta.get(&0).map(|m| m.last_modified)); + pull_backoff = BACKOFF_START; + watcher = Watcher::new(stored.clone()); + if let Some(k) = &db.key { + let _ = write_current(k); + } + serde_json::to_value(&churn).map_err(|e| e.to_string()) + } + "figmog_stats" => st.rtx( + |( + (nodes, _, _, _, _, _, by_type), + components, + component_sets, + styles, + variables, + .., + )| { + query::stats( + &nodes, + &components, + &component_sets, + &styles, + &variables, + &by_type, + ) + }, + ), + "figmog_path" => { + let id = require_str(args, "id")?; + st.rtx(|((nodes, ..), ..)| query::path(&nodes, id)) + } + "figmog_text" => { + let page = arg_str(args, "page"); + st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| { + query::text(&nodes, &by_type, page) + }) + } + "figmog_where" => { + let pointer = require_str(args, "pointer")?; + let equals = args.get("equals").cloned(); + let page = arg_str(args, "page"); + st.rtx(|((nodes, ..), ..)| query::where_(&nodes, &pointer, equals, page)) + } + "figmog_at" => { + let x = require_f64(args, "x")?; + let y = require_f64(args, "y")?; + st.rtx(|((nodes, ..), ..)| query::at(&nodes, x, y)) + } + other => Err(format!("unknown tool: {other}")), + } + }); + + if let Some(resp) = mcp::handle_message(&line, &tools, &mut handler) { + println!("{resp}"); + std::io::stdout().flush().map_err(|e| e.to_string())?; + } + } +} + +// ---- arg extraction ---- + +fn arg_str(args: &Value, key: &str) -> Option { + args.get(key).and_then(Value::as_str).map(str::to_string) +} + +fn require_str(args: &Value, key: &str) -> Result { + arg_str(args, key).ok_or_else(|| format!("missing required field: {key}")) +} + +fn arg_usize(args: &Value, key: &str) -> Option { + args.get(key).and_then(Value::as_u64).map(|n| n as usize) +} + +fn arg_bool(args: &Value, key: &str) -> bool { + args.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn require_f64(args: &Value, key: &str) -> Result { + args.get(key) + .and_then(Value::as_f64) + .ok_or_else(|| format!("missing required field: {key}")) +} + +// ---- tool registry ---- + +/// The 17 `figmog_*` MCP tools: 12 core reads + 5 whole-file structural +/// queries (build design §11's two tables). Every tool but `figmog_sync` +/// reads the local mirror at zero Figma API cost. +fn tool_registry() -> Vec { + vec![ + ToolDef { + name: "figmog_status", + description: "File name, version, last modified time, and node count — reads the local mirror (no Figma API cost).", + input_schema: json!({"type": "object", "properties": {}}), + }, + ToolDef { + name: "figmog_pages", + description: "List the file's pages (CANVAS nodes), in document order — reads the local mirror (no Figma API cost).", + input_schema: json!({"type": "object", "properties": {}}), + }, + ToolDef { + name: "figmog_tree", + description: "Subtree outline (id, name, type, children) rooted at a node, defaulting to the whole document — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "id": {"type": "string", "description": "Root node id; defaults to the DOCUMENT node."}, + "depth": {"type": "integer", "description": "Max depth to descend; omitted means unlimited."} + } + }), + }, + ToolDef { + name: "figmog_node", + description: "Full raw JSON of one node by id, optionally with a one-level children summary — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "id": {"type": "string", "description": "Node id (12:34 or 12-34 form)."}, + "children": {"type": "boolean", "description": "Inline a one-level children summary."} + }, + "required": ["id"] + }), + }, + ToolDef { + name: "figmog_find", + description: "Nodes by Figma node type, optionally scoped to one page — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "type": {"type": "string", "description": "Figma node type, e.g. FRAME."}, + "page": {"type": "string", "description": "Page (CANVAS) node id to scope to."} + }, + "required": ["type"] + }), + }, + ToolDef { + name: "figmog_search", + description: "BM25 search over layer names and text content — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "description": "Max hits (default 10)."} + }, + "required": ["query"] + }), + }, + ToolDef { + name: "figmog_instances", + description: "Instances of a component, resolved by node id, global key, or component/component-set name — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "target": {"type": "string", "description": "Node id, key, or name of a component or component set."} + }, + "required": ["target"] + }), + }, + ToolDef { + name: "figmog_components", + description: "Design-system inventory: component sets with their variant axes, plus standalone components — reads the local mirror (no Figma API cost).", + input_schema: json!({"type": "object", "properties": {}}), + }, + ToolDef { + name: "figmog_styles", + description: "Styles with usage counts; `values` derives each style's definition from a consumer node — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "type": {"type": "string", "description": "Style type filter, e.g. FILL, TEXT."}, + "values": {"type": "boolean", "description": "Derive each style's definition from a consumer node."} + } + }), + }, + ToolDef { + name: "figmog_uses", + description: "Nodes using a style id or bound to a variable id — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": {"id": {"type": "string", "description": "A style id or variable id."}}, + "required": ["id"] + }), + }, + ToolDef { + name: "figmog_vars", + description: "Variables: the authoritative record if imported via figmog import-variables, else inferred from bindings — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": {"id": {"type": "string", "description": "Variable id filter; omitted means all variables."}} + }), + }, + ToolDef { + name: "figmog_sync", + description: "Forces one pull from Figma and returns the sync churn (+added ~changed -removed) — fetches from Figma (spends Tier-1 rate budget).", + input_schema: json!({"type": "object", "properties": {}}), + }, + ToolDef { + name: "figmog_stats", + description: "Node counts by type and by page, component/set/style/variable totals, text-node count, max tree depth — reads the local mirror (no Figma API cost).", + input_schema: json!({"type": "object", "properties": {}}), + }, + ToolDef { + name: "figmog_path", + description: "Ancestor chain from the document root to a node, as [{id, name, type}] — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"] + }), + }, + ToolDef { + name: "figmog_text", + description: "Every TEXT node's (id, characters, page_id), optionally scoped to one page, sorted by id — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": {"page": {"type": "string"}} + }), + }, + ToolDef { + name: "figmog_where", + description: "Nodes whose raw JSON matches an RFC 6901 pointer, optionally filtered by value — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "pointer": {"type": "string", "description": "RFC 6901 pointer into the node's raw JSON, e.g. /layoutMode."}, + "equals": {"description": "JSON value to match; omitted means \"pointer exists\"."}, + "page": {"type": "string"} + }, + "required": ["pointer"] + }), + }, + ToolDef { + name: "figmog_at", + description: "Nodes whose absolute bounds contain a point, sorted by area ascending (deepest/smallest first) — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"} + }, + "required": ["x", "y"] + }), + }, + ] +} From 82c395e7660e4ea91c9d1734777cee255a80b7c9 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 23:39:19 -0700 Subject: [PATCH 29/56] fix(figmog): advance sync backoff/next tick on failed figmog_sync A failed figmog_sync tool call left pull_backoff and next_deadline untouched, so a background watch tick could still fire back into a rate-limit window the server had just been told about. Reuse pull_failure_wait (same as the tick-triggered pull path) to advance the backoff and push next_deadline out on failure. Co-Authored-By: Claude Fable 5 --- examples/figmog/src/serve.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/examples/figmog/src/serve.rs b/examples/figmog/src/serve.rs index e1bae25..9a50043 100644 --- a/examples/figmog/src/serve.rs +++ b/examples/figmog/src/serve.rs @@ -256,7 +256,18 @@ pub(crate) fn run_serve( }); Ok(store::sync(&mut st, &prior, &flattened, now_ms())) })(); - let churn = pull_result.map_err(|e| e.to_string())?; + // A failed manual sync still spends the same backoff + // budget as a failed background tick, and — when watch + // is enabled — the next tick must not fire back into a + // rate-limit window this call just learned about. + let churn = match pull_result { + Ok(c) => c, + Err(e) => { + let wait = pull_failure_wait(&e, &mut pull_backoff, interval_dur); + next_deadline = Instant::now() + wait; + return Err(e.to_string()); + } + }; stored = st.rtx(|(_, _, _, _, _, _, meta)| meta.get(&0).map(|m| m.last_modified)); pull_backoff = BACKOFF_START; From 99a8613c067127f3826d4585045934ce970ed408 Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 23:46:28 -0700 Subject: [PATCH 30/56] feat(figmog): serve e2e tests and MCP docs Co-Authored-By: Claude Fable 5 --- README.md | 3 +- examples/figmog/README.md | 78 +++++++++ examples/figmog/src/query.rs | 2 +- examples/figmog/tests/common/mod.rs | 25 +++ examples/figmog/tests/serve.rs | 241 ++++++++++++++++++++++++++++ 5 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 examples/figmog/tests/serve.rs diff --git a/README.md b/README.md index 2991b30..834dfad 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ In this directory you'll find a few examples that show bog style databases in va - `chat` — a chat backend where fold is the source of truth and every update is broadcast to clients over a websocket. `cargo run -p chat`, then open http://localhost:3000 - `search` — text search three ways over one document stream: BM25 keyword search, HNSW semantic search over ese embeddings, and hybrid rank fusion. A good base for agent memory or document search projects. `cargo run -p search` - `figmog` — a local mirror of a Figma file: sync once, then search, walk, and - query components/styles/variables with zero API calls. `cargo run -p figmog -- --help` + query components/styles/variables with zero API calls, and an MCP server + (`figmog serve`). `cargo run -p figmog -- --help` ## More about Bog Bog is a database runtime that makes every attempt to do as much work as possible as early as possible, to make reads incredibly fast. This means compiling queries into functions that eagerly update their output as mutations occur. diff --git a/examples/figmog/README.md b/examples/figmog/README.md index b4da5df..0a0de23 100644 --- a/examples/figmog/README.md +++ b/examples/figmog/README.md @@ -30,6 +30,7 @@ store location (default `.figmog//db`). |---|---|---| | `figmog pull [file] [--from-file ] [--fresh]` | — | sync now; prints a churn summary (`+added ~changed -removed`). `file` is optional after the first pull. `--from-file` ingests a saved `GET /v1/files/:key` response instead of the network (offline ingestion, and what keeps the CLI tests hermetic). `--fresh` wipes the store and rebuilds from scratch. | | `figmog watch [file] [--interval N]` | — | poll loop: cheap metadata check every `N` seconds (default 10), full pull only on an actual change | +| `figmog serve [file] [--interval N] [--no-watch]` | — | MCP stdio server (see "Use from agents (MCP)" below); `--no-watch` disables the poll loop for a read-only, offline server | | `figmog status` | meta + nodes | file name, version, last modified, node count | | `figmog pages` | by_type + nodes | list CANVAS pages (id, name) | | `figmog tree [id] [--depth N]` | children + nodes (+ by_type to find the root) | indented outline: `name [type] id`; root defaults to the DOCUMENT node | @@ -69,6 +70,83 @@ that budget workable for an agent that wants to treat the file as live. The Tier-3 meta poll itself is capped around **50 requests/min on Starter**, well above any sane `--interval`. +## Use from agents (MCP) + +`figmog serve` is figmog's other head onto the same store: an MCP stdio +server with the sync loop built in. It's one process — fjall is +single-writer, so a standalone MCP server would fight `figmog watch` for +the store lock — that owns the mirror, polls for changes exactly like +`watch`, and answers 17 `figmog_*` tools from whatever's currently in the +store. There's nothing else to run alongside it. + +```console +$ cargo build -p figmog +$ claude mcp add figmog -- /absolute/path/to/clog/target/debug/figmog serve "https://www.figma.com/design//" +``` + +Read-only / offline, once a store already exists (no `FIGMA_TOKEN` +needed): + +```console +$ claude mcp add figmog -- /absolute/path/to/clog/target/debug/figmog serve --db .figmog//db --no-watch +``` + +`--interval N` (default 10s) controls the poll cadence, same as `watch`. + +### Core read tools + +Each mirrors a CLI read command one-to-one and answers instantly from the +local store — zero Figma API cost, zero rate-limit exposure. + +| tool | input | reads | +|---|---|---| +| `figmog_status` | — | file name, version, last modified, node count | +| `figmog_pages` | — | list CANVAS pages (id, name), in document order | +| `figmog_tree` | `id`, `depth` | subtree outline rooted at a node; root defaults to the document | +| `figmog_node` | `id` (required), `children` | full `raw` JSON of one node; `children` inlines a one-level summary | +| `figmog_find` | `type` (required), `page` | nodes by Figma node type, optionally scoped to one page | +| `figmog_search` | `query` (required), `limit` | BM25 search over layer names and text content | +| `figmog_instances` | `target` (required) | instances of a component, resolved by node id, key, or (set) name | +| `figmog_components` | — | design-system inventory: sets with variant axes, standalone components | +| `figmog_styles` | `type`, `values` | styles with usage counts; `values` derives each definition from a consumer | +| `figmog_uses` | `id` (required) | nodes using a style id or bound to a variable id | +| `figmog_vars` | `id` | variables: authoritative if imported, else inferred from bindings | +| `figmog_sync` | — | forces one pull and returns the churn — the **only** tool that spends Figma's rate budget | + +### Whole-file structural queries + +The local mirror's unfair advantage: full-file answers no rate-limited API +surface could offer, each a read-only scan/join over the same indexes. +Every one has a matching CLI subcommand, so the CLI/tool surface stays +one-to-one. + +| tool | CLI equivalent | input | answer | +|---|---|---|---| +| `figmog_stats` | `figmog stats` | — | node counts by type/page, component/set/style/variable totals, text-node count, max tree depth | +| `figmog_path` | `figmog path ` | `id` (required) | ancestor chain root→node as `[{id, name, type}]` | +| `figmog_text` | `figmog text [--page id]` | `page` | every TEXT node's `(id, characters, page_id)`, sorted by id | +| `figmog_where` | `figmog where --pointer /p --equals ` | `pointer` (required, RFC 6901 into `raw`), `equals`, `page` | matching `[{id, name, type, page_id, value}]`, sorted by id | +| `figmog_at` | `figmog at --x N --y N` | `x`, `y` (required) | nodes whose `abs_bounds` contain the point, sorted by area ascending (deepest/smallest first) | + +### Relationship to Figma's official MCP server + +figmog is a second, separate MCP server — connect it alongside Figma's +official one, not instead of it. Every figmog tool lives in the +`figmog_*` namespace, so the two servers' tools never collide by name. +figmog's `initialize` response carries steering `instructions` telling an +agent when to reach for which: + +> figmog is a local, instant, rate-limit-free mirror of one Figma file. +> Use figmog tools for ALL structure, search, components, styles, and +> variables. Use the official Figma MCP only for code generation or +> screenshots — never for reads figmog can answer. + +The two servers have zero capability overlap: figmog only ever reads its +local mirror and only ever writes to it via `figmog_sync`, which is the +one tool among the 17 that spends Figma's Tier-1 rate budget (a forced +pull) — every other tool call is instant, free, and backed by the same +fold-materialized indexes the CLI reads. + ## Variables on a free plan The Variables REST endpoints (`variables/local`, `variables/published`) diff --git a/examples/figmog/src/query.rs b/examples/figmog/src/query.rs index 2571df1..a6a1ff3 100644 --- a/examples/figmog/src/query.rs +++ b/examples/figmog/src/query.rs @@ -537,7 +537,7 @@ pub fn stats( /// Ancestor chain root→node, as `[{id, name, type}]`. Unknown id → Err. /// A `parent_id` cycle (a corrupted store) is also an `Err` rather than an -/// infinite loop — see [`depth_of`]'s doc comment for why that matters here. +/// infinite loop — see `depth_of`'s doc comment for why that matters here. pub fn path( nodes: &TableReader<'_, R, String, NodeRec>, id: String, diff --git a/examples/figmog/tests/common/mod.rs b/examples/figmog/tests/common/mod.rs index e57ce09..22635a5 100644 --- a/examples/figmog/tests/common/mod.rs +++ b/examples/figmog/tests/common/mod.rs @@ -1,6 +1,7 @@ //! Synthetic Figma file fixtures. Deliberately NOT derived from any real //! file. Shape mirrors GET /v1/files/:key responses. +use assert_cmd::Command; use serde_json::{Value, json}; /// 12 nodes over 3 pages: a hero frame with a text, a variant'd button @@ -97,3 +98,27 @@ pub fn fixture_v2() -> Value { })); v } + +/// Materialize [`fixture_v1`] into a DB via `pull --from-file` and return the +/// (tempdir, db-path) pair every read command — CLI or `serve` — needs. +/// Shared so `tests/cli.rs` and `tests/serve.rs` build the same fixture the +/// same way. +#[allow(dead_code)] // not every test binary that includes this module calls it +pub fn fixture_db() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let response = dir.path().join("resp.json"); + std::fs::write(&response, serde_json::to_string(&fixture_v1()).unwrap()).unwrap(); + let db = dir.path().join("db"); + Command::cargo_bin("figmog") + .unwrap() + .args([ + "pull", + "--from-file", + response.to_str().unwrap(), + "--db", + db.to_str().unwrap(), + ]) + .assert() + .success(); + (dir, db) +} diff --git a/examples/figmog/tests/serve.rs b/examples/figmog/tests/serve.rs new file mode 100644 index 0000000..0f7932a --- /dev/null +++ b/examples/figmog/tests/serve.rs @@ -0,0 +1,241 @@ +#![recursion_limit = "256"] + +//! End-to-end test of `figmog serve`: spawns the real compiled binary as a +//! child process, drives it over stdin/stdout exactly as an MCP client +//! would, and asserts on the JSON-RPC frames it writes back. Everything +//! else in this crate tests the pieces (`mcp::handle_message` unit tests, +//! CLI smoke tests over `query::*`); this is the one test proving the +//! pieces are wired together correctly in the real process, including the +//! stdin-EOF exit contract `--no-watch` mode relies on. + +mod common; + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +/// Generous but bounded: every wait in this test — for a response line or +/// for the child to exit — is capped at this, so a regression that makes +/// the server hang fails the test instead of the test run. +const TIMEOUT: Duration = Duration::from_secs(10); + +/// Kills the child on drop so a failed assertion (which unwinds past the +/// rest of the test body, skipping the normal stdin-close/wait sequence) +/// never leaves an orphaned `figmog serve` process behind. +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// Spawn `figmog serve --no-watch --db ` with piped stdio. Returns the +/// kill-on-drop guard, a writer for stdin, and a channel of stdout lines +/// fed by a reader thread — driving the child through a channel (rather +/// than reading its stdout inline) means a hung child blocks only the +/// bounded `recv_timeout` in [`recv`], never the test thread itself. +fn spawn_serve(db: &std::path::Path) -> (ChildGuard, ChildStdin, Receiver) { + let bin = assert_cmd::cargo::cargo_bin("figmog"); + let mut child = Command::new(bin) + .args(["serve", "--no-watch", "--db"]) + .arg(db) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn figmog serve"); + + let stdin = child.stdin.take().expect("child stdin"); + let stdout = child.stdout.take().expect("child stdout"); + let stderr = child.stderr.take().expect("child stderr"); + + // Drain stderr on its own thread purely for debugging visibility + // (`serve` logs there, e.g. "figmog serving ..."); never asserted on. + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + eprintln!("[figmog serve stderr] {line}"); + } + }); + + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if tx.send(line).is_err() { + break; + } + } + }); + + (ChildGuard(child), stdin, rx) +} + +/// Write one JSON-RPC frame, newline-delimited (the protocol this crate's +/// `mcp`/`serve` modules speak). +fn send(stdin: &mut ChildStdin, msg: &Value) { + writeln!(stdin, "{msg}").expect("write to child stdin"); + stdin.flush().expect("flush child stdin"); +} + +/// Read and parse the next response line, bounded by [`TIMEOUT`] so a +/// stuck server fails this assertion instead of hanging the test binary. +fn recv(rx: &Receiver) -> Value { + let line = rx + .recv_timeout(TIMEOUT) + .expect("figmog serve did not respond within the timeout"); + serde_json::from_str(&line) + .unwrap_or_else(|e| panic!("response line was not valid JSON: {e}\nline: {line}")) +} + +/// Poll `try_wait` instead of a single blocking `wait()`, so a child that +/// never exits fails with a clear panic at `timeout` rather than hanging +/// the test run forever. +fn wait_with_timeout(child: &mut Child, timeout: Duration) -> std::process::ExitStatus { + let start = Instant::now(); + loop { + if let Some(status) = child.try_wait().expect("try_wait") { + return status; + } + if start.elapsed() > timeout { + let _ = child.kill(); + panic!("figmog serve did not exit within {timeout:?} of stdin EOF"); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn call(stdin: &mut ChildStdin, rx: &Receiver, id: i64, name: &str, args: Value) -> Value { + send( + stdin, + &json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": {"name": name, "arguments": args}, + }), + ); + recv(rx) +} + +/// The tool result's text content, parsed as JSON (every `figmog_*` tool +/// returns `query::*` JSON serialized as the single text content block). +fn result_json(resp: &Value) -> Value { + let text = resp["result"]["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("no text content in: {resp}")); + serde_json::from_str(text) + .unwrap_or_else(|e| panic!("content text not JSON: {e}\ntext: {text}")) +} + +#[test] +fn serve_e2e_initialize_tools_list_and_tool_calls() { + let (_dir, db) = common::fixture_db(); + let (mut guard, mut stdin, rx) = spawn_serve(&db); + + // -- initialize -- + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }), + ); + let resp = recv(&rx); + assert_eq!(resp["id"], json!(1)); + assert_eq!(resp["result"]["serverInfo"]["name"], json!("figmog")); + let instructions = resp["result"]["instructions"] + .as_str() + .expect("instructions is a string"); + assert!(!instructions.is_empty()); + assert!( + instructions.contains("official Figma MCP"), + "instructions should mention the official Figma MCP: {instructions}" + ); + + // notifications/initialized: no `id`, so no response frame is expected + // (mirrors a real MCP client's handshake; the server ignores it). + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + ); + + // -- tools/list: exactly 17 figmog_* tools -- + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), + ); + let resp = recv(&rx); + let tools = resp["result"]["tools"].as_array().expect("tools array"); + assert_eq!(tools.len(), 17, "tools: {tools:#?}"); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + for name in &names { + assert!( + name.starts_with("figmog_"), + "tool outside the figmog_ namespace: {name}" + ); + } + for expected in ["figmog_search", "figmog_where", "figmog_sync"] { + assert!(names.contains(&expected), "missing tool: {expected}"); + } + + // -- figmog_search: first hit is 1:2 ("Title", text "...garden") -- + let resp = call( + &mut stdin, + &rx, + 3, + "figmog_search", + json!({"query": "garden"}), + ); + assert_eq!(resp["result"]["isError"], json!(false)); + let hits = result_json(&resp); + assert_eq!(hits[0]["id"], json!("1:2")); + + // -- figmog_node: id normalization (12-34 form) + raw JSON name -- + let resp = call(&mut stdin, &rx, 4, "figmog_node", json!({"id": "1-2"})); + assert_eq!(resp["result"]["isError"], json!(false)); + let node = result_json(&resp); + assert_eq!(node["name"], json!("Title")); + + // -- figmog_where: exactly one row, id 1:1 -- + let resp = call( + &mut stdin, + &rx, + 5, + "figmog_where", + json!({"pointer": "/layoutMode", "equals": "VERTICAL"}), + ); + assert_eq!(resp["result"]["isError"], json!(false)); + let rows = result_json(&resp); + let rows = rows.as_array().expect("rows array"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["id"], json!("1:1")); + + // -- figmog_node on an unknown id: isError -- + let resp = call(&mut stdin, &rx, 6, "figmog_node", json!({"id": "99:99"})); + assert_eq!(resp["result"]["isError"], json!(true)); + + // -- unknown JSON-RPC method: -32601 -- + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "id": 7, "method": "totally/bogus"}), + ); + let resp = recv(&rx); + assert_eq!(resp["error"]["code"], json!(-32601)); + + // -- unknown tool name: isError, not a protocol-level error -- + let resp = call(&mut stdin, &rx, 8, "figmog_nonexistent", json!({})); + assert_eq!(resp["result"]["isError"], json!(true)); + + // Closing stdin is what makes the (`--no-watch`) serve loop exit: its + // reader thread sees EOF and drops the sender, so the main loop's + // blocking `rx.recv()` returns `Disconnected` and the process exits 0. + drop(stdin); + let status = wait_with_timeout(&mut guard.0, TIMEOUT); + assert!(status.success(), "figmog serve exited with {status:?}"); +} From 81bb59bc25e9136231017f3e2635ba5660e0044b Mon Sep 17 00:00:00 2001 From: hhff Date: Sat, 15 Aug 2026 23:54:15 -0700 Subject: [PATCH 31/56] feat(figmog): upstream MCP client for the Figma desktop server Co-Authored-By: Claude Fable 5 --- examples/figmog/src/lib.rs | 1 + examples/figmog/src/upstream.rs | 635 ++++++++++++++++++++++++++++++++ 2 files changed, 636 insertions(+) create mode 100644 examples/figmog/src/upstream.rs diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs index 986be7c..18b16ac 100644 --- a/examples/figmog/src/lib.rs +++ b/examples/figmog/src/lib.rs @@ -18,5 +18,6 @@ pub mod model; pub mod query; pub mod serve; pub mod store; +pub mod upstream; pub mod vars; pub mod watch; diff --git a/examples/figmog/src/upstream.rs b/examples/figmog/src/upstream.rs new file mode 100644 index 0000000..4d8c475 --- /dev/null +++ b/examples/figmog/src/upstream.rs @@ -0,0 +1,635 @@ +//! Client for Figma's native desktop MCP server (streamable HTTP). +//! +//! This is figmog's half of the "cached proxy" design in build design §12: +//! figmog probes the local Figma desktop app's Dev Mode MCP server +//! (`http://127.0.0.1:3845/mcp` by default) at startup, forwards calls it +//! doesn't handle itself, and — per §12's cache rules, implemented +//! elsewhere — caches `get_*`/`list_*` responses keyed by file version. +//! This module owns only the wire protocol: MCP's `initialize` handshake, +//! `tools/list`, and `tools/call`, all as JSON-RPC 2.0 frames POSTed to one +//! URL. Registry merge, routing, and caching are Task 8's job (`serve.rs`). +//! +//! [`UpstreamMcp`] is the seam: [`HttpUpstream`] is the real client, and +//! [`FakeUpstream`] is a scripted double other test suites in this crate +//! (e.g. `tests/serve.rs`) can drive without a live desktop app. + +use std::collections::VecDeque; +use std::time::Duration; + +use serde_json::{Value, json}; + +/// Errors surfaced by an [`UpstreamMcp`] implementation. +#[derive(Debug, thiserror::Error)] +pub enum UpstreamError { + /// Transport-level failure: connection refused, DNS failure, timeout — + /// the upstream server could not be reached at all. + #[error("upstream unreachable: {0}")] + Unreachable(String), + /// The upstream was reached but the exchange was invalid: a malformed + /// frame, an unparseable response body, or a JSON-RPC `error` member. + #[error("upstream protocol error: {0}")] + Protocol(String), +} + +/// The three calls figmog's proxy needs from an upstream MCP server. +pub trait UpstreamMcp { + /// Perform the MCP handshake (`initialize` + `notifications/initialized`) + /// and populate the tool list returned by [`tools`](Self::tools). + fn initialize(&mut self) -> Result<(), UpstreamError>; + /// The tools discovered by the most recent [`initialize`](Self::initialize) + /// call, verbatim as returned by the upstream's `tools/list`. + fn tools(&self) -> &[Value]; + /// Invoke `tools/call` on the upstream and return the JSON-RPC `result` + /// value (the same shape the MCP spec gives a client: typically + /// `{"content": [...], "isError": bool}`). + fn call(&mut self, name: &str, args: &Value) -> Result; +} + +/// MCP protocol version figmog's `initialize` request declares. +const PROTOCOL_VERSION: &str = "2025-06-18"; + +/// Blocking `ureq`-backed [`UpstreamMcp`] against a streamable-HTTP MCP +/// server (Figma's desktop app, by default at +/// `http://127.0.0.1:3845/mcp`). +pub struct HttpUpstream { + url: String, + agent: ureq::Agent, + session_id: Option, + next_id: u64, + tools: Vec, +} + +impl HttpUpstream { + /// A client posting JSON-RPC frames to `url`. Does not connect until + /// [`initialize`](UpstreamMcp::initialize) or + /// [`call`](UpstreamMcp::call) is called. + pub fn new(url: String) -> Self { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(10)) + .build(); + HttpUpstream { + url, + agent, + session_id: None, + next_id: 1, + tools: Vec::new(), + } + } + + fn next_request_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id += 1; + id + } + + /// POST one JSON-RPC frame and return the parsed JSON-RPC response + /// object (still containing its `result`/`error` wrapper — callers use + /// [`extract_result`] to unwrap it). Captures `Mcp-Session-Id` from the + /// response header, if present, for subsequent requests. + fn send_request(&mut self, body: &Value) -> Result { + let (content_type, text) = self.post(body)?; + parse_streamable_body(&content_type, &text) + } + + /// POST a notification (no response body expected): fire-and-forget, + /// still subject to the session header dance and transport error + /// mapping, but the body (if any) is discarded rather than parsed. + fn send_notification(&mut self, body: &Value) -> Result<(), UpstreamError> { + self.post(body)?; + Ok(()) + } + + /// Shared transport: POST `body`, capture/refresh the session header, + /// and return `(content_type, body_text)` for the caller to interpret. + /// Both success and HTTP-error responses are read the same way — an + /// MCP server can return a JSON-RPC `error` object on a non-2xx status. + fn post(&mut self, body: &Value) -> Result<(String, String), UpstreamError> { + let mut req = self + .agent + .post(&self.url) + .set("Content-Type", "application/json") + .set("Accept", "application/json, text/event-stream"); + if let Some(session_id) = &self.session_id { + req = req.set("Mcp-Session-Id", session_id); + } + let resp = match req.send_json(body.clone()) { + Ok(resp) => resp, + Err(ureq::Error::Status(_, resp)) => resp, + Err(ureq::Error::Transport(e)) => { + return Err(UpstreamError::Unreachable(e.to_string())); + } + }; + if let Some(session_id) = resp.header("Mcp-Session-Id") { + self.session_id = Some(session_id.to_string()); + } + let content_type = resp.content_type().to_string(); + let text = resp + .into_string() + .map_err(|e| UpstreamError::Protocol(format!("failed to read response body: {e}")))?; + Ok((content_type, text)) + } +} + +impl UpstreamMcp for HttpUpstream { + fn initialize(&mut self) -> Result<(), UpstreamError> { + let id = self.next_request_id(); + let init_req = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "figmog", "version": env!("CARGO_PKG_VERSION")}, + }, + }); + let resp = self.send_request(&init_req)?; + extract_result(resp)?; + + self.send_notification(&json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }))?; + + let list_id = self.next_request_id(); + let list_req = json!({ + "jsonrpc": "2.0", + "id": list_id, + "method": "tools/list", + }); + let resp = self.send_request(&list_req)?; + let result = extract_result(resp)?; + self.tools = result + .get("tools") + .and_then(Value::as_array) + .cloned() + .ok_or_else(|| { + UpstreamError::Protocol("tools/list result missing `tools` array".into()) + })?; + Ok(()) + } + + fn tools(&self) -> &[Value] { + &self.tools + } + + fn call(&mut self, name: &str, args: &Value) -> Result { + let id = self.next_request_id(); + let req = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": {"name": name, "arguments": args}, + }); + let resp = self.send_request(&req)?; + extract_result(resp) + } +} + +/// Unwrap a JSON-RPC response object: `{"error": {...}}` becomes +/// [`UpstreamError::Protocol`]; otherwise the `result` member is returned +/// (missing `result` is itself a protocol error — never a panic). +fn extract_result(resp: Value) -> Result { + if let Some(err) = resp.get("error") { + let msg = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("upstream returned an error") + .to_string(); + return Err(UpstreamError::Protocol(msg)); + } + resp.get("result") + .cloned() + .ok_or_else(|| UpstreamError::Protocol("response has neither `result` nor `error`".into())) +} + +/// Parse a streamable-HTTP MCP response body given its Content-Type: +/// `application/json` is a single JSON-RPC object; `text/event-stream` is +/// SSE, whose final `data:` event is the JSON-RPC response (consecutive +/// `data:` lines within one event join with `\n`; events are separated by +/// a blank line). Any other content type, or a body that doesn't parse, is +/// a [`UpstreamError::Protocol`] — never a panic. +fn parse_streamable_body(content_type: &str, body: &str) -> Result { + let ct = content_type + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + match ct.as_str() { + "application/json" => serde_json::from_str(body) + .map_err(|e| UpstreamError::Protocol(format!("invalid JSON body: {e}"))), + "text/event-stream" => parse_sse_last_event(body), + other => Err(UpstreamError::Protocol(format!( + "unexpected content-type: {other}" + ))), + } +} + +/// Parse an SSE stream and return the last event's `data:` payload as +/// JSON. Events are separated by a blank line; within one event, +/// consecutive `data:` lines join with `\n` per the SSE spec. +fn parse_sse_last_event(body: &str) -> Result { + let normalized = body.replace("\r\n", "\n"); + let mut last: Option = None; + for event in normalized.split("\n\n") { + let mut data_lines = Vec::new(); + for line in event.lines() { + if let Some(rest) = line.strip_prefix("data:") { + data_lines.push(rest.strip_prefix(' ').unwrap_or(rest)); + } + } + if data_lines.is_empty() { + continue; + } + let joined = data_lines.join("\n"); + match serde_json::from_str::(&joined) { + Ok(v) => last = Some(v), + Err(_) => continue, + } + } + last.ok_or_else(|| UpstreamError::Protocol("no JSON-RPC event found in SSE stream".into())) +} + +/// A scripted [`UpstreamMcp`] double for tests. `tools` is returned +/// verbatim by [`tools()`](UpstreamMcp::tools); `results` is a FIFO queue +/// consumed one entry per [`call()`](UpstreamMcp::call) — push whatever a +/// test needs the next call(s) to return, in order. `call_count` lets a +/// test assert whether the upstream was hit at all (e.g. a cache-hit test +/// asserting the fake was *not* called a second time). +/// +/// `pub` (not `#[cfg(test)]`) so other test binaries in this crate, such +/// as `tests/serve.rs`, can construct and script it directly. +pub struct FakeUpstream { + pub tools: Vec, + pub results: VecDeque>, + pub call_count: usize, + pub initialize_calls: usize, +} + +impl FakeUpstream { + /// A fake exposing `tools` from `tools/list`, with no scripted call + /// results yet — push some with [`push_result`](Self::push_result) + /// before driving any [`call`](UpstreamMcp::call). + pub fn new(tools: Vec) -> Self { + FakeUpstream { + tools, + results: VecDeque::new(), + call_count: 0, + initialize_calls: 0, + } + } + + /// Queue the result the next [`call()`](UpstreamMcp::call) returns. + pub fn push_result(&mut self, result: Result) { + self.results.push_back(result); + } +} + +impl UpstreamMcp for FakeUpstream { + fn initialize(&mut self) -> Result<(), UpstreamError> { + self.initialize_calls += 1; + Ok(()) + } + + fn tools(&self) -> &[Value] { + &self.tools + } + + fn call(&mut self, name: &str, args: &Value) -> Result { + let _ = (name, args); + self.call_count += 1; + self.results + .pop_front() + .unwrap_or_else(|| Err(UpstreamError::Protocol("no scripted result queued".into()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::{Arc, Mutex}; + use std::thread; + + // --- pure helper tests ------------------------------------------------- + + #[test] + fn parses_application_json_body() { + let v = parse_streamable_body( + "application/json", + r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#, + ) + .unwrap(); + assert_eq!(v, json!({"jsonrpc":"2.0","id":1,"result":{"ok":true}})); + } + + #[test] + fn parses_application_json_body_with_charset_param() { + let v = parse_streamable_body( + "application/json; charset=utf-8", + r#"{"jsonrpc":"2.0","id":1,"result":{}}"#, + ) + .unwrap(); + assert_eq!(v["id"], json!(1)); + } + + #[test] + fn parses_single_event_sse_body() { + let body = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n"; + let v = parse_streamable_body("text/event-stream", body).unwrap(); + assert_eq!(v, json!({"jsonrpc":"2.0","id":1,"result":{"ok":true}})); + } + + #[test] + fn sse_multiline_data_within_one_event_joins_with_newline() { + // Per SSE rules, consecutive `data:` lines within one event join + // with `\n`. Split across two `data:` lines, the JSON is only + // valid once joined — proving the join actually happens. + let body = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"a\":1,\ndata: \"b\":2}}\n\n"; + let v = parse_streamable_body("text/event-stream", body).unwrap(); + assert_eq!(v["result"], json!({"a": 1, "b": 2})); + } + + #[test] + fn sse_takes_last_event_when_multiple_present() { + let body = concat!( + "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"first\":true}}\n\n", + "data: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"second\":true}}\n\n", + ); + let v = parse_streamable_body("text/event-stream", body).unwrap(); + assert_eq!(v["result"], json!({"second": true})); + } + + #[test] + fn unknown_content_type_is_protocol_error() { + let err = parse_streamable_body("text/plain", "hello").unwrap_err(); + assert!(matches!(err, UpstreamError::Protocol(_))); + } + + #[test] + fn malformed_json_body_is_protocol_error_not_panic() { + let err = parse_streamable_body("application/json", "{not json").unwrap_err(); + assert!(matches!(err, UpstreamError::Protocol(_))); + } + + #[test] + fn sse_body_with_no_data_lines_is_protocol_error() { + let err = parse_streamable_body("text/event-stream", "event: ping\n\n").unwrap_err(); + assert!(matches!(err, UpstreamError::Protocol(_))); + } + + #[test] + fn extract_result_unwraps_result_member() { + let v = extract_result(json!({"jsonrpc":"2.0","id":1,"result":{"ok":true}})).unwrap(); + assert_eq!(v, json!({"ok": true})); + } + + #[test] + fn extract_result_maps_error_member_to_protocol_error() { + let err = extract_result(json!({ + "jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"boom"} + })) + .unwrap_err(); + assert!(matches!(err, UpstreamError::Protocol(msg) if msg == "boom")); + } + + #[test] + fn extract_result_missing_both_members_is_protocol_error_not_panic() { + let err = extract_result(json!({"jsonrpc":"2.0","id":1})).unwrap_err(); + assert!(matches!(err, UpstreamError::Protocol(_))); + } + + #[test] + fn frame_construction_initialize_and_call_shapes() { + // initialize's params shape, independent of any network I/O. + let init = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "figmog", "version": env!("CARGO_PKG_VERSION")}, + }, + }); + assert_eq!(init["params"]["protocolVersion"], json!("2025-06-18")); + assert_eq!(init["params"]["clientInfo"]["name"], json!("figmog")); + + let call = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "get_code", "arguments": {"nodeId": "1:2"}}, + }); + assert_eq!(call["method"], json!("tools/call")); + assert_eq!(call["params"]["name"], json!("get_code")); + assert_eq!(call["params"]["arguments"]["nodeId"], json!("1:2")); + } + + // --- FakeUpstream -------------------------------------------------- + + #[test] + fn fake_upstream_serves_scripted_results_in_order_and_counts_calls() { + let mut fake = FakeUpstream::new(vec![json!({"name": "get_code"})]); + fake.push_result(Ok(json!({"first": true}))); + fake.push_result(Err(UpstreamError::Protocol("second fails".into()))); + + fake.initialize().unwrap(); + assert_eq!(fake.tools().len(), 1); + + assert_eq!( + fake.call("get_code", &json!({})).unwrap(), + json!({"first": true}) + ); + assert!(fake.call("get_code", &json!({})).is_err()); + assert_eq!(fake.call_count, 2); + // Queue exhausted: next call reports a clear scripting error, not a panic. + assert!(fake.call("get_code", &json!({})).is_err()); + assert_eq!(fake.call_count, 3); + } + + // --- in-process HTTP fake: full handshake + call ----------------------- + + fn read_request(stream: &mut TcpStream) -> (String, String) { + let mut header_bytes = Vec::new(); + let mut byte = [0u8; 1]; + loop { + stream.read_exact(&mut byte).expect("read request byte"); + header_bytes.push(byte[0]); + if header_bytes.ends_with(b"\r\n\r\n") { + break; + } + } + let header_text = String::from_utf8_lossy(&header_bytes).to_string(); + let content_length: usize = header_text + .lines() + .find_map(|line| { + let lower = line.to_ascii_lowercase(); + lower + .strip_prefix("content-length:") + .map(|v| v.trim().parse().unwrap_or(0)) + }) + .unwrap_or(0); + let mut body_bytes = vec![0u8; content_length]; + if content_length > 0 { + stream + .read_exact(&mut body_bytes) + .expect("read request body"); + } + ( + header_text, + String::from_utf8_lossy(&body_bytes).to_string(), + ) + } + + fn write_response(stream: &mut TcpStream, status: &str, headers: &[(&str, &str)], body: &str) { + let mut resp = format!("HTTP/1.1 {status}\r\n"); + resp.push_str(&format!("Content-Length: {}\r\n", body.len())); + // Force a fresh TCP connection per request so this hand-rolled + // single-shot server never has to multiplex keep-alive requests. + resp.push_str("Connection: close\r\n"); + for (k, v) in headers { + resp.push_str(&format!("{k}: {v}\r\n")); + } + resp.push_str("\r\n"); + resp.push_str(body); + stream.write_all(resp.as_bytes()).expect("write response"); + stream.flush().expect("flush response"); + } + + fn request_id(body: &str) -> Value { + serde_json::from_str::(body) + .ok() + .and_then(|v| v.get("id").cloned()) + .unwrap_or(Value::Null) + } + + /// Drives `HttpUpstream` through initialize (which itself is two HTTP + /// requests: `initialize` then the `notifications/initialized` + /// notification) → `tools/list` → `tools/call`, against a hand-rolled + /// HTTP/1.1 server on a thread. Asserts: the session id the fake issues + /// on the `initialize` response is echoed as `Mcp-Session-Id` on every + /// later request; an `application/json` response and a + /// `text/event-stream` response both parse. + #[test] + fn http_upstream_handshake_and_call_against_fake_server() { + const SESSION_ID: &str = "sess-abc123"; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + let seen_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seen_requests_srv = Arc::clone(&seen_requests); + + let server = thread::spawn(move || { + for i in 0..4u32 { + let (mut stream, _) = listener.accept().expect("accept"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set_read_timeout"); + let (headers, body) = read_request(&mut stream); + seen_requests_srv.lock().unwrap().push(headers); + + match i { + 0 => { + // initialize + let resp = json!({ + "jsonrpc": "2.0", + "id": request_id(&body), + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "serverInfo": {"name": "fake-figma-desktop", "version": "1.0"}, + }, + }) + .to_string(); + write_response( + &mut stream, + "200 OK", + &[ + ("Content-Type", "application/json"), + ("Mcp-Session-Id", SESSION_ID), + ], + &resp, + ); + } + 1 => { + // notifications/initialized + write_response(&mut stream, "202 Accepted", &[], ""); + } + 2 => { + // tools/list, delivered as a single-event SSE stream + let inner = json!({ + "jsonrpc": "2.0", + "id": request_id(&body), + "result": {"tools": [ + {"name": "get_code", "description": "d", "inputSchema": {"type": "object"}}, + ]}, + }) + .to_string(); + let sse = format!("data: {inner}\n\n"); + write_response( + &mut stream, + "200 OK", + &[("Content-Type", "text/event-stream")], + &sse, + ); + } + 3 => { + // tools/call + let resp = json!({ + "jsonrpc": "2.0", + "id": request_id(&body), + "result": {"content": [{"type": "text", "text": "ok"}], "isError": false}, + }) + .to_string(); + write_response( + &mut stream, + "200 OK", + &[("Content-Type", "application/json")], + &resp, + ); + } + _ => unreachable!(), + } + } + }); + + let url = format!("http://{addr}/mcp"); + let mut upstream = HttpUpstream::new(url); + upstream.initialize().expect("initialize"); + assert_eq!(upstream.tools().len(), 1); + assert_eq!(upstream.tools()[0]["name"], json!("get_code")); + + let result = upstream + .call("get_code", &json!({"nodeId": "1:2"})) + .expect("call"); + assert_eq!(result["content"][0]["text"], json!("ok")); + + server.join().expect("server thread"); + + let reqs = seen_requests.lock().unwrap(); + assert_eq!(reqs.len(), 4); + // The initialize request predates the session id, so it must not + // carry one yet. + assert!(!reqs[0].to_ascii_lowercase().contains("mcp-session-id")); + // Every request after the initialize response must echo it. + for req in &reqs[1..] { + assert!( + req.to_ascii_lowercase() + .contains(&format!("mcp-session-id: {SESSION_ID}")), + "expected Mcp-Session-Id header, got: {req}" + ); + } + } + + #[test] + fn http_upstream_unreachable_url_is_unreachable_error() { + // Port 0 as a *target* (not bind) is refused immediately by the OS + // on every platform we run on, so this never actually blocks. + let mut upstream = HttpUpstream::new("http://127.0.0.1:0/mcp".to_string()); + let err = upstream.initialize().unwrap_err(); + assert!(matches!(err, UpstreamError::Unreachable(_))); + } +} From 78786e8f9934ae1a0b3309ee4f92d9faf212f79e Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 00:04:22 -0700 Subject: [PATCH 32/56] feat(figmog): version-keyed proxy response cache Co-Authored-By: Claude Fable 5 --- examples/figmog/src/cache.rs | 101 ++++++++++++++++++++++++++++++++++ examples/figmog/src/cli.rs | 6 +- examples/figmog/src/lib.rs | 1 + examples/figmog/src/model.rs | 23 ++++++++ examples/figmog/src/serve.rs | 10 ++-- examples/figmog/src/store.rs | 47 +++++++++++++++- examples/figmog/tests/sync.rs | 98 +++++++++++++++++++++++++++++++-- 7 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 examples/figmog/src/cache.rs diff --git a/examples/figmog/src/cache.rs b/examples/figmog/src/cache.rs new file mode 100644 index 0000000..1b7d370 --- /dev/null +++ b/examples/figmog/src/cache.rs @@ -0,0 +1,101 @@ +//! Version-keyed proxy response cache (spec §12). +//! +//! An upstream `get_*`/`list_*` call whose args carry an explicit node id +//! is cacheable: key it by `hash(tool, canonical args)`, tag the row with +//! the file version it was fetched at, and only serve it back while that +//! version is still current. `store::stale_cache_ids` / +//! `store::evict_stale_cache` handle eviction when the version moves on; +//! this module owns key hashing and the read/write helpers. + +use fold::pipeline::terminal::TableReader; +use fold::pipeline::{Keyed, Push}; +use fold::stream::{KeyedStream, Readable}; +use serde_json::Value; + +use crate::model::{Id, ProxyCacheRec, Rec}; + +/// Deterministic hex key for a `(tool, args_canonical)` pair: FNV-1a 64 +/// over `tool`'s bytes, a NUL separator, then `args_canonical`'s bytes. +/// The separator prevents boundary collisions (`tool="ab", args="c"` vs +/// `tool="a", args="bc"` would otherwise hash the same concatenation). +pub fn cache_key(tool: &str, args_canonical: &str) -> String { + const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + + let mut hash = OFFSET_BASIS; + for byte in tool + .as_bytes() + .iter() + .chain(std::iter::once(&0u8)) + .chain(args_canonical.as_bytes()) + { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(PRIME); + } + format!("{hash:016x}") +} + +/// Look up a cached response. A hit requires the stored row's +/// `file_version` to equal `current_version`; a miss (absent or stale) +/// returns `None` without evicting anything — eviction is a separate, +/// explicit step (see `store::evict_stale_cache`). +pub fn lookup( + cache: &TableReader<'_, R, String, ProxyCacheRec>, + tool: &str, + args_canonical: &str, + current_version: &str, +) -> Option { + let rec = cache.get(&cache_key(tool, args_canonical))?; + if rec.file_version != current_version { + return None; + } + serde_json::from_str(&rec.content).ok() +} + +/// Store (upsert) a response under its cache key, tagged with the file +/// version it was fetched at. +pub fn store>>( + st: &mut KeyedStream, + tool: &str, + args_canonical: &str, + file_version: &str, + content: &Value, +) { + let key = cache_key(tool, args_canonical); + let rec = ProxyCacheRec { + key_hash: key.clone(), + tool: tool.to_string(), + args_canonical: args_canonical.to_string(), + file_version: file_version.to_string(), + content: serde_json::to_string(content).unwrap_or_default(), + }; + st.wtx(|tx| { + tx.upsert(&Id::ProxyCache(key.clone()), &Rec::ProxyCache(rec.clone())); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_key_is_deterministic() { + assert_eq!( + cache_key("get_code", "{\"nodeId\":\"1:2\"}"), + cache_key("get_code", "{\"nodeId\":\"1:2\"}") + ); + } + + #[test] + fn cache_key_distinguishes_boundary_shift() { + assert_ne!(cache_key("ab", "c"), cache_key("a", "bc")); + } + + #[test] + fn cache_key_distinguishes_tool_and_args() { + assert_ne!( + cache_key("get_code", "{\"nodeId\":\"1:2\"}"), + cache_key("get_variable_defs", "{\"nodeId\":\"1:2\"}") + ); + } +} diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 23b82c7..c677a8f 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -186,7 +186,7 @@ fn dispatch(cli: Cli) -> Result<(), String> { let st = crate::open_store!(&db.path); let json = cli.json; match other { - Cmd::Status => st.rtx(|((nodes, _, _, _, _, _, _), _, _, _, _, _, meta)| { + Cmd::Status => st.rtx(|((nodes, _, _, _, _, _, _), _, _, _, _, _, meta, _)| { cmd_status(&nodes, &meta, json) }), Cmd::Pages => st @@ -232,7 +232,7 @@ fn dispatch(cli: Cli) -> Result<(), String> { cmd_uses(&nodes, &styled_by, &bound_to, id, json) }), Cmd::Vars { id } => st.rtx( - |((nodes, ..), _, _, _, variables, variable_collections, _)| { + |((nodes, ..), _, _, _, variables, variable_collections, _, _)| { cmd_vars(&nodes, &variables, &variable_collections, id, json) }, ), @@ -594,7 +594,7 @@ fn cmd_import_variables(db: &Db, path: PathBuf, json: bool) -> Result<(), String pub(crate) fn read_watermark(db: &Db) -> Option { let st = crate::open_store!(&db.path); - st.rtx(|(_, _, _, _, _, _, meta)| meta.get(&0).map(|m| m.last_modified)) + st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)) } // ---- core reads ---- diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs index 18b16ac..fb7b729 100644 --- a/examples/figmog/src/lib.rs +++ b/examples/figmog/src/lib.rs @@ -10,6 +10,7 @@ //! See `docs/superpowers/specs/2026-08-15-figmog-build-design.md`. pub mod api; +pub mod cache; pub mod cli; pub mod flatten; pub mod ident; diff --git a/examples/figmog/src/model.rs b/examples/figmog/src/model.rs index 0c41a45..fe5c087 100644 --- a/examples/figmog/src/model.rs +++ b/examples/figmog/src/model.rs @@ -17,6 +17,10 @@ pub enum Id { Variable(String), VariableCollection(String), Meta, + /// Cached upstream proxy response, keyed by its hash (spec §12). APPEND + /// ONLY: postcard encodes variant indices, so inserting a variant + /// earlier in this enum would corrupt every existing store. + ProxyCache(String), } /// One mirrored record; variant always matches its [`Id`] variant. @@ -29,6 +33,8 @@ pub enum Rec { Variable(VariableRec), VariableCollection(VariableCollectionRec), Meta(FileMeta), + /// See [`Id::ProxyCache`]. APPEND ONLY — see that variant's note. + ProxyCache(ProxyCacheRec), } /// One node of the document tree (children stripped from `raw`). @@ -125,6 +131,23 @@ pub struct FileMeta { pub synced_at_unix_ms: u64, } +/// One cached upstream proxy response (spec §12). A hit requires +/// `file_version` to equal the mirror's current [`FileMeta::version`]; a +/// version bump makes the row stale and eligible for eviction +/// (`store::stale_cache_ids` / `store::evict_stale_cache`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProxyCacheRec { + /// Hash of `tool` + `args_canonical`; identical to the [`Id::ProxyCache`] key. + pub key_hash: String, + pub tool: String, + /// Canonical JSON (`serde_json::to_string`) of the call arguments. + pub args_canonical: String, + /// File version this response was fetched at. + pub file_version: String, + /// Canonical JSON of the upstream MCP result content. + pub content: String, +} + #[cfg(test)] mod tests { use super::*; diff --git a/examples/figmog/src/serve.rs b/examples/figmog/src/serve.rs index 9a50043..cce3f41 100644 --- a/examples/figmog/src/serve.rs +++ b/examples/figmog/src/serve.rs @@ -95,7 +95,7 @@ pub(crate) fn run_serve( let mut st = crate::open_store!(&db.path); let mut stored: Option = - st.rtx(|(_, _, _, _, _, _, meta)| meta.get(&0).map(|m| m.last_modified)); + st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)); let mut watcher = Watcher::new(stored.clone()); let mut pull_backoff = BACKOFF_START; let tools = tool_registry(); @@ -142,7 +142,7 @@ pub(crate) fn run_serve( })(); match pull_result { Ok(_churn) => { - stored = st.rtx(|(_, _, _, _, _, _, meta)| { + stored = st.rtx(|(_, _, _, _, _, _, meta, _)| { meta.get(&0).map(|m| m.last_modified) }); pull_backoff = BACKOFF_START; @@ -169,7 +169,7 @@ pub(crate) fn run_serve( let mut handler = FnHandler(|name: &str, args: &Value| -> Result { match name { - "figmog_status" => st.rtx(|((nodes, _, _, _, _, _, _), _, _, _, _, _, meta)| { + "figmog_status" => st.rtx(|((nodes, _, _, _, _, _, _), _, _, _, _, _, meta, _)| { query::status(&nodes, &meta) }), "figmog_pages" => { @@ -234,7 +234,7 @@ pub(crate) fn run_serve( "figmog_vars" => { let id = arg_str(args, "id"); st.rtx( - |((nodes, ..), _, _, _, variables, variable_collections, _)| { + |((nodes, ..), _, _, _, variables, variable_collections, _, _)| { query::vars(&nodes, &variables, &variable_collections, id) }, ) @@ -269,7 +269,7 @@ pub(crate) fn run_serve( } }; stored = - st.rtx(|(_, _, _, _, _, _, meta)| meta.get(&0).map(|m| m.last_modified)); + st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)); pull_backoff = BACKOFF_START; watcher = Watcher::new(stored.clone()); if let Some(k) = &db.key { diff --git a/examples/figmog/src/store.rs b/examples/figmog/src/store.rs index 6263b02..97bfc6e 100644 --- a/examples/figmog/src/store.rs +++ b/examples/figmog/src/store.rs @@ -11,7 +11,7 @@ use fold::stream::KeyedStream; use serde::Serialize; use crate::flatten::Flattened; -use crate::model::{FileMeta, Id, NodeRec, Rec}; +use crate::model::{FileMeta, Id, NodeRec, ProxyCacheRec, Rec}; // ---- pipeline branch functions (pure; fold requires determinism) ---- @@ -118,6 +118,12 @@ rec_branch!( VariableCollection, crate::model::VariableCollectionRec ); +rec_branch!( + proxy_cache_only, + ProxyCache, + ProxyCache, + crate::model::ProxyCacheRec +); /// Feeds the `meta` table: the single [`FileMeta`] row, keyed by `0u8` /// (not `()`: `()` postcard-encodes to zero bytes and the store forbids @@ -180,6 +186,10 @@ macro_rules! figmog_pipeline { terminal::Table::new("variable_collections"), ), FilterMap::new($crate::store::meta_only, terminal::Table::new("meta")), + FilterMap::new( + $crate::store::proxy_cache_only, + terminal::Table::new("proxy_cache"), + ), ) }}; } @@ -268,3 +278,38 @@ pub fn collect_sweepable( out.extend(styles.iter().map(|(k, _)| Id::Style(k))); out } + +// ---- proxy cache eviction (spec §12) ---- +// +// Cache eviction is deliberately NOT folded into `sync`'s sweep: the sweep +// removes ids that vanished from the *newly flattened file*, whereas cache +// rows go stale only because the file *version* moved, independent of +// which nodes/components/styles are still live. Keeping it a separate +// pass means `sync`'s churn accounting (and every test that pins its +// numbers) is untouched by this feature. Callers run a version-changing +// pull, then `stale_cache_ids` + `evict_stale_cache` in a follow-up step. + +/// `ProxyCache` rows whose `file_version` no longer matches +/// `current_version` — the sweep set for [`evict_stale_cache`]. +pub fn stale_cache_ids( + cache: &fold::pipeline::terminal::TableReader<'_, R, String, ProxyCacheRec>, + current_version: &str, +) -> Vec { + cache + .iter() + .filter(|(_, rec)| rec.file_version != current_version) + .map(|(k, _)| Id::ProxyCache(k)) + .collect() +} + +/// Remove `stale` cache rows in one write transaction. Never touches +/// variables, collections, or the meta row — pass only ids gathered by +/// [`stale_cache_ids`]. +pub fn evict_stale_cache>>(st: &mut KeyedStream, stale: &[Id]) { + st.wtx(|tx| { + for id in stale { + debug_assert!(matches!(id, Id::ProxyCache(_))); + tx.remove(id); + } + }); +} diff --git a/examples/figmog/tests/sync.rs b/examples/figmog/tests/sync.rs index d777283..9e50b0d 100644 --- a/examples/figmog/tests/sync.rs +++ b/examples/figmog/tests/sync.rs @@ -68,6 +68,7 @@ fn initial_pull_populates_every_sink() { _vars, _colls, meta, + _cache, )| { assert_eq!(nodes.iter().count(), 12); assert_eq!(nodes.get(&"1:2".to_string()).unwrap().name, "Title"); @@ -144,7 +145,7 @@ fn reopen_resumes_persisted_state() { sync(&mut st, &BTreeSet::new(), &flattened, 1_000); } let st = figmog::open_store!(&db); - st.rtx(|((nodes, ..), _, _, _, _, _, _)| { + st.rtx(|((nodes, ..), _, _, _, _, _, _, _)| { assert_eq!(nodes.iter().count(), 12); }); } @@ -168,7 +169,7 @@ fn v1_to_v2_minimal_churn_and_index_consistency() { pull(&mut st, &common::fixture_v1()); let prior = st.rtx( - |((nodes, ..), components, component_sets, styles, _, _, _)| { + |((nodes, ..), components, component_sets, styles, _, _, _, _)| { figmog::store::collect_sweepable(&nodes, &components, &component_sets, &styles) }, ); @@ -199,6 +200,7 @@ fn v1_to_v2_minimal_churn_and_index_consistency() { _v, _vc, meta, + _cache, )| { // rename re-indexed in bm25 assert!(text.search("Headline", 5).iter().any(|h| h.val == "1:2")); @@ -260,12 +262,12 @@ fn sweep_never_touches_variables() { ); }); let prior = st.rtx( - |((nodes, ..), components, component_sets, styles, _, _, _)| { + |((nodes, ..), components, component_sets, styles, _, _, _, _)| { figmog::store::collect_sweepable(&nodes, &components, &component_sets, &styles) }, ); pull_with_sweep(&mut st, &common::fixture_v2(), prior, 2_000); - st.rtx(|(_, _, _, _, vars, colls, _)| { + st.rtx(|(_, _, _, _, vars, colls, _, _)| { assert!(vars.get(&"VariableID:100".to_string()).is_some()); assert!(colls.get(&"VC:1".to_string()).is_some()); }); @@ -303,7 +305,7 @@ fn panicking_transaction_rolls_back_entirely() { }) })); assert!(result.is_err()); - st.rtx(|((nodes, ..), _, _, _, _, _, meta)| { + st.rtx(|((nodes, ..), _, _, _, _, _, meta, _)| { assert!( nodes.get(&"9:9".to_string()).is_none(), "aborted upsert must not persist" @@ -312,3 +314,89 @@ fn panicking_transaction_rolls_back_entirely() { assert_eq!(meta.get(&0).unwrap().version, "100"); }); } + +/// Proxy cache rows (spec §12): survive a same-version repull, are evicted +/// by `stale_cache_ids` + `evict_stale_cache` after a version-changing +/// pull, and never perturb (or are perturbed by) the variable sweep-exempt +/// set — a manually imported variable survives both steps. +#[test] +fn proxy_cache_survives_same_version_and_is_evicted_on_version_change() { + use figmog::model::VariableRec; + + let dir = tempfile::tempdir().unwrap(); + let mut st = figmog::open_store!(dir.path().join("db")); + pull(&mut st, &common::fixture_v1()); + + // Hand-insert an imported variable, same as `sweep_never_touches_variables`. + st.wtx(|tx| { + tx.upsert( + &Id::Variable("VariableID:100".into()), + &Rec::Variable(VariableRec { + id: "VariableID:100".into(), + name: "color/bg".into(), + resolved_type: "COLOR".into(), + collection_id: "VC:1".into(), + values_by_mode: vec![("M:1".into(), "{\"r\":0.06}".into())], + description: String::new(), + scopes: vec![], + }), + ); + }); + + let tool = "get_code"; + let args = "{\"nodeId\":\"1:2\"}"; + let content = serde_json::json!({"content": [{"type": "text", "text": "
"}]}); + figmog::cache::store(&mut st, tool, args, "100", &content); + + // Cache row present right after the write. + st.rtx(|(_, _, _, _, _, _, _, cache)| { + assert_eq!( + figmog::cache::lookup(&cache, tool, args, "100"), + Some(content.clone()) + ); + }); + + // Identical re-pull: file version unchanged -> cache row must survive, + // and so must the imported variable. + pull(&mut st, &common::fixture_v1()); + st.rtx(|(_, _, _, _, vars, _, _, cache)| { + assert_eq!( + figmog::cache::lookup(&cache, tool, args, "100"), + Some(content.clone()), + "cache row must survive a same-version repull" + ); + assert!(vars.get(&"VariableID:100".to_string()).is_some()); + }); + + // v1 -> v2 pull (version "100" -> "101") with the sweep enabled. + let prior = st.rtx( + |((nodes, ..), components, component_sets, styles, _, _, _, _)| { + figmog::store::collect_sweepable(&nodes, &components, &component_sets, &styles) + }, + ); + pull_with_sweep(&mut st, &common::fixture_v2(), prior, 2_000); + + // The cache row is now stale (its file_version is still "100"). + let stale = + st.rtx(|(_, _, _, _, _, _, _, cache)| figmog::store::stale_cache_ids(&cache, "101")); + assert_eq!( + stale, + vec![Id::ProxyCache(figmog::cache::cache_key(tool, args))] + ); + figmog::store::evict_stale_cache(&mut st, &stale); + + st.rtx(|(_, _, _, _, vars, _, _, cache)| { + assert!( + figmog::cache::lookup(&cache, tool, args, "101").is_none(), + "stale cache row must be gone after eviction" + ); + assert!( + cache.get(&figmog::cache::cache_key(tool, args)).is_none(), + "eviction removes the row outright, not just the version-gated read" + ); + assert!( + vars.get(&"VariableID:100".to_string()).is_some(), + "cache eviction must never touch sweep-exempt variables" + ); + }); +} From 888d6a33c12616939d2a144e186b92ec8abd396f Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 00:27:10 -0700 Subject: [PATCH 33/56] =?UTF-8?q?feat(figmog):=20cached=20proxy=20?= =?UTF-8?q?=E2=80=94=20figmog=20as=20the=20single=20Figma=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- examples/figmog/README.md | 131 ++++++-- examples/figmog/src/cli.rs | 197 +++++++++++- examples/figmog/src/dispatch.rs | 341 ++++++++++++++++++++ examples/figmog/src/lib.rs | 2 + examples/figmog/src/mcp.rs | 18 +- examples/figmog/src/proxy.rs | 331 ++++++++++++++++++++ examples/figmog/src/serve.rs | 537 ++++++++++++-------------------- examples/figmog/tests/serve.rs | 241 +++++++++++++- 8 files changed, 1419 insertions(+), 379 deletions(-) create mode 100644 examples/figmog/src/dispatch.rs create mode 100644 examples/figmog/src/proxy.rs diff --git a/examples/figmog/README.md b/examples/figmog/README.md index 0a0de23..f7c45ac 100644 --- a/examples/figmog/README.md +++ b/examples/figmog/README.md @@ -28,9 +28,11 @@ store location (default `.figmog//db`). | command | reads | behavior | |---|---|---| -| `figmog pull [file] [--from-file ] [--fresh]` | — | sync now; prints a churn summary (`+added ~changed -removed`). `file` is optional after the first pull. `--from-file` ingests a saved `GET /v1/files/:key` response instead of the network (offline ingestion, and what keeps the CLI tests hermetic). `--fresh` wipes the store and rebuilds from scratch. | +| `figmog pull [file] [--from-file ] [--fresh]` | — | sync now; prints a churn summary (`+added ~changed -removed`). `file` is optional after the first pull. `--from-file` ingests a saved `GET /v1/files/:key` response instead of the network (offline ingestion, and what keeps the CLI tests hermetic). `--fresh` wipes the store **and the proxy response cache** and rebuilds from scratch. | | `figmog watch [file] [--interval N]` | — | poll loop: cheap metadata check every `N` seconds (default 10), full pull only on an actual change | -| `figmog serve [file] [--interval N] [--no-watch]` | — | MCP stdio server (see "Use from agents (MCP)" below); `--no-watch` disables the poll loop for a read-only, offline server | +| `figmog serve [file] [--interval N] [--no-watch] [--upstream ] [--no-upstream]` | — | MCP stdio server (see "Use from agents (MCP)" below); `--no-watch` disables the poll loop for a read-only, offline server; `--no-upstream` disables the cached proxy to Figma's native desktop MCP server | +| `figmog tools [--upstream ] [--no-upstream]` | — | list every tool `figmog serve` would expose for this mirror: name, source (`local`/`upstream`), and whether it's cache-capable | +| `figmog call [--args ''] [--upstream ] [--no-upstream]` | — | invoke any tool by name through the same dispatch `figmog serve` uses — local `figmog_*` tools and, when attached, any upstream tool | | `figmog status` | meta + nodes | file name, version, last modified, node count | | `figmog pages` | by_type + nodes | list CANVAS pages (id, name) | | `figmog tree [id] [--depth N]` | children + nodes (+ by_type to find the root) | indented outline: `name [type] id`; root defaults to the DOCUMENT node | @@ -72,12 +74,16 @@ Starter**, well above any sane `--interval`. ## Use from agents (MCP) -`figmog serve` is figmog's other head onto the same store: an MCP stdio -server with the sync loop built in. It's one process — fjall is -single-writer, so a standalone MCP server would fight `figmog watch` for -the store lock — that owns the mirror, polls for changes exactly like -`watch`, and answers 17 `figmog_*` tools from whatever's currently in the -store. There's nothing else to run alongside it. +**figmog is the only Figma MCP an agent needs to connect.** `figmog serve` +is one process — fjall is single-writer, so a standalone MCP server would +fight `figmog watch` for the store lock — that owns the mirror, polls for +changes exactly like `watch`, and (unless `--no-upstream`) also attaches +Figma's native desktop MCP server as a **cached proxy**: `tools/list` +merges figmog's 17 local `figmog_*` tools with every tool the desktop +server advertises, verbatim, so an agent gets one server, one connection, +and the full native tool surface (`get_design_context`, `get_screenshot`, +`get_variable_defs`, code-generation tools, …) without figmog reimplementing +any of it. ```console $ cargo build -p figmog @@ -93,6 +99,62 @@ $ claude mcp add figmog -- /absolute/path/to/clog/target/debug/figmog serve --db `--interval N` (default 10s) controls the poll cadence, same as `watch`. +### The cached proxy + +Proxying targets **paid Dev/Full seats**: it requires the Figma desktop +app to be running with its Dev Mode MCP server enabled (streamable HTTP, +default `http://127.0.0.1:3845/mcp`). At startup figmog probes it; on +success, every non-`figmog_*` tool call is forwarded there. On failure +(desktop app not running, no Dev/Full seat, wrong URL), figmog logs one +stderr line and falls back to local-only tools for the rest of the +process — no mid-session re-probe, so restart `figmog serve` once the +desktop server is reachable to attach it. + +- `--upstream ` overrides the desktop server's URL. +- `--no-upstream` disables proxying entirely — figmog serves its 17 + `figmog_*` tools only, exactly like v2. +- **Namespace rule:** `figmog_*` tools are always local; every other tool + name is always proxied. If the desktop server ever advertised a tool + named `figmog_*`, figmog would drop it and log a warning rather than + let it collide — this can't happen with figmog's own registry, but a + live desktop server's tool list is outside figmog's control. +- **Cacheable rule:** a proxied call is served from (and written to) a + version-keyed response cache when its tool name starts `get_`/`list_` + **and** its arguments carry an explicit node id (`nodeId`, `node_id`, + or `id`, as a string) — e.g. `get_code` with a `nodeId` hits the cache + on a repeat call for the same node, as long as the mirror's file + version hasn't changed since. Selection-based calls (no explicit node + id) are always forwarded live. A version bump (from a pull, whether the + poll loop's or `figmog_sync`'s) evicts every cache row tagged with the + old version. +- **Only two things spend Figma's API/rate budget:** `figmog_sync` (a + forced pull) and any proxied, native-named tool call that reaches the + desktop server (a cache hit doesn't). Every `figmog_*` read tool is + free, whether or not the proxy is attached. +- A successful proxied call to a tool that isn't `get_*`/`list_*` (e.g. a + code-connect write) may have changed the file, so figmog schedules an + immediate meta-poll rather than waiting for the next `--interval` tick + (skipped in `--no-watch` mode, which has no poll loop to schedule). +- `pull --fresh` wipes the store **and** the proxy response cache — a + totally clean rebuild. + +### CLI parity + +Every tool figmog serves — local or proxied — is also reachable from the +CLI, so you can inspect or drive the exact same dispatch without an MCP +client: + +```console +$ figmog tools # merged list: name, source, cacheable +$ figmog call figmog_search --args '{"query": "pricing card"}' +$ figmog call get_code --args '{"nodeId": "1:2"}' # proxied, cached by version +``` + +Both accept `--upstream ` / `--no-upstream`, probed fresh per +invocation (no persistent connection between CLI calls). There are +deliberately no bespoke subcommands for upstream tools — Figma's tool +list churns; `figmog call` is the stable, generic surface. + ### Core read tools Each mirrors a CLI read command one-to-one and answers instantly from the @@ -130,22 +192,26 @@ one-to-one. ### Relationship to Figma's official MCP server -figmog is a second, separate MCP server — connect it alongside Figma's -official one, not instead of it. Every figmog tool lives in the -`figmog_*` namespace, so the two servers' tools never collide by name. -figmog's `initialize` response carries steering `instructions` telling an -agent when to reach for which: - -> figmog is a local, instant, rate-limit-free mirror of one Figma file. -> Use figmog tools for ALL structure, search, components, styles, and -> variables. Use the official Figma MCP only for code generation or -> screenshots — never for reads figmog can answer. - -The two servers have zero capability overlap: figmog only ever reads its -local mirror and only ever writes to it via `figmog_sync`, which is the -one tool among the 17 that spends Figma's Tier-1 rate budget (a forced -pull) — every other tool call is instant, free, and backed by the same -fold-materialized indexes the CLI reads. +figmog **replaces** the official desktop MCP server in an agent's config — +connect figmog instead of it, not alongside it. figmog's `initialize` +response carries steering `instructions` telling an agent to reach for +figmog for everything: + +> figmog is your Figma server: a local, instant mirror of one Figma file +> plus a cached proxy to Figma's native capabilities. Call figmog for +> everything Figma-related. figmog_* tools answer from the local mirror +> at zero API cost; native-named tools (get_*, …) go to Figma, cached by +> file version where possible. + +Every figmog-native tool lives in the `figmog_*` namespace, so it never +collides by name with a proxied tool; local tools only ever read the +mirror and only ever write to it via `figmog_sync`, the one local tool +that spends Figma's Tier-1 rate budget (a forced pull) — every other +local tool call is instant, free, and backed by the same +fold-materialized indexes the CLI reads. Proxied tools go through the +cache described above. `--no-upstream` recovers the older, "second, +separate server" shape (v2) if that's ever preferable — figmog's 17 +`figmog_*` tools alongside Figma's own, unrelated MCP connection. ## Variables on a free plan @@ -189,13 +255,16 @@ Figma's own developer console. `figmog vars` prefers an imported // save the logged JSON, then: figmog import-variables vars.json ``` -A third source — Figma's MCP servers, which expose `get_variable_defs` — -exists for paid seats only and is deliberately not built into figmog: the -desktop server needs a Dev/Full seat on a paid plan, the remote server -caps Starter users at 6 tool calls a *month*, and the tool is -selection-scoped rather than whole-collection. Anyone with a paid seat can -pipe its output into `import-variables` by hand; figmog itself never -depends on MCP. +A third source, for paid Dev/Full seats: `figmog serve`'s cached proxy +(see "Use from agents (MCP)" above) forwards `get_variable_defs` to +Figma's desktop server like any other native-named tool, selection-scoped +and cached by file version like the rest of the proxy. It's still +selection-scoped rather than whole-collection, so `import-variables` +remains the way to get an authoritative, whole-collection record into the +mirror; anyone with a paid seat can pipe a proxied `get_variable_defs` +call's output into it by hand. Figma's *remote* MCP server (as opposed to +the local desktop one figmog proxies) caps Starter users at 6 tool calls +a *month* and isn't something figmog talks to at all. ## Manual live check diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index c677a8f..0845d28 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -12,14 +12,17 @@ use fold::pipeline::terminal::{InvertedIndexReader, MultimapReader, TableReader} use fold::stream::Readable; use crate::api::{ApiError, FigmaApi, UreqApi}; +use crate::dispatch; use crate::flatten::flatten_file; use crate::ident::parse_file_ref; use crate::model::{ ComponentRec, ComponentSetRec, FileMeta, Id, NodeRec, StyleRec, VariableCollectionRec, VariableRec, }; +use crate::proxy; use crate::query::{self, TextReader}; use crate::store::{Churn, collect_sweepable, sync}; +use crate::upstream::{HttpUpstream, UpstreamMcp}; use crate::watch::{BACKOFF_CAP, BACKOFF_START, Tick, Watcher}; #[derive(Parser)] @@ -56,7 +59,9 @@ enum Cmd { interval: u64, }, /// MCP stdio server: `figmog_*` tools over the local mirror, with the - /// sync loop built in (one process owns the store). + /// sync loop built in (one process owns the store), plus (unless + /// `--no-upstream`) a cached proxy to Figma's native desktop MCP + /// server — figmog is the only Figma MCP an agent needs to connect. Serve { /// File key or figma.com URL. Optional after the first pull, or /// with `--no-watch` and `--db` for a read-only, offline server. @@ -67,6 +72,36 @@ enum Cmd { /// Disable the poll loop (offline/fixture use). #[arg(long)] no_watch: bool, + /// Figma desktop app's Dev Mode MCP server URL. + #[arg(long, default_value = crate::serve::DEFAULT_UPSTREAM_URL)] + upstream: String, + /// Serve local `figmog_*` tools only — no upstream proxy. + #[arg(long)] + no_upstream: bool, + }, + /// List every tool figmog would serve: the local registry, plus + /// upstream tools when reachable. + Tools { + /// Figma desktop app's Dev Mode MCP server URL. + #[arg(long, default_value = crate::serve::DEFAULT_UPSTREAM_URL)] + upstream: String, + /// List local `figmog_*` tools only — no upstream probe. + #[arg(long)] + no_upstream: bool, + }, + /// Invoke any tool by name through the same dispatch `figmog serve` + /// uses — local `figmog_*` tools included. + Call { + tool: String, + /// JSON object of arguments (default `{}`). + #[arg(long)] + args: Option, + /// Figma desktop app's Dev Mode MCP server URL. + #[arg(long, default_value = crate::serve::DEFAULT_UPSTREAM_URL)] + upstream: String, + /// Don't probe upstream — fail on a non-`figmog_*` tool name. + #[arg(long)] + no_upstream: bool, }, /// File name, version, last modified, node count. Status, @@ -175,7 +210,19 @@ fn dispatch(cli: Cli) -> Result<(), String> { file, interval, no_watch, - } => crate::serve::run_serve(&db, file, interval, no_watch), + upstream, + no_upstream, + } => crate::serve::run_serve(&db, file, interval, no_watch, upstream, no_upstream), + Cmd::Tools { + upstream, + no_upstream, + } => cmd_tools(upstream, no_upstream, cli.json), + Cmd::Call { + tool, + args, + upstream, + no_upstream, + } => cmd_call(&db, tool, args, upstream, no_upstream, cli.json), other => { // `open_store!`'s pipeline type contains fn items and can't be // named, so the store-reading dispatch below must live at this @@ -269,7 +316,9 @@ fn dispatch(cli: Cli) -> Result<(), String> { Cmd::Pull { .. } | Cmd::Watch { .. } | Cmd::ImportVariables { .. } - | Cmd::Serve { .. } => { + | Cmd::Serve { .. } + | Cmd::Tools { .. } + | Cmd::Call { .. } => { unreachable!("handled above") } } @@ -597,6 +646,148 @@ pub(crate) fn read_watermark(db: &Db) -> Option { st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)) } +// ---- cached-proxy CLI parity: `figmog tools` / `figmog call` ---- + +/// Probe `upstream_url` unless `no_upstream`, matching `figmog serve`'s own +/// startup behavior exactly: on failure, one stderr line and local-only +/// (never a hard error — see build design §12 "Startup"). +fn attach_upstream( + upstream_url: String, + no_upstream: bool, +) -> (Option, &'static str) { + if no_upstream { + return (None, "disabled"); + } + let mut client = HttpUpstream::new(upstream_url); + match client.initialize() { + Ok(()) => (Some(client), "connected"), + Err(e) => { + eprintln!("figmog: upstream unreachable, serving local tools only: {e}"); + (None, "unreachable") + } + } +} + +/// `figmog tools`: the merged registry `figmog serve` would expose for this +/// mirror — local tools always, upstream tools when reachable. +fn cmd_tools(upstream_url: String, no_upstream: bool, json: bool) -> Result<(), String> { + let (upstream, status) = attach_upstream(upstream_url, no_upstream); + let (tools, dropped) = match &upstream { + Some(u) => proxy::merge_registry(dispatch::tool_registry(), u.tools()), + None => (dispatch::tool_registry(), Vec::new()), + }; + for name in &dropped { + eprintln!("figmog: dropping upstream tool named like a local tool: {name}"); + } + + if json { + let rows: Vec = tools + .iter() + .map(|t| { + json!({ + "name": t.name, + "source": if proxy::is_local_tool(t.name) { "local" } else { "upstream" }, + "cacheable": proxy::tool_name_cache_capable(t.name), + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string(&rows).map_err(|e| e.to_string())? + ); + } else { + for t in &tools { + let source = if proxy::is_local_tool(t.name) { + "local" + } else { + "upstream" + }; + println!( + "{} [{source}] cacheable={}", + t.name, + proxy::tool_name_cache_capable(t.name) + ); + } + if status != "connected" { + eprintln!("figmog: upstream {status} — showing local tools only"); + } + } + Ok(()) +} + +/// `figmog call [--args json]`: invoke any tool by name through the +/// same routing `figmog serve` uses — local `figmog_*` tools (including +/// `figmog_sync`) and, when attached, the upstream proxy with the same +/// cacheable-rule lookup/store. +fn cmd_call( + db: &Db, + tool: String, + args: Option, + upstream_url: String, + no_upstream: bool, + json: bool, +) -> Result<(), String> { + let args: Value = match args { + Some(raw) => { + serde_json::from_str(&raw).map_err(|e| format!("--args: invalid JSON: {e}"))? + } + None => json!({}), + }; + + let (mut upstream, upstream_status) = attach_upstream(upstream_url, no_upstream); + let mut st = crate::open_store!(&db.path); + + let result: Result = if tool == "figmog_sync" { + do_pull(db, None, None, false) + .map(|(churn, _name, _version)| serde_json::to_value(&churn).unwrap_or_default()) + .map_err(|e| e.to_string()) + } else if proxy::is_local_tool(&tool) { + match st.rtx(|r| dispatch::dispatch_read_tool(&tool, &args, upstream_status, r)) { + Some(r) => r, + None => Err(format!("unknown tool: {tool}")), + } + } else { + let up = upstream + .as_mut() + .ok_or_else(|| format!("upstream not attached: {tool}"))?; + let args_canonical = proxy::canonical_args(&args); + let version_and_hit = if proxy::is_cacheable(&tool, &args) { + st.rtx(|(_, _, _, _, _, _, meta, cache)| { + let version = meta.get(&0).map(|m| m.version.clone()); + let hit = version + .as_ref() + .and_then(|v| crate::cache::lookup(&cache, &tool, &args_canonical, v)); + (version, hit) + }) + } else { + (None, None) + }; + proxy::proxy_call(&mut st, up, &tool, &args, version_and_hit).map(|(value, trigger_poll)| { + if trigger_poll { + eprintln!( + "figmog: {tool} may have changed the file — run `figmog pull` (or `figmog serve`, which polls automatically) to refresh the mirror" + ); + } + value + }) + }; + + match result { + Ok(v) => { + println!( + "{}", + serde_json::to_string_pretty(&v).map_err(|e| e.to_string())? + ); + Ok(()) + } + Err(e) if json => { + println!("{}", json!({"error": e})); + Ok(()) + } + Err(e) => Err(e), + } +} + // ---- core reads ---- fn cmd_status( diff --git a/examples/figmog/src/dispatch.rs b/examples/figmog/src/dispatch.rs new file mode 100644 index 0000000..781547b --- /dev/null +++ b/examples/figmog/src/dispatch.rs @@ -0,0 +1,341 @@ +//! The 16 read-only `figmog_*` tools: one dispatch function, generic over +//! the store's reader types, shared by `figmog serve`'s request loop and +//! the CLI's `figmog call`/`figmog tools`. (`figmog_sync` is not here — it +//! writes to the store and drives watch/backoff state that only exists at +//! each call site's `open_store!` — see the identical note in `serve.rs` +//! and `cli::dispatch` about the pipeline's unnameable type.) +//! +//! Both call sites destructure the same `st.rtx(|tuple| ...)` reader tuple +//! ([`RootReaders`]) that `open_store!`'s pipeline produces, so +//! [`dispatch_read_tool`] can take it directly and stay the single place +//! that maps a tool name + arguments to a `query::*` call. + +use fold::pipeline::terminal::search::Bm25Reader; +use fold::pipeline::terminal::{InvertedIndexReader, MultimapReader, TableReader}; +use fold::stream::Readable; +use serde_json::{Value, json}; + +use crate::mcp::ToolDef; +use crate::model::{ + ComponentRec, ComponentSetRec, FileMeta, NodeRec, ProxyCacheRec, StyleRec, + VariableCollectionRec, VariableRec, +}; +use crate::query; + +/// Read handles for the pipeline's `nodes` branch (see `figmog_pipeline!` +/// in `store.rs`): table, children edges, BM25 text index, then the three +/// inverted indexes plus `by_type`, in the branch's own nesting order. +pub(crate) type NodeReaders<'a, R> = ( + TableReader<'a, R, String, NodeRec>, + MultimapReader<'a, R, String, (u32, String)>, + Bm25Reader<'a, R, String, fn(&str, &mut Vec)>, + InvertedIndexReader<'a, R, String, String>, + InvertedIndexReader<'a, R, String, String>, + InvertedIndexReader<'a, R, String, String>, + InvertedIndexReader<'a, R, String, String>, +); + +/// Read handles for the whole pipeline, in `figmog_pipeline!`'s top-level +/// order — the exact tuple `st.rtx`'s closure receives. 8 elements: the +/// `nodes` branch bundle, then `components`, `component_sets`, `styles`, +/// `variables`, `variable_collections`, `meta`, `proxy_cache`. +pub(crate) type RootReaders<'a, R> = ( + NodeReaders<'a, R>, + TableReader<'a, R, String, ComponentRec>, + TableReader<'a, R, String, ComponentSetRec>, + TableReader<'a, R, String, StyleRec>, + TableReader<'a, R, String, VariableRec>, + TableReader<'a, R, String, VariableCollectionRec>, + TableReader<'a, R, u8, FileMeta>, + TableReader<'a, R, String, ProxyCacheRec>, +); + +// ---- arg extraction ---- + +pub(crate) fn arg_str(args: &Value, key: &str) -> Option { + args.get(key).and_then(Value::as_str).map(str::to_string) +} + +pub(crate) fn require_str(args: &Value, key: &str) -> Result { + arg_str(args, key).ok_or_else(|| format!("missing required field: {key}")) +} + +pub(crate) fn arg_usize(args: &Value, key: &str) -> Option { + args.get(key).and_then(Value::as_u64).map(|n| n as usize) +} + +pub(crate) fn arg_bool(args: &Value, key: &str) -> bool { + args.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +pub(crate) fn require_f64(args: &Value, key: &str) -> Result { + args.get(key) + .and_then(Value::as_f64) + .ok_or_else(|| format!("missing required field: {key}")) +} + +/// Dispatch one of the 16 read-only `figmog_*` tools against an open +/// snapshot. `upstream_status` is spliced into `figmog_status`'s output +/// (`"connected"` / `"unreachable"` / `"disabled"`) without changing +/// `query::status`'s own signature (spec §12 point 4). +/// +/// Returns `None` for any name this function doesn't recognize — `figmog_*` +/// names it doesn't know (a genuine bug, since the registry gates +/// `tools/call` before this is reached) as well as `figmog_sync` and every +/// non-local name, both of which the caller handles itself. +pub(crate) fn dispatch_read_tool( + name: &str, + args: &Value, + upstream_status: &str, + r: RootReaders<'_, R>, +) -> Option> { + let ( + (nodes, children, text, instances_of, styled_by, bound_to, by_type), + components, + component_sets, + styles, + variables, + variable_collections, + meta, + _cache, + ) = r; + + match name { + "figmog_status" => Some(query::status(&nodes, &meta).map(|mut v| { + if let Some(obj) = v.as_object_mut() { + obj.insert("upstream".to_string(), json!(upstream_status)); + } + v + })), + "figmog_pages" => Some(query::pages(&nodes, &by_type)), + "figmog_tree" => { + let id = arg_str(args, "id"); + let depth = arg_usize(args, "depth"); + Some(query::tree(&nodes, &children, &by_type, id, depth)) + } + "figmog_node" => Some((|| { + let id = require_str(args, "id")?; + let with_children = arg_bool(args, "children"); + query::node(&nodes, &children, id, with_children) + })()), + "figmog_find" => Some((|| { + let node_type = require_str(args, "type")?; + let page = arg_str(args, "page"); + query::find(&nodes, &by_type, node_type, page) + })()), + "figmog_search" => Some((|| { + let q = require_str(args, "query")?; + let limit = arg_usize(args, "limit").unwrap_or(10); + query::search(&text, &nodes, &q, limit) + })()), + "figmog_instances" => Some((|| { + let target = require_str(args, "target")?; + query::instances(&nodes, &components, &component_sets, &instances_of, &target) + })()), + "figmog_components" => Some(query::components(&nodes, &components, &component_sets)), + "figmog_styles" => { + let style_type = arg_str(args, "type"); + let values = arg_bool(args, "values"); + Some(query::styles( + &nodes, &styles, &styled_by, style_type, values, + )) + } + "figmog_uses" => Some((|| { + let id = require_str(args, "id")?; + query::uses(&nodes, &styled_by, &bound_to, &id) + })()), + "figmog_vars" => { + let id = arg_str(args, "id"); + Some(query::vars(&nodes, &variables, &variable_collections, id)) + } + "figmog_stats" => Some(query::stats( + &nodes, + &components, + &component_sets, + &styles, + &variables, + &by_type, + )), + "figmog_path" => Some((|| { + let id = require_str(args, "id")?; + query::path(&nodes, id) + })()), + "figmog_text" => { + let page = arg_str(args, "page"); + Some(query::text(&nodes, &by_type, page)) + } + "figmog_where" => Some((|| { + let pointer = require_str(args, "pointer")?; + let equals = args.get("equals").cloned(); + let page = arg_str(args, "page"); + query::where_(&nodes, &pointer, equals, page) + })()), + "figmog_at" => Some((|| { + let x = require_f64(args, "x")?; + let y = require_f64(args, "y")?; + query::at(&nodes, x, y) + })()), + _ => None, + } +} + +/// The 17 `figmog_*` MCP tools: 12 core reads + 5 whole-file structural +/// queries (build design §11's two tables). Every tool but `figmog_sync` +/// reads the local mirror at zero Figma API cost. +pub(crate) fn tool_registry() -> Vec { + vec![ + ToolDef { + name: "figmog_status", + description: "File name, version, last modified time, and node count — reads the local mirror (no Figma API cost).", + input_schema: json!({"type": "object", "properties": {}}), + }, + ToolDef { + name: "figmog_pages", + description: "List the file's pages (CANVAS nodes), in document order — reads the local mirror (no Figma API cost).", + input_schema: json!({"type": "object", "properties": {}}), + }, + ToolDef { + name: "figmog_tree", + description: "Subtree outline (id, name, type, children) rooted at a node, defaulting to the whole document — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "id": {"type": "string", "description": "Root node id; defaults to the DOCUMENT node."}, + "depth": {"type": "integer", "description": "Max depth to descend; omitted means unlimited."} + } + }), + }, + ToolDef { + name: "figmog_node", + description: "Full raw JSON of one node by id, optionally with a one-level children summary — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "id": {"type": "string", "description": "Node id (12:34 or 12-34 form)."}, + "children": {"type": "boolean", "description": "Inline a one-level children summary."} + }, + "required": ["id"] + }), + }, + ToolDef { + name: "figmog_find", + description: "Nodes by Figma node type, optionally scoped to one page — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "type": {"type": "string", "description": "Figma node type, e.g. FRAME."}, + "page": {"type": "string", "description": "Page (CANVAS) node id to scope to."} + }, + "required": ["type"] + }), + }, + ToolDef { + name: "figmog_search", + description: "BM25 search over layer names and text content — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "description": "Max hits (default 10)."} + }, + "required": ["query"] + }), + }, + ToolDef { + name: "figmog_instances", + description: "Instances of a component, resolved by node id, global key, or component/component-set name — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "target": {"type": "string", "description": "Node id, key, or name of a component or component set."} + }, + "required": ["target"] + }), + }, + ToolDef { + name: "figmog_components", + description: "Design-system inventory: component sets with their variant axes, plus standalone components — reads the local mirror (no Figma API cost).", + input_schema: json!({"type": "object", "properties": {}}), + }, + ToolDef { + name: "figmog_styles", + description: "Styles with usage counts; `values` derives each style's definition from a consumer node — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "type": {"type": "string", "description": "Style type filter, e.g. FILL, TEXT."}, + "values": {"type": "boolean", "description": "Derive each style's definition from a consumer node."} + } + }), + }, + ToolDef { + name: "figmog_uses", + description: "Nodes using a style id or bound to a variable id — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": {"id": {"type": "string", "description": "A style id or variable id."}}, + "required": ["id"] + }), + }, + ToolDef { + name: "figmog_vars", + description: "Variables: the authoritative record if imported via figmog import-variables, else inferred from bindings — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": {"id": {"type": "string", "description": "Variable id filter; omitted means all variables."}} + }), + }, + ToolDef { + name: "figmog_sync", + description: "Forces one pull from Figma and returns the sync churn (+added ~changed -removed) — fetches from Figma (spends Tier-1 rate budget).", + input_schema: json!({"type": "object", "properties": {}}), + }, + ToolDef { + name: "figmog_stats", + description: "Node counts by type and by page, component/set/style/variable totals, text-node count, max tree depth — reads the local mirror (no Figma API cost).", + input_schema: json!({"type": "object", "properties": {}}), + }, + ToolDef { + name: "figmog_path", + description: "Ancestor chain from the document root to a node, as [{id, name, type}] — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"] + }), + }, + ToolDef { + name: "figmog_text", + description: "Every TEXT node's (id, characters, page_id), optionally scoped to one page, sorted by id — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": {"page": {"type": "string"}} + }), + }, + ToolDef { + name: "figmog_where", + description: "Nodes whose raw JSON matches an RFC 6901 pointer, optionally filtered by value — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "pointer": {"type": "string", "description": "RFC 6901 pointer into the node's raw JSON, e.g. /layoutMode."}, + "equals": {"description": "JSON value to match; omitted means \"pointer exists\"."}, + "page": {"type": "string"} + }, + "required": ["pointer"] + }), + }, + ToolDef { + name: "figmog_at", + description: "Nodes whose absolute bounds contain a point, sorted by area ascending (deepest/smallest first) — reads the local mirror (no Figma API cost).", + input_schema: json!({ + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"} + }, + "required": ["x", "y"] + }), + }, + ] +} diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs index fb7b729..c135e55 100644 --- a/examples/figmog/src/lib.rs +++ b/examples/figmog/src/lib.rs @@ -12,10 +12,12 @@ pub mod api; pub mod cache; pub mod cli; +mod dispatch; pub mod flatten; pub mod ident; pub mod mcp; pub mod model; +mod proxy; pub mod query; pub mod serve; pub mod store; diff --git a/examples/figmog/src/mcp.rs b/examples/figmog/src/mcp.rs index 48668d4..82f4244 100644 --- a/examples/figmog/src/mcp.rs +++ b/examples/figmog/src/mcp.rs @@ -7,15 +7,17 @@ use serde_json::{Value, json}; -/// figmog is a local, instant, rate-limit-free mirror of one Figma file. Use -/// figmog tools for ALL structure, search, components, styles, and -/// variables. Use the official Figma MCP only for code generation or -/// screenshots — never for reads figmog can answer. +/// figmog is your Figma server: a local, instant mirror of one Figma file +/// plus a cached proxy to Figma's native capabilities. Call figmog for +/// everything Figma-related. figmog_* tools answer from the local mirror at +/// zero API cost; native-named tools (get_*, …) go to Figma, cached by file +/// version where possible. /// /// This is the exact steering text carried verbatim in the `initialize` -/// result's `instructions` field — see build design §11 "Relationship to -/// Figma's official MCP server", point 2. -const INSTRUCTIONS: &str = "figmog is a local, instant, rate-limit-free mirror of one Figma file. Use figmog tools for ALL structure, search, components, styles, and variables. Use the official Figma MCP only for code generation or screenshots — never for reads figmog can answer."; +/// result's `instructions` field — see build design §12 / §11 point 3 +/// (v3, cached-proxy positioning: figmog is the ONLY Figma MCP an agent +/// connects to, superseding the v2 "second, separate server" text). +const INSTRUCTIONS: &str = "figmog is your Figma server: a local, instant mirror of one Figma file plus a cached proxy to Figma's native capabilities. Call figmog for everything Figma-related. figmog_* tools answer from the local mirror at zero API cost; native-named tools (get_*, …) go to Figma, cached by file version where possible."; /// The default MCP protocol version echoed when a client's `initialize` /// request omits `protocolVersion`. @@ -227,7 +229,7 @@ mod tests { "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "figmog", "version": env!("CARGO_PKG_VERSION")}, - "instructions": "figmog is a local, instant, rate-limit-free mirror of one Figma file. Use figmog tools for ALL structure, search, components, styles, and variables. Use the official Figma MCP only for code generation or screenshots — never for reads figmog can answer.", + "instructions": "figmog is your Figma server: a local, instant mirror of one Figma file plus a cached proxy to Figma's native capabilities. Call figmog for everything Figma-related. figmog_* tools answer from the local mirror at zero API cost; native-named tools (get_*, …) go to Figma, cached by file version where possible.", }, }) ); diff --git a/examples/figmog/src/proxy.rs b/examples/figmog/src/proxy.rs new file mode 100644 index 0000000..c419546 --- /dev/null +++ b/examples/figmog/src/proxy.rs @@ -0,0 +1,331 @@ +//! Cached-proxy routing rules (build design §12): the namespace rule, +//! registry merge, and the cacheable-call rule. Pure and side-effect-free +//! (no store, no upstream I/O) so they're unit-testable in isolation — +//! `serve.rs` and `cli.rs` both drive real store/upstream state through +//! these same decisions. + +use serde_json::{Value, json}; + +use fold::pipeline::{Keyed, Push}; +use fold::stream::KeyedStream; + +use crate::cache; +use crate::mcp::ToolDef; +use crate::model::{Id, Rec}; +use crate::upstream::UpstreamMcp; + +/// The namespace rule (spec §12, §11 point 3): `figmog_*` tools are always +/// local; every other name is proxied to the upstream (when attached). +pub(crate) fn is_local_tool(name: &str) -> bool { + name.starts_with("figmog_") +} + +/// Name-only half of the cacheable rule: whether this tool's *kind* is +/// cacheable in principle. A concrete call is only actually cached when it +/// also carries [`has_explicit_node_id`] — this half alone is what `figmog +/// tools`'s `cacheable` column reports, since a tool listing has no call +/// arguments to inspect. +pub(crate) fn tool_name_cache_capable(name: &str) -> bool { + name.starts_with("get_") || name.starts_with("list_") +} + +/// Whether `args` carries an explicit node id under any of the key names +/// Figma's native tools use for one (`nodeId`, `node_id`, `id`), as a +/// *string* value — selection-based calls (no such key, or a non-string +/// value) are invisible to the cache and always forwarded. +pub(crate) fn has_explicit_node_id(args: &Value) -> bool { + ["nodeId", "node_id", "id"] + .iter() + .any(|key| matches!(args.get(*key), Some(Value::String(_)))) +} + +/// The full cacheable rule (spec §12 "Cache"): `get_*`/`list_*` AND an +/// explicit node id in the call arguments. +pub(crate) fn is_cacheable(name: &str, args: &Value) -> bool { + tool_name_cache_capable(name) && has_explicit_node_id(args) +} + +/// Canonical JSON of a call's arguments, for the cache key and stored row. +/// `serde_json::Value` objects are backed by a `BTreeMap` (this crate never +/// enables the `preserve_order` feature), so `to_string` already yields +/// sorted-key, deterministic output — no extra normalization needed. +pub(crate) fn canonical_args(args: &Value) -> String { + serde_json::to_string(args).unwrap_or_default() +} + +/// `tools/list` = the local `figmog_*` registry followed by every upstream +/// tool verbatim (name/inputSchema passed through, description prefixed +/// `"[via Figma desktop] "`). Returns the merged list plus the names of any +/// upstream tools dropped for colliding with the `figmog_*` namespace (the +/// namespace rule makes this impossible for figmog's own registry, but a +/// live desktop server's tool list is outside figmog's control) — the +/// caller logs the drops. +pub(crate) fn merge_registry( + mut local: Vec, + upstream_tools: &[Value], +) -> (Vec, Vec) { + let mut dropped = Vec::new(); + for tool in upstream_tools { + let Some(name) = tool.get("name").and_then(Value::as_str) else { + continue; + }; + if is_local_tool(name) { + dropped.push(name.to_string()); + continue; + } + let description = tool + .get("description") + .and_then(Value::as_str) + .unwrap_or(""); + let input_schema = tool + .get("inputSchema") + .cloned() + .unwrap_or_else(|| json!({"type": "object"})); + // Upstream tool names/descriptions are only known once, at startup + // (no mid-session re-probe in v3 — spec §12), and figmog serves for + // the life of the process: leaking these few dozen strings to get + // the `&'static str` `ToolDef` needs is bounded and never repeats. + local.push(ToolDef { + name: Box::leak(name.to_string().into_boxed_str()), + description: Box::leak(format!("[via Figma desktop] {description}").into_boxed_str()), + input_schema, + }); + } + (local, dropped) +} + +/// Execute one proxied `tools/call`. `version_and_hit` is `(current +/// `FileMeta.version`, cache hit if any)`, already read via `st.rtx` at the +/// call site — the reader tuple's shape is pinned to one concrete +/// `open_store!` call site (see `dispatch.rs`'s doc comment), so this +/// function, generic only over `P: Push<..>`, can't read the store itself; +/// it only writes to it (`cache::store`, via `wtx`, has no such +/// restriction). +/// +/// Returns the value to hand back to the MCP client, and whether the +/// caller should trigger an immediate meta-poll tick (spec §12 "Writes": a +/// successful call to a tool that isn't `get_*`/`list_*` may have changed +/// the file). +pub(crate) fn proxy_call>>( + st: &mut KeyedStream, + upstream: &mut U, + name: &str, + args: &Value, + version_and_hit: (Option, Option), +) -> Result<(Value, bool), String> { + let (version, hit) = version_and_hit; + if let Some(hit) = hit { + return Ok((hit, false)); + } + + let result = upstream.call(name, args).map_err(|e| e.to_string())?; + + if is_cacheable(name, args) { + if let Some(version) = &version { + let args_canonical = canonical_args(args); + cache::store(st, name, &args_canonical, version, &result); + } + return Ok((result, false)); + } + + let trigger_poll = !tool_name_cache_capable(name); + Ok((result, trigger_poll)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::upstream::{FakeUpstream, UpstreamError}; + + fn local_tool(name: &'static str) -> ToolDef { + ToolDef { + name, + description: "d", + input_schema: json!({"type": "object"}), + } + } + + #[test] + fn is_local_tool_matches_only_figmog_prefix() { + assert!(is_local_tool("figmog_status")); + assert!(!is_local_tool("get_code")); + assert!(!is_local_tool("list_something")); + } + + #[test] + fn cacheable_requires_get_or_list_prefix_and_string_node_id() { + assert!(is_cacheable("get_code", &json!({"nodeId": "1:2"}))); + assert!(is_cacheable("list_variables", &json!({"id": "1:2"}))); + assert!(is_cacheable("get_code", &json!({"node_id": "1:2"}))); + // Not get_/list_. + assert!(!is_cacheable( + "add_code_connect_map", + &json!({"nodeId": "1:2"}) + )); + // No node id at all: selection-based call. + assert!(!is_cacheable("get_code", &json!({}))); + // Node id present but not a string. + assert!(!is_cacheable("get_code", &json!({"nodeId": 12}))); + } + + #[test] + fn canonical_args_is_stable_regardless_of_source_key_order() { + let a: Value = serde_json::from_str(r#"{"nodeId":"1:2","depth":1}"#).unwrap(); + let b: Value = serde_json::from_str(r#"{"depth":1,"nodeId":"1:2"}"#).unwrap(); + assert_eq!(canonical_args(&a), canonical_args(&b)); + } + + #[test] + fn merge_registry_appends_upstream_tools_with_prefixed_description() { + let local = vec![local_tool("figmog_status")]; + let upstream = vec![json!({ + "name": "get_code", + "description": "Returns code for a node", + "inputSchema": {"type": "object", "properties": {"nodeId": {"type": "string"}}}, + })]; + let (merged, dropped) = merge_registry(local, &upstream); + assert!(dropped.is_empty()); + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].name, "figmog_status"); + assert_eq!(merged[1].name, "get_code"); + assert_eq!( + merged[1].description, + "[via Figma desktop] Returns code for a node" + ); + assert_eq!( + merged[1].input_schema, + json!({"type": "object", "properties": {"nodeId": {"type": "string"}}}) + ); + } + + #[test] + fn merge_registry_drops_and_reports_upstream_tool_named_like_local() { + let local = vec![local_tool("figmog_status")]; + let upstream = vec![ + json!({"name": "figmog_evil", "description": "impersonating"}), + json!({"name": "get_code", "description": "fine"}), + ]; + let (merged, dropped) = merge_registry(local, &upstream); + assert_eq!(dropped, vec!["figmog_evil".to_string()]); + assert_eq!(merged.len(), 2); + assert!(merged.iter().any(|t| t.name == "get_code")); + assert!(!merged.iter().any(|t| t.name == "figmog_evil")); + } + + #[test] + fn merge_registry_defaults_missing_description_and_schema() { + let local = vec![local_tool("figmog_status")]; + let upstream = vec![json!({"name": "get_screenshot"})]; + let (merged, _dropped) = merge_registry(local, &upstream); + assert_eq!(merged[1].description, "[via Figma desktop] "); + assert_eq!(merged[1].input_schema, json!({"type": "object"})); + } + + // ---- proxy_call routing ---- + + #[test] + fn proxy_call_cache_hit_never_calls_upstream() { + let dir = tempfile::tempdir().unwrap(); + let mut st = crate::open_store!(dir.path().join("db")); + let mut upstream = FakeUpstream::new(vec![]); + // No result queued: if `call` were invoked, the fake would return a + // "no scripted result queued" error, so a returned `Ok` proves the + // hit short-circuited the upstream entirely. + let (value, poll) = proxy_call( + &mut st, + &mut upstream, + "get_code", + &json!({"nodeId": "1:2"}), + (Some("100".to_string()), Some(json!({"cached": true}))), + ) + .unwrap(); + assert_eq!(value, json!({"cached": true})); + assert!(!poll); + assert_eq!(upstream.call_count, 0); + } + + #[test] + fn proxy_call_miss_calls_upstream_and_stores_cache_row() { + let dir = tempfile::tempdir().unwrap(); + let mut st = crate::open_store!(dir.path().join("db")); + let mut upstream = FakeUpstream::new(vec![]); + upstream.push_result(Ok(json!({"content": [{"type": "text", "text": "fresh"}]}))); + + let (value, poll) = proxy_call( + &mut st, + &mut upstream, + "get_code", + &json!({"nodeId": "1:2"}), + (Some("100".to_string()), None), + ) + .unwrap(); + assert_eq!( + value, + json!({"content": [{"type": "text", "text": "fresh"}]}) + ); + assert!(!poll); + assert_eq!(upstream.call_count, 1); + + let stored = st.rtx(|(_, _, _, _, _, _, _, cache)| { + cache::lookup( + &cache, + "get_code", + &canonical_args(&json!({"nodeId": "1:2"})), + "100", + ) + }); + assert_eq!(stored, Some(value)); + } + + #[test] + fn proxy_call_non_cacheable_write_triggers_meta_poll() { + let dir = tempfile::tempdir().unwrap(); + let mut st = crate::open_store!(dir.path().join("db")); + let mut upstream = FakeUpstream::new(vec![]); + upstream.push_result(Ok(json!({"ok": true}))); + + let (_value, poll) = proxy_call( + &mut st, + &mut upstream, + "add_code_connect_map", + &json!({}), + (None, None), + ) + .unwrap(); + assert!(poll, "a non-get/list write should trigger a meta poll"); + } + + #[test] + fn proxy_call_get_prefixed_without_node_id_forwards_uncached_and_no_poll() { + // Selection-based `get_*` calls are invisible to the cache (spec + // §12), but they're still reads, so they must not trigger a poll. + let dir = tempfile::tempdir().unwrap(); + let mut st = crate::open_store!(dir.path().join("db")); + let mut upstream = FakeUpstream::new(vec![]); + upstream.push_result(Ok(json!({"selection": true}))); + + let (value, poll) = proxy_call( + &mut st, + &mut upstream, + "get_code", + &json!({}), + (Some("100".to_string()), None), + ) + .unwrap(); + assert_eq!(value, json!({"selection": true})); + assert!(!poll); + assert_eq!(upstream.call_count, 1); + } + + #[test] + fn proxy_call_propagates_upstream_error() { + let dir = tempfile::tempdir().unwrap(); + let mut st = crate::open_store!(dir.path().join("db")); + let mut upstream = FakeUpstream::new(vec![]); + upstream.push_result(Err(UpstreamError::Protocol("boom".into()))); + + let err = + proxy_call(&mut st, &mut upstream, "get_code", &json!({}), (None, None)).unwrap_err(); + assert!(err.contains("boom")); + } +} diff --git a/examples/figmog/src/serve.rs b/examples/figmog/src/serve.rs index cce3f41..9afcd35 100644 --- a/examples/figmog/src/serve.rs +++ b/examples/figmog/src/serve.rs @@ -9,35 +9,60 @@ //! (`flatten_file` → `collect_sweepable` → `store::sync`) and the same //! failure-backoff discipline as `figmog watch` (see `cli::pull_failure_wait`). //! +//! **v3 (build design §12):** unless `--no-upstream`, figmog also probes +//! Figma's native desktop MCP server at startup and becomes the *only* +//! Figma MCP an agent needs — `tools/list` merges the 17 local `figmog_*` +//! tools with every upstream tool verbatim (`proxy::merge_registry`), and +//! `tools/call` routes by the namespace rule (`proxy::is_local_tool`): +//! local names answer from the store exactly as in v2; everything else is +//! proxied, with `get_*`/`list_*` calls against an explicit node id served +//! from (and written to) the version-keyed `proxy_cache` table +//! (`proxy::proxy_call`). No mid-session re-probe: an unreachable upstream +//! at startup means local-only tools for the life of the process. +//! //! Every `rtx`/`wtx` call against the store has to live at this concrete, //! non-generic call site: `open_store!`'s pipeline type contains fn items //! and can't be named, so it can't be threaded through a helper `fn` //! generic over `P: Push<..>` (see the identical note in `cli::dispatch`). //! The [`mcp::ToolHandler`] the loop hands to [`mcp::handle_message`] is //! therefore a closure — wrapped in [`mcp::FnHandler`] — defined right -//! here, capturing the store by unique reference. +//! here, capturing the store by unique reference. What *can* be shared +//! across call sites — because it only needs individual reader values, or +//! only `wtx`, never a raw `rtx` tuple pattern spelled out generically — +//! lives in `crate::dispatch` (local tool reads) and `crate::proxy` +//! (routing rules and the proxied-call execution), and both `run_serve` +//! and the CLI's `figmog call`/`figmog tools` (`cli.rs`) call into them. use std::collections::BTreeSet; use std::io::{BufRead, Write}; use std::sync::mpsc; use std::time::{Duration, Instant}; -use serde_json::{Value, json}; +use serde_json::Value; use crate::api::{FigmaApi, UreqApi}; use crate::cli::{ Db, PullError, do_pull, now_ms, pull_failure_wait, read_watermark, write_current, }; +use crate::dispatch; use crate::flatten::flatten_file; use crate::ident::parse_file_ref; -use crate::mcp::{self, FnHandler, ToolDef}; +use crate::mcp::{self, FnHandler}; use crate::model::Id; -use crate::query; +use crate::proxy; use crate::store::{self, collect_sweepable}; +use crate::upstream::{HttpUpstream, UpstreamMcp}; use crate::watch::{BACKOFF_START, Tick, Watcher}; +/// Default streamable-HTTP URL of Figma desktop app's Dev Mode MCP server +/// (build design §12). +pub const DEFAULT_UPSTREAM_URL: &str = "http://127.0.0.1:3845/mcp"; + /// Run the MCP stdio server against `db`, serving `figmog_*` tools and — -/// unless `no_watch` — pulling inline whenever the file changes. +/// unless `no_watch` — pulling inline whenever the file changes. Unless +/// `no_upstream`, also attaches Figma's native desktop MCP server at +/// `upstream_url` as a cached proxy (build design §12); a failed probe +/// degrades to local-only tools with one stderr line, never a hard error. /// /// `file` resolves the mirrored key the same way `pull`/`watch` do (a /// `--db` override alone is enough for a read-only, offline server; a key @@ -48,6 +73,8 @@ pub(crate) fn run_serve( file: Option, interval: u64, no_watch: bool, + upstream_url: String, + no_upstream: bool, ) -> Result<(), String> { let key: Option = db .key @@ -69,8 +96,39 @@ pub(crate) fn run_serve( Some(UreqApi::new(token)) }; + // Upstream probe: no mid-session re-probe in v3 — an unreachable + // desktop server at startup means local-only tools for the process's + // whole life (build design §12). + let mut upstream: Option = if no_upstream { + None + } else { + let mut client = HttpUpstream::new(upstream_url); + match client.initialize() { + Ok(()) => Some(client), + Err(e) => { + eprintln!("figmog: upstream unreachable, serving local tools only: {e}"); + None + } + } + }; + let upstream_status: &'static str = if no_upstream { + "disabled" + } else if upstream.is_some() { + "connected" + } else { + "unreachable" + }; + + let (tools, dropped) = match &upstream { + Some(u) => proxy::merge_registry(dispatch::tool_registry(), u.tools()), + None => (dispatch::tool_registry(), Vec::new()), + }; + for name in &dropped { + eprintln!("figmog: dropping upstream tool named like a local tool: {name}"); + } + eprintln!( - "{} serving {} (watch {})", + "{} serving {} (watch {}, upstream {upstream_status})", mcp::SERVER_NAME, key.as_deref().unwrap_or(""), if no_watch { "off" } else { "on" } @@ -98,7 +156,6 @@ pub(crate) fn run_serve( st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)); let mut watcher = Watcher::new(stored.clone()); let mut pull_backoff = BACKOFF_START; - let tools = tool_registry(); let mut next_deadline = Instant::now() + interval_dur; loop { @@ -145,6 +202,20 @@ pub(crate) fn run_serve( stored = st.rtx(|(_, _, _, _, _, _, meta, _)| { meta.get(&0).map(|m| m.last_modified) }); + // Sweep any proxy_cache rows the new version made + // stale (spec §12; a no-op if the version didn't + // actually move — see `store.rs`'s eviction note). + let version = st.rtx(|(_, _, _, _, _, _, meta, _)| { + meta.get(&0).map(|m| m.version.clone()) + }); + if let Some(version) = version { + let stale = st.rtx(|(_, _, _, _, _, _, _, cache)| { + store::stale_cache_ids(&cache, &version) + }); + if !stale.is_empty() { + store::evict_stale_cache(&mut st, &stale); + } + } pull_backoff = BACKOFF_START; if let Some(k) = &db.key { let _ = write_current(k); @@ -168,157 +239,87 @@ pub(crate) fn run_serve( }; let mut handler = FnHandler(|name: &str, args: &Value| -> Result { - match name { - "figmog_status" => st.rtx(|((nodes, _, _, _, _, _, _), _, _, _, _, _, meta, _)| { - query::status(&nodes, &meta) - }), - "figmog_pages" => { - st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| query::pages(&nodes, &by_type)) - } - "figmog_tree" => { - let id = arg_str(args, "id"); - let depth = arg_usize(args, "depth"); - st.rtx(|((nodes, children, _, _, _, _, by_type), ..)| { - query::tree(&nodes, &children, &by_type, id, depth) - }) - } - "figmog_node" => { - let id = require_str(args, "id")?; - let with_children = arg_bool(args, "children"); - st.rtx(|((nodes, children, ..), ..)| { - query::node(&nodes, &children, id, with_children) - }) - } - "figmog_find" => { - let node_type = require_str(args, "type")?; - let page = arg_str(args, "page"); - st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| { - query::find(&nodes, &by_type, node_type, page) - }) - } - "figmog_search" => { - let q = require_str(args, "query")?; - let limit = arg_usize(args, "limit").unwrap_or(10); - st.rtx(|((nodes, _, text, ..), ..)| query::search(&text, &nodes, &q, limit)) - } - "figmog_instances" => { - let target = require_str(args, "target")?; - st.rtx( - |((nodes, _, _, instances_of, ..), components, component_sets, ..)| { - query::instances( - &nodes, - &components, - &component_sets, - &instances_of, - &target, - ) - }, - ) - } - "figmog_components" => st.rtx(|((nodes, ..), components, component_sets, ..)| { - query::components(&nodes, &components, &component_sets) - }), - "figmog_styles" => { - let style_type = arg_str(args, "type"); - let values = arg_bool(args, "values"); - st.rtx(|((nodes, _, _, _, styled_by, ..), _, _, styles, ..)| { - query::styles(&nodes, &styles, &styled_by, style_type, values) - }) - } - "figmog_uses" => { - let id = require_str(args, "id")?; - st.rtx(|((nodes, _, _, _, styled_by, bound_to, _), ..)| { - query::uses(&nodes, &styled_by, &bound_to, &id) - }) - } - "figmog_vars" => { - let id = arg_str(args, "id"); - st.rtx( - |((nodes, ..), _, _, _, variables, variable_collections, _, _)| { - query::vars(&nodes, &variables, &variable_collections, id) - }, - ) - } - "figmog_sync" => { - let sync_key = key.clone().ok_or_else(|| { - "no file key: pass a file key or figma.com URL".to_string() - })?; - let token = std::env::var("FIGMA_TOKEN").map_err(|_| { - "FIGMA_TOKEN not set — required for figmog_sync".to_string() - })?; - let sync_api = UreqApi::new(token); - let pull_result: Result = (|| { - let resp = sync_api.file(&sync_key)?; - let flattened = flatten_file(&resp).map_err(|e| e.to_string())?; - let prior: BTreeSet = - st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { - collect_sweepable(&nodes, &components, &component_sets, &styles) - }); - Ok(store::sync(&mut st, &prior, &flattened, now_ms())) - })(); - // A failed manual sync still spends the same backoff - // budget as a failed background tick, and — when watch - // is enabled — the next tick must not fire back into a - // rate-limit window this call just learned about. - let churn = match pull_result { - Ok(c) => c, - Err(e) => { - let wait = pull_failure_wait(&e, &mut pull_backoff, interval_dur); - next_deadline = Instant::now() + wait; - return Err(e.to_string()); - } - }; - stored = - st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)); - pull_backoff = BACKOFF_START; - watcher = Watcher::new(stored.clone()); - if let Some(k) = &db.key { - let _ = write_current(k); + if name == "figmog_sync" { + let sync_key = key + .clone() + .ok_or_else(|| "no file key: pass a file key or figma.com URL".to_string())?; + let token = std::env::var("FIGMA_TOKEN") + .map_err(|_| "FIGMA_TOKEN not set — required for figmog_sync".to_string())?; + let sync_api = UreqApi::new(token); + let pull_result: Result = (|| { + let resp = sync_api.file(&sync_key)?; + let flattened = flatten_file(&resp).map_err(|e| e.to_string())?; + let prior: BTreeSet = + st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + Ok(store::sync(&mut st, &prior, &flattened, now_ms())) + })(); + // A failed manual sync still spends the same backoff + // budget as a failed background tick, and — when watch + // is enabled — the next tick must not fire back into a + // rate-limit window this call just learned about. + let churn = match pull_result { + Ok(c) => c, + Err(e) => { + let wait = pull_failure_wait(&e, &mut pull_backoff, interval_dur); + next_deadline = Instant::now() + wait; + return Err(e.to_string()); + } + }; + stored = + st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)); + // Sweep any proxy_cache rows the new version made stale + // (spec §12; a no-op if the version didn't actually move). + let version = + st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.version.clone())); + if let Some(version) = version { + let stale = st.rtx(|(_, _, _, _, _, _, _, cache)| { + store::stale_cache_ids(&cache, &version) + }); + if !stale.is_empty() { + store::evict_stale_cache(&mut st, &stale); } - serde_json::to_value(&churn).map_err(|e| e.to_string()) - } - "figmog_stats" => st.rtx( - |( - (nodes, _, _, _, _, _, by_type), - components, - component_sets, - styles, - variables, - .., - )| { - query::stats( - &nodes, - &components, - &component_sets, - &styles, - &variables, - &by_type, - ) - }, - ), - "figmog_path" => { - let id = require_str(args, "id")?; - st.rtx(|((nodes, ..), ..)| query::path(&nodes, id)) - } - "figmog_text" => { - let page = arg_str(args, "page"); - st.rtx(|((nodes, _, _, _, _, _, by_type), ..)| { - query::text(&nodes, &by_type, page) - }) - } - "figmog_where" => { - let pointer = require_str(args, "pointer")?; - let equals = args.get("equals").cloned(); - let page = arg_str(args, "page"); - st.rtx(|((nodes, ..), ..)| query::where_(&nodes, &pointer, equals, page)) } - "figmog_at" => { - let x = require_f64(args, "x")?; - let y = require_f64(args, "y")?; - st.rtx(|((nodes, ..), ..)| query::at(&nodes, x, y)) + pull_backoff = BACKOFF_START; + watcher = Watcher::new(stored.clone()); + if let Some(k) = &db.key { + let _ = write_current(k); } - other => Err(format!("unknown tool: {other}")), + return serde_json::to_value(&churn).map_err(|e| e.to_string()); + } + + if let Some(result) = + st.rtx(|r| dispatch::dispatch_read_tool(name, args, upstream_status, r)) + { + return result; + } + + if proxy::is_local_tool(name) { + return Err(format!("unknown tool: {name}")); } + + let up = upstream + .as_mut() + .ok_or_else(|| format!("upstream not attached: {name}"))?; + let args_canonical = proxy::canonical_args(args); + let version_and_hit = if proxy::is_cacheable(name, args) { + st.rtx(|(_, _, _, _, _, _, meta, cache)| { + let version = meta.get(&0).map(|m| m.version.clone()); + let hit = version + .as_ref() + .and_then(|v| crate::cache::lookup(&cache, name, &args_canonical, v)); + (version, hit) + }) + } else { + (None, None) + }; + let (value, trigger_poll) = + proxy::proxy_call(&mut st, up, name, args, version_and_hit)?; + if trigger_poll && !no_watch { + next_deadline = Instant::now(); + } + Ok(value) }); if let Some(resp) = mcp::handle_message(&line, &tools, &mut handler) { @@ -328,189 +329,57 @@ pub(crate) fn run_serve( } } -// ---- arg extraction ---- - -fn arg_str(args: &Value, key: &str) -> Option { - args.get(key).and_then(Value::as_str).map(str::to_string) -} +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::ToolDef; + use crate::upstream::FakeUpstream; + use serde_json::json; -fn require_str(args: &Value, key: &str) -> Result { - arg_str(args, key).ok_or_else(|| format!("missing required field: {key}")) -} - -fn arg_usize(args: &Value, key: &str) -> Option { - args.get(key).and_then(Value::as_u64).map(|n| n as usize) -} + fn local_registry() -> Vec { + dispatch::tool_registry() + } -fn arg_bool(args: &Value, key: &str) -> bool { - args.get(key).and_then(Value::as_bool).unwrap_or(false) -} + #[test] + fn merged_registry_places_local_tools_first_then_upstream_verbatim() { + let upstream = FakeUpstream::new(vec![json!({ + "name": "get_design_context", + "description": "Design context for a node", + "inputSchema": {"type": "object"}, + })]); + let (tools, dropped) = proxy::merge_registry(local_registry(), upstream.tools()); + assert!(dropped.is_empty()); + assert_eq!(tools.len(), 18); + assert!(tools[..17].iter().all(|t| t.name.starts_with("figmog_"))); + assert_eq!(tools[17].name, "get_design_context"); + assert!(tools[17].description.starts_with("[via Figma desktop] ")); + } -fn require_f64(args: &Value, key: &str) -> Result { - args.get(key) - .and_then(Value::as_f64) - .ok_or_else(|| format!("missing required field: {key}")) -} + #[test] + fn routing_local_name_never_reaches_upstream() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("db"); + let mut st = crate::open_store!(&db); + let flattened = crate::flatten::flatten_file(&json!({ + "name": "F", "version": "1", "lastModified": "t", + "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": []}, + "components": {}, "componentSets": {}, "styles": {}, + })) + .unwrap(); + store::sync(&mut st, &BTreeSet::new(), &flattened, 0); -// ---- tool registry ---- + let result = + st.rtx(|r| dispatch::dispatch_read_tool("figmog_status", &json!({}), "connected", r)); + let value = result + .expect("figmog_status is a recognized local tool") + .unwrap(); + assert_eq!(value["upstream"], json!("connected")); -/// The 17 `figmog_*` MCP tools: 12 core reads + 5 whole-file structural -/// queries (build design §11's two tables). Every tool but `figmog_sync` -/// reads the local mirror at zero Figma API cost. -fn tool_registry() -> Vec { - vec![ - ToolDef { - name: "figmog_status", - description: "File name, version, last modified time, and node count — reads the local mirror (no Figma API cost).", - input_schema: json!({"type": "object", "properties": {}}), - }, - ToolDef { - name: "figmog_pages", - description: "List the file's pages (CANVAS nodes), in document order — reads the local mirror (no Figma API cost).", - input_schema: json!({"type": "object", "properties": {}}), - }, - ToolDef { - name: "figmog_tree", - description: "Subtree outline (id, name, type, children) rooted at a node, defaulting to the whole document — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": { - "id": {"type": "string", "description": "Root node id; defaults to the DOCUMENT node."}, - "depth": {"type": "integer", "description": "Max depth to descend; omitted means unlimited."} - } - }), - }, - ToolDef { - name: "figmog_node", - description: "Full raw JSON of one node by id, optionally with a one-level children summary — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": { - "id": {"type": "string", "description": "Node id (12:34 or 12-34 form)."}, - "children": {"type": "boolean", "description": "Inline a one-level children summary."} - }, - "required": ["id"] - }), - }, - ToolDef { - name: "figmog_find", - description: "Nodes by Figma node type, optionally scoped to one page — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": { - "type": {"type": "string", "description": "Figma node type, e.g. FRAME."}, - "page": {"type": "string", "description": "Page (CANVAS) node id to scope to."} - }, - "required": ["type"] - }), - }, - ToolDef { - name: "figmog_search", - description: "BM25 search over layer names and text content — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": { - "query": {"type": "string"}, - "limit": {"type": "integer", "description": "Max hits (default 10)."} - }, - "required": ["query"] - }), - }, - ToolDef { - name: "figmog_instances", - description: "Instances of a component, resolved by node id, global key, or component/component-set name — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": { - "target": {"type": "string", "description": "Node id, key, or name of a component or component set."} - }, - "required": ["target"] - }), - }, - ToolDef { - name: "figmog_components", - description: "Design-system inventory: component sets with their variant axes, plus standalone components — reads the local mirror (no Figma API cost).", - input_schema: json!({"type": "object", "properties": {}}), - }, - ToolDef { - name: "figmog_styles", - description: "Styles with usage counts; `values` derives each style's definition from a consumer node — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": { - "type": {"type": "string", "description": "Style type filter, e.g. FILL, TEXT."}, - "values": {"type": "boolean", "description": "Derive each style's definition from a consumer node."} - } - }), - }, - ToolDef { - name: "figmog_uses", - description: "Nodes using a style id or bound to a variable id — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": {"id": {"type": "string", "description": "A style id or variable id."}}, - "required": ["id"] - }), - }, - ToolDef { - name: "figmog_vars", - description: "Variables: the authoritative record if imported via figmog import-variables, else inferred from bindings — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": {"id": {"type": "string", "description": "Variable id filter; omitted means all variables."}} - }), - }, - ToolDef { - name: "figmog_sync", - description: "Forces one pull from Figma and returns the sync churn (+added ~changed -removed) — fetches from Figma (spends Tier-1 rate budget).", - input_schema: json!({"type": "object", "properties": {}}), - }, - ToolDef { - name: "figmog_stats", - description: "Node counts by type and by page, component/set/style/variable totals, text-node count, max tree depth — reads the local mirror (no Figma API cost).", - input_schema: json!({"type": "object", "properties": {}}), - }, - ToolDef { - name: "figmog_path", - description: "Ancestor chain from the document root to a node, as [{id, name, type}] — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": {"id": {"type": "string"}}, - "required": ["id"] - }), - }, - ToolDef { - name: "figmog_text", - description: "Every TEXT node's (id, characters, page_id), optionally scoped to one page, sorted by id — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": {"page": {"type": "string"}} - }), - }, - ToolDef { - name: "figmog_where", - description: "Nodes whose raw JSON matches an RFC 6901 pointer, optionally filtered by value — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": { - "pointer": {"type": "string", "description": "RFC 6901 pointer into the node's raw JSON, e.g. /layoutMode."}, - "equals": {"description": "JSON value to match; omitted means \"pointer exists\"."}, - "page": {"type": "string"} - }, - "required": ["pointer"] - }), - }, - ToolDef { - name: "figmog_at", - description: "Nodes whose absolute bounds contain a point, sorted by area ascending (deepest/smallest first) — reads the local mirror (no Figma API cost).", - input_schema: json!({ - "type": "object", - "properties": { - "x": {"type": "number"}, - "y": {"type": "number"} - }, - "required": ["x", "y"] - }), - }, - ] + // A non-figmog_ name is simply not recognized by the local + // dispatcher — proving the namespace rule routes it away from the + // local path without needing a live upstream to demonstrate it. + let result = + st.rtx(|r| dispatch::dispatch_read_tool("get_code", &json!({}), "connected", r)); + assert!(result.is_none()); + } } diff --git a/examples/figmog/tests/serve.rs b/examples/figmog/tests/serve.rs index 0f7932a..01edf0f 100644 --- a/examples/figmog/tests/serve.rs +++ b/examples/figmog/tests/serve.rs @@ -10,7 +10,8 @@ mod common; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::mpsc::{self, Receiver}; use std::time::{Duration, Instant}; @@ -40,10 +41,20 @@ impl Drop for ChildGuard { /// than reading its stdout inline) means a hung child blocks only the /// bounded `recv_timeout` in [`recv`], never the test thread itself. fn spawn_serve(db: &std::path::Path) -> (ChildGuard, ChildStdin, Receiver) { + spawn_serve_with_args(db, &["--no-upstream"]) +} + +/// Like [`spawn_serve`], but with extra CLI args after `--db ` (e.g. +/// `--upstream ` for the proxy e2e test). +fn spawn_serve_with_args( + db: &std::path::Path, + extra_args: &[&str], +) -> (ChildGuard, ChildStdin, Receiver) { let bin = assert_cmd::cargo::cargo_bin("figmog"); let mut child = Command::new(bin) .args(["serve", "--no-watch", "--db"]) .arg(db) + .args(extra_args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -153,9 +164,13 @@ fn serve_e2e_initialize_tools_list_and_tool_calls() { .as_str() .expect("instructions is a string"); assert!(!instructions.is_empty()); + // v3 steering text (build design §12/§11 point 3): figmog is now the + // only Figma MCP an agent connects to — a cached proxy in front of + // Figma's native capabilities — which supersedes the v2 "second, + // separate server" text this assertion used to pin. assert!( - instructions.contains("official Figma MCP"), - "instructions should mention the official Figma MCP: {instructions}" + instructions.contains("cached proxy"), + "instructions should mention the cached proxy: {instructions}" ); // notifications/initialized: no `id`, so no response frame is expected @@ -239,3 +254,223 @@ fn serve_e2e_initialize_tools_list_and_tool_calls() { let status = wait_with_timeout(&mut guard.0, TIMEOUT); assert!(status.success(), "figmog serve exited with {status:?}"); } + +// ---- cached-proxy e2e: figmog serve against an in-process HTTP fake ---- +// +// Minimal hand-rolled HTTP/1.1 server (std `TcpListener`, no new deps) that +// answers exactly the handshake + one `tools/call` `HttpUpstream::initialize` +// and a proxied call make: `initialize`, `notifications/initialized`, +// `tools/list`, then one `tools/call`. Mirrors `upstream.rs`'s own +// in-process fake (same wire mechanics), recreated here because that one +// lives in a `#[cfg(test)]` module private to the lib crate and isn't +// reachable from this integration-test binary. + +fn read_request(stream: &mut TcpStream) -> String { + let mut header_bytes = Vec::new(); + let mut byte = [0u8; 1]; + loop { + stream.read_exact(&mut byte).expect("read request byte"); + header_bytes.push(byte[0]); + if header_bytes.ends_with(b"\r\n\r\n") { + break; + } + } + let header_text = String::from_utf8_lossy(&header_bytes).to_string(); + let content_length: usize = header_text + .lines() + .find_map(|line| { + let lower = line.to_ascii_lowercase(); + lower + .strip_prefix("content-length:") + .map(|v| v.trim().parse().unwrap_or(0)) + }) + .unwrap_or(0); + let mut body_bytes = vec![0u8; content_length]; + if content_length > 0 { + stream + .read_exact(&mut body_bytes) + .expect("read request body"); + } + String::from_utf8_lossy(&body_bytes).to_string() +} + +fn write_response(stream: &mut TcpStream, status: &str, headers: &[(&str, &str)], body: &str) { + let mut resp = format!("HTTP/1.1 {status}\r\n"); + resp.push_str(&format!("Content-Length: {}\r\n", body.len())); + resp.push_str("Connection: close\r\n"); + for (k, v) in headers { + resp.push_str(&format!("{k}: {v}\r\n")); + } + resp.push_str("\r\n"); + resp.push_str(body); + stream.write_all(resp.as_bytes()).expect("write response"); + stream.flush().expect("flush response"); +} + +fn request_id(body: &str) -> Value { + serde_json::from_str::(body) + .ok() + .and_then(|v| v.get("id").cloned()) + .unwrap_or(Value::Null) +} + +/// Spawn a fake upstream MCP server answering exactly 4 requests: the +/// `HttpUpstream::initialize` handshake (`initialize`, +/// `notifications/initialized`, `tools/list` — advertising one tool, +/// `get_code`), then one `tools/call` returning canned content. Returns +/// its address and a join handle; the test drives exactly one real +/// `tools/call` through the child, so a second, cache-served call never +/// reaches this server — proven by the fake never accepting a 5th +/// connection (the accept loop simply ends). +fn spawn_fake_upstream() -> (String, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + let handle = std::thread::spawn(move || { + for i in 0..4u32 { + let (mut stream, _) = listener.accept().expect("accept"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set_read_timeout"); + let body = read_request(&mut stream); + match i { + 0 => { + let resp = json!({ + "jsonrpc": "2.0", + "id": request_id(&body), + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "serverInfo": {"name": "fake-figma-desktop", "version": "1.0"}, + }, + }) + .to_string(); + write_response( + &mut stream, + "200 OK", + &[("Content-Type", "application/json")], + &resp, + ); + } + 1 => { + write_response(&mut stream, "202 Accepted", &[], ""); + } + 2 => { + let resp = json!({ + "jsonrpc": "2.0", + "id": request_id(&body), + "result": {"tools": [ + { + "name": "get_code", + "description": "Returns code for a node", + "inputSchema": {"type": "object", "properties": {"nodeId": {"type": "string"}}}, + }, + ]}, + }) + .to_string(); + write_response( + &mut stream, + "200 OK", + &[("Content-Type", "application/json")], + &resp, + ); + } + 3 => { + let resp = json!({ + "jsonrpc": "2.0", + "id": request_id(&body), + "result": {"content": [{"type": "text", "text": "CODE_HERE"}], "isError": false}, + }) + .to_string(); + write_response( + &mut stream, + "200 OK", + &[("Content-Type", "application/json")], + &resp, + ); + } + _ => unreachable!(), + } + } + }); + (format!("http://{addr}/mcp"), handle) +} + +#[test] +fn serve_e2e_proxied_tool_lists_round_trips_and_second_call_is_cache_served() { + let (_dir, db) = common::fixture_db(); + let (fake_addr, fake_handle) = spawn_fake_upstream(); + let (mut guard, mut stdin, rx) = spawn_serve_with_args(&db, &["--upstream", &fake_addr]); + + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }), + ); + let resp = recv(&rx); + assert_eq!(resp["id"], json!(1)); + + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + ); + + // -- tools/list: 17 local + 1 proxied, prefixed description -- + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), + ); + let resp = recv(&rx); + let tools = resp["result"]["tools"].as_array().expect("tools array"); + assert_eq!(tools.len(), 18, "tools: {tools:#?}"); + let proxied = tools + .iter() + .find(|t| t["name"] == json!("get_code")) + .expect("get_code should be in the merged registry"); + assert_eq!( + proxied["description"], + json!("[via Figma desktop] Returns code for a node") + ); + + // -- figmog_status: upstream connected -- + let resp = call(&mut stdin, &rx, 3, "figmog_status", json!({})); + assert_eq!(resp["result"]["isError"], json!(false)); + assert_eq!(result_json(&resp)["upstream"], json!("connected")); + + // -- first get_code call with an explicit nodeId: round-trips the + // fake's canned content, and is cacheable (get_* + string nodeId). -- + let resp = call(&mut stdin, &rx, 4, "get_code", json!({"nodeId": "1:2"})); + assert_eq!(resp["result"]["isError"], json!(false)); + assert_eq!( + resp["result"]["content"][0]["text"], + json!( + serde_json::to_string( + &json!({"content": [{"type": "text", "text": "CODE_HERE"}], "isError": false}) + ) + .unwrap() + ) + ); + + // -- second identical call: served from the version-keyed cache — the + // fake upstream server only ever accepts 4 connections total (the + // handshake's 3 plus this test's one real `tools/call`), so if this + // call reached the network the fake's accept loop would still be + // blocked waiting for a 5th connection and `fake_handle.join()` below + // would hang past the test harness's own timeout. + let resp2 = call(&mut stdin, &rx, 5, "get_code", json!({"nodeId": "1:2"})); + assert_eq!( + resp2["result"], resp["result"], + "second identical call should be served byte-identically from cache" + ); + + drop(stdin); + let status = wait_with_timeout(&mut guard.0, TIMEOUT); + assert!(status.success(), "figmog serve exited with {status:?}"); + + fake_handle + .join() + .expect("fake upstream server thread should finish after exactly 4 requests"); +} From 7d246136a93a6888cc40b9353649b0bd67eab91e Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 00:42:35 -0700 Subject: [PATCH 34/56] =?UTF-8?q?fix(figmog):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20sync=20double-open=20panic,=20raw=20proxy=20passthrough,=20e?= =?UTF-8?q?rror=20caching,=20do=5Fpull=20eviction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C1: cmd_call opened its own store before delegating figmog_sync to do_pull (which opens the same path again) — fjall's single-open-per- process rule turned that into a panic after the Tier-1 fetch was already spent. figmog_sync now returns before cmd_call opens a store. - C2 (controller-amended constraint, spec §11 point 1): proxied results were double-wrapped as escaped text, mangling native output formats (e.g. unrenderable image content). mcp::ToolOutput{Json,Raw} lets local tools keep the text-block wrap while proxied calls pass through verbatim. - I3: upstream tool-level errors (isError: true) were being cached, replaying a stale failure forever; cache-store is now skipped for them. - I4 (controller ruling): stale proxy_cache eviction after a version-changing pull now lives in do_pull itself, covering pull/watch/ call figmog_sync in one place; figmog serve's own inline blocks are unchanged. Co-Authored-By: Claude Fable 5 --- examples/figmog/src/cli.rs | 49 ++++++++++++-- examples/figmog/src/mcp.rs | 82 +++++++++++++++++++---- examples/figmog/src/proxy.rs | 46 ++++++++++++- examples/figmog/src/serve.rs | 15 +++-- examples/figmog/tests/cli.rs | 116 +++++++++++++++++++++++++++++++++ examples/figmog/tests/serve.rs | 15 ++--- 6 files changed, 291 insertions(+), 32 deletions(-) diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 0845d28..e5f1711 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -487,8 +487,27 @@ pub(crate) fn do_pull( let prior: BTreeSet = st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { collect_sweepable(&nodes, &components, &component_sets, &styles) }); + let prior_version = + st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.version.clone())); let churn = sync(&mut st, &prior, &flattened, now_ms()); + // Every caller of `do_pull` (`pull`, `watch`'s per-tick pull, and + // `figmog call figmog_sync`) goes through here, so eviction lives here + // rather than duplicated at each call site (build design §12: a + // version-changing pull sweeps stale `proxy_cache` rows). `figmog + // serve`'s own pull paths don't call `do_pull` — they keep their own + // inline eviction blocks, since they already hold `st` open and + // re-opening it here would hit the same single-open-per-process wall + // `figmog call figmog_sync` used to. + if prior_version.as_deref() != Some(flattened.file.version.as_str()) { + let stale = st.rtx(|(_, _, _, _, _, _, _, cache)| { + crate::store::stale_cache_ids(&cache, &flattened.file.version) + }); + if !stale.is_empty() { + crate::store::evict_stale_cache(&mut st, &stale); + } + } + if let Some(key) = &db.key { write_current(key)?; } @@ -734,14 +753,26 @@ fn cmd_call( None => json!({}), }; + // `figmog_sync` delegates entirely to `do_pull`, which opens its own + // `open_store!` handle at `db.path`. fjall allows only one open handle + // per process for a given store, so this has to return *before* this + // function opens its own `st` below — opening both in the same process + // deadlocks/panics on the second open's file lock (this is why this + // branch can't just join the `if tool == "figmog_sync"` chain further + // down, the way `figmog serve`'s handler can: `run_serve` never opens a + // second handle for `figmog_sync`, since it reuses its own long-lived + // `st` instead of calling `do_pull`). + if tool == "figmog_sync" { + let result = do_pull(db, None, None, false) + .map(|(churn, _name, _version)| serde_json::to_value(&churn).unwrap_or_default()) + .map_err(|e| e.to_string()); + return print_call_result(result, json); + } + let (mut upstream, upstream_status) = attach_upstream(upstream_url, no_upstream); let mut st = crate::open_store!(&db.path); - let result: Result = if tool == "figmog_sync" { - do_pull(db, None, None, false) - .map(|(churn, _name, _version)| serde_json::to_value(&churn).unwrap_or_default()) - .map_err(|e| e.to_string()) - } else if proxy::is_local_tool(&tool) { + let result: Result = if proxy::is_local_tool(&tool) { match st.rtx(|r| dispatch::dispatch_read_tool(&tool, &args, upstream_status, r)) { Some(r) => r, None => Err(format!("unknown tool: {tool}")), @@ -772,6 +803,14 @@ fn cmd_call( }) }; + print_call_result(result, json) +} + +/// Shared `figmog call` output: pretty-printed JSON on success; on +/// failure, `{"error": ...}` on stdout (exit 0) under `--json`, otherwise +/// the plain error via the normal `figmog: ` / exit-1 path (see +/// `run`). +fn print_call_result(result: Result, json: bool) -> Result<(), String> { match result { Ok(v) => { println!( diff --git a/examples/figmog/src/mcp.rs b/examples/figmog/src/mcp.rs index 82f4244..016c434 100644 --- a/examples/figmog/src/mcp.rs +++ b/examples/figmog/src/mcp.rs @@ -34,10 +34,28 @@ pub struct ToolDef { pub input_schema: Value, } -/// Executes a `tools/call`. `Ok(v)` becomes success content; `Err(msg)` -/// becomes `isError` content. +/// What a `tools/call` handler produces on success (spec §11/§12: local +/// `figmog_*` tools own their own JSON shape and answer it as MCP text +/// content the way figmog always has; proxied tools' results are already a +/// complete, correctly-shaped MCP `CallToolResult` produced by the +/// upstream — re-wrapping that as ANOTHER text block would double-encode +/// it and, for a non-text content type such as `get_screenshot`'s image +/// block, make it unrenderable). +pub enum ToolOutput { + /// figmog's own JSON, serialized into a single text content block — + /// today's (v1/v2) behavior, still used for every local `figmog_*` + /// tool. + Json(Value), + /// A complete MCP `tools/call` result, emitted verbatim as the + /// JSON-RPC `result` member. Used for proxied calls, whose shape (and + /// `isError`) is the upstream's to own. + Raw(Value), +} + +/// Executes a `tools/call`. `Ok(output)` becomes the success result per +/// [`ToolOutput`]'s two shapes; `Err(msg)` becomes `isError` text content. pub trait ToolHandler { - fn call(&mut self, name: &str, args: &Value) -> Result; + fn call(&mut self, name: &str, args: &Value) -> Result; } /// Adapts a closure to [`ToolHandler`]. `figmog serve`'s store handle has @@ -48,8 +66,8 @@ pub trait ToolHandler { /// whatever concrete type it was defined against. pub struct FnHandler(pub F); -impl Result> ToolHandler for FnHandler { - fn call(&mut self, name: &str, args: &Value) -> Result { +impl Result> ToolHandler for FnHandler { + fn call(&mut self, name: &str, args: &Value) -> Result { (self.0)(name, args) } } @@ -147,10 +165,11 @@ fn tools_call_result(params: &Value, tools: &[ToolDef], handler: &mut dyn ToolHa let args = params.get("arguments").cloned().unwrap_or(json!({})); match handler.call(name, &args) { - Ok(v) => json!({ + Ok(ToolOutput::Json(v)) => json!({ "content": [{"type": "text", "text": serde_json::to_string(&v).unwrap()}], "isError": false, }), + Ok(ToolOutput::Raw(v)) => v, Err(msg) => error_content(&msg), } } @@ -166,16 +185,21 @@ fn error_content(msg: &str) -> Value { mod tests { use super::*; - /// A `ToolHandler` test double: returns `Ok({"ok":true})` for a tool - /// named `"ok"`, `Err("boom")` for a tool named `"err"`, and panics for - /// any other name (the dispatch contract guarantees unknown names never - /// reach the handler). + /// A `ToolHandler` test double: returns `Ok(Json({"ok":true}))` for a + /// tool named `"ok"`, `Ok(Raw(...))` for `"raw"` (an already-complete + /// MCP result, as a proxied call would produce), `Err("boom")` for + /// `"err"`, and panics for any other name (the dispatch contract + /// guarantees unknown names never reach the handler). struct FakeHandler; impl ToolHandler for FakeHandler { - fn call(&mut self, name: &str, _args: &Value) -> Result { + fn call(&mut self, name: &str, _args: &Value) -> Result { match name { - "ok" => Ok(json!({"ok": true})), + "ok" => Ok(ToolOutput::Json(json!({"ok": true}))), + "raw" => Ok(ToolOutput::Raw(json!({ + "content": [{"type": "image", "data": "base64==", "mimeType": "image/png"}], + "isError": false, + }))), "err" => Err("boom".to_string()), other => panic!("handler should not be called for {other}"), } @@ -189,6 +213,11 @@ mod tests { description: "always succeeds", input_schema: json!({"type": "object"}), }, + ToolDef { + name: "raw", + description: "returns a raw passthrough result", + input_schema: json!({"type": "object"}), + }, ToolDef { name: "err", description: "always fails", @@ -311,6 +340,7 @@ mod tests { "result": { "tools": [ {"name": "ok", "description": "always succeeds", "inputSchema": {"type": "object"}}, + {"name": "raw", "description": "returns a raw passthrough result", "inputSchema": {"type": "object"}}, {"name": "err", "description": "always fails", "inputSchema": {"type": "object"}}, ], }, @@ -342,6 +372,34 @@ mod tests { ); } + #[test] + fn tools_call_raw_output_is_emitted_verbatim_as_the_result_member() { + // A proxied call's result is already a complete MCP `CallToolResult` + // (e.g. an image content block for a screenshot tool) — `Raw` must + // pass it through untouched, NOT re-wrap it in another text block + // (which would double-encode it and make it unrenderable). + let raw = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "raw", "arguments": {}}, + }) + .to_string(); + let tools = fake_tools(); + let resp = handle_message(&raw, &tools, &mut FakeHandler).unwrap(); + assert_eq!( + resp, + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"type": "image", "data": "base64==", "mimeType": "image/png"}], + "isError": false, + }, + }) + ); + } + #[test] fn tools_call_handler_error_becomes_is_error_content() { let raw = json!({ diff --git a/examples/figmog/src/proxy.rs b/examples/figmog/src/proxy.rs index c419546..4000b8f 100644 --- a/examples/figmog/src/proxy.rs +++ b/examples/figmog/src/proxy.rs @@ -121,7 +121,13 @@ pub(crate) fn proxy_call>>( let result = upstream.call(name, args).map_err(|e| e.to_string())?; if is_cacheable(name, args) { - if let Some(version) = &version { + // Never cache a tool-level failure (spec §12's cache is a response + // cache, not an error cache): an upstream `isError: true` result + // still passes through to the client verbatim (its own `isError` + // preserved), it just isn't written to `proxy_cache`, so the next + // identical call gets a fresh attempt instead of a stuck failure. + let is_error = result.get("isError") == Some(&Value::Bool(true)); + if !is_error && let Some(version) = &version { let args_canonical = canonical_args(args); cache::store(st, name, &args_canonical, version, &result); } @@ -317,6 +323,44 @@ mod tests { assert_eq!(upstream.call_count, 1); } + #[test] + fn proxy_call_cacheable_tool_error_is_forwarded_but_not_cached() { + // An upstream tool-level failure (isError: true in a successful + // Ok(...) result — not an UpstreamError) must reach the client + // verbatim, but must NOT be written to the cache: otherwise a + // transient failure would be replayed forever on every later call + // for the same node. + let dir = tempfile::tempdir().unwrap(); + let mut st = crate::open_store!(dir.path().join("db")); + let mut upstream = FakeUpstream::new(vec![]); + let error_result = json!({ + "content": [{"type": "text", "text": "node not found"}], + "isError": true, + }); + upstream.push_result(Ok(error_result.clone())); + + let (value, poll) = proxy_call( + &mut st, + &mut upstream, + "get_code", + &json!({"nodeId": "1:2"}), + (Some("100".to_string()), None), + ) + .unwrap(); + assert_eq!(value, error_result); + assert!(!poll); + + let stored = st.rtx(|(_, _, _, _, _, _, _, cache)| { + cache::lookup( + &cache, + "get_code", + &canonical_args(&json!({"nodeId": "1:2"})), + "100", + ) + }); + assert_eq!(stored, None, "an isError result must never be cached"); + } + #[test] fn proxy_call_propagates_upstream_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/examples/figmog/src/serve.rs b/examples/figmog/src/serve.rs index 9afcd35..e3b853e 100644 --- a/examples/figmog/src/serve.rs +++ b/examples/figmog/src/serve.rs @@ -47,7 +47,7 @@ use crate::cli::{ use crate::dispatch; use crate::flatten::flatten_file; use crate::ident::parse_file_ref; -use crate::mcp::{self, FnHandler}; +use crate::mcp::{self, FnHandler, ToolOutput}; use crate::model::Id; use crate::proxy; use crate::store::{self, collect_sweepable}; @@ -238,7 +238,7 @@ pub(crate) fn run_serve( continue; }; - let mut handler = FnHandler(|name: &str, args: &Value| -> Result { + let mut handler = FnHandler(|name: &str, args: &Value| -> Result { if name == "figmog_sync" { let sync_key = key .clone() @@ -286,13 +286,14 @@ pub(crate) fn run_serve( if let Some(k) = &db.key { let _ = write_current(k); } - return serde_json::to_value(&churn).map_err(|e| e.to_string()); + let churn_value = serde_json::to_value(&churn).map_err(|e| e.to_string())?; + return Ok(ToolOutput::Json(churn_value)); } if let Some(result) = st.rtx(|r| dispatch::dispatch_read_tool(name, args, upstream_status, r)) { - return result; + return result.map(ToolOutput::Json); } if proxy::is_local_tool(name) { @@ -319,7 +320,11 @@ pub(crate) fn run_serve( if trigger_poll && !no_watch { next_deadline = Instant::now(); } - Ok(value) + // A proxied result is already a complete MCP `CallToolResult` + // from the upstream — emit it verbatim (spec §11/§12; see + // `mcp::ToolOutput::Raw`'s doc comment) rather than re-wrapping + // it as figmog's own text-block shape. + Ok(ToolOutput::Raw(value)) }); if let Some(resp) = mcp::handle_message(&line, &tools, &mut handler) { diff --git a/examples/figmog/tests/cli.rs b/examples/figmog/tests/cli.rs index c79ed46..dd9d787 100644 --- a/examples/figmog/tests/cli.rs +++ b/examples/figmog/tests/cli.rs @@ -390,3 +390,119 @@ fn failed_pull_does_not_persist_current_or_create_store() { assert!(stderr.contains("no mirror here"), "stderr: {stderr}"); assert!(!dir.path().join(".figmog").exists()); } + +#[test] +fn call_figmog_sync_fails_cleanly_not_panicking() { + // Regression test: `cmd_call`'s `figmog_sync` branch used to open its + // own store handle unconditionally, then delegate to `do_pull`, which + // opens the *same* path again — fjall allows only one open handle per + // store per process, so a real sync would panic on the second open's + // file lock (after the Tier-1 fetch was already spent). `cmd_call` now + // checks for `figmog_sync` and returns before ever opening its own + // handle, so this call — which fails during `do_pull`'s own key + // resolution, since `--db` alone establishes no file key — has to fail + // cleanly (exit 1, one plain stderr line), never panic, for the fix to + // hold: a panic would print a backtrace banner and a different exit + // status instead. + let (_dir, db) = fixture_db(); + let out = Command::cargo_bin("figmog") + .unwrap() + .env_remove("FIGMA_TOKEN") + .args(["call", "figmog_sync", "--db", &db]) + .assert() + .failure() + .code(1); + let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string(); + assert!( + stderr.starts_with("figmog:"), + "expected a clean `figmog: ...` error, got: {stderr}" + ); + assert!( + !stderr.to_lowercase().contains("panic"), + "must not panic: {stderr}" + ); +} + +#[test] +fn cli_pull_evicts_stale_cache_rows_on_version_change() { + // I4: eviction lives inside `do_pull` itself (not just `figmog + // serve`'s two inline blocks), so it covers `figmog pull`, `figmog + // watch`, and `figmog call figmog_sync` — all three delegate to + // `do_pull`. Exercised here through the actual `figmog pull` CLI + // command (the store handle used to hand-insert the cache row is + // dropped before each CLI invocation — fjall allows only one open + // handle per store per process). + use figmog::cache; + + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("db"); + let db_str = db.display().to_string(); + + let response_v1 = dir.path().join("v1.json"); + std::fs::write( + &response_v1, + serde_json::to_string(&common::fixture_v1()).unwrap(), + ) + .unwrap(); + Command::cargo_bin("figmog") + .unwrap() + .args([ + "pull", + "--from-file", + response_v1.to_str().unwrap(), + "--db", + &db_str, + ]) + .assert() + .success(); + + // Hand-store a proxy_cache row tagged at v1's version ("100"), scoped + // so the store handle closes before the next `figmog pull` subprocess + // opens its own. + { + let mut st = figmog::open_store!(&db); + cache::store( + &mut st, + "get_code", + "{}", + "100", + &serde_json::json!({"cached": true}), + ); + let hit = st.rtx(|(_, _, _, _, _, _, _, cache_reader)| { + cache::lookup(&cache_reader, "get_code", "{}", "100") + }); + assert!( + hit.is_some(), + "sanity: the hand-stored row must be readable before the v2 pull" + ); + } + + // v2 (version "101") via `figmog pull` — this is the version-changing + // pull that must sweep the stale row. + let response_v2 = dir.path().join("v2.json"); + std::fs::write( + &response_v2, + serde_json::to_string(&common::fixture_v2()).unwrap(), + ) + .unwrap(); + Command::cargo_bin("figmog") + .unwrap() + .args([ + "pull", + "--from-file", + response_v2.to_str().unwrap(), + "--db", + &db_str, + ]) + .assert() + .success(); + + let st = figmog::open_store!(&db); + let evicted = st.rtx(|(_, _, _, _, _, _, _, cache_reader)| { + cache::lookup(&cache_reader, "get_code", "{}", "100") + }); + assert_eq!( + evicted, None, + "the v1-tagged cache row must be evicted by the v2 `figmog pull`" + ); +} diff --git a/examples/figmog/tests/serve.rs b/examples/figmog/tests/serve.rs index 01edf0f..b6a0233 100644 --- a/examples/figmog/tests/serve.rs +++ b/examples/figmog/tests/serve.rs @@ -441,17 +441,14 @@ fn serve_e2e_proxied_tool_lists_round_trips_and_second_call_is_cache_served() { assert_eq!(result_json(&resp)["upstream"], json!("connected")); // -- first get_code call with an explicit nodeId: round-trips the - // fake's canned content, and is cacheable (get_* + string nodeId). -- + // fake's canned content verbatim (the proxied result is a complete MCP + // `CallToolResult` already — figmog emits it as-is, NOT re-wrapped in + // another text content block, so native output formats like an image + // block survive unmangled) and is cacheable (get_* + string nodeId). -- let resp = call(&mut stdin, &rx, 4, "get_code", json!({"nodeId": "1:2"})); - assert_eq!(resp["result"]["isError"], json!(false)); assert_eq!( - resp["result"]["content"][0]["text"], - json!( - serde_json::to_string( - &json!({"content": [{"type": "text", "text": "CODE_HERE"}], "isError": false}) - ) - .unwrap() - ) + resp["result"], + json!({"content": [{"type": "text", "text": "CODE_HERE"}], "isError": false}) ); // -- second identical call: served from the version-keyed cache — the From 742463959e36d5cfebb61dc98d3bdb5307329060 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 00:53:44 -0700 Subject: [PATCH 35/56] feat(figmog): opportunistic Enterprise variables sync in pull Co-Authored-By: Claude Fable 5 --- examples/figmog/README.md | 54 ++++-- examples/figmog/src/api.rs | 71 +++++++- examples/figmog/src/cli.rs | 37 +++- examples/figmog/src/serve.rs | 38 +++- examples/figmog/src/store.rs | 39 ++++- examples/figmog/tests/enterprise_vars.rs | 213 +++++++++++++++++++++++ 6 files changed, 412 insertions(+), 40 deletions(-) create mode 100644 examples/figmog/tests/enterprise_vars.rs diff --git a/examples/figmog/README.md b/examples/figmog/README.md index f7c45ac..39f9d11 100644 --- a/examples/figmog/README.md +++ b/examples/figmog/README.md @@ -44,7 +44,7 @@ store location (default `.figmog//db`). | `figmog styles [--type ] [--values]` | styles + styled_by (+ nodes) | styles with usage counts; `--values` derives each style's definition from a consumer node (§ below) | | `figmog uses ` | styled_by / bound_to + nodes | nodes using a style id or bound to a variable id | | `figmog vars [id]` | nodes + variables + variable_collections | variables: authoritative record if imported, else inferred value(s) + binding sites | -| `figmog import-variables ` | — | upsert variable/collection records from a variables export (see "Variables on a free plan") | +| `figmog import-variables ` | — | upsert variable/collection records from a variables export (see "Variables") | | `figmog stats` | nodes + by_type + components + component_sets + styles + variables | node counts by type and by page, component/set/style/variable totals, text-node count, max tree depth — whole-file structural queries the API can't offer at all | | `figmog path ` | nodes | ancestor chain root→node: `[{id, name, type}]` | | `figmog text [--page ]` | by_type + nodes | every TEXT node's `(id, characters, page_id)`, sorted by id | @@ -213,11 +213,21 @@ cache described above. `--no-upstream` recovers the older, "second, separate server" shape (v2) if that's ever preferable — figmog's 17 `figmog_*` tools alongside Figma's own, unrelated MCP connection. -## Variables on a free plan +## Variables -The Variables REST endpoints (`variables/local`, `variables/published`) -are Enterprise-only, so figmog never calls them. Variables are supported -through two complementary paths: +**Enterprise auto-sync (automatic, zero setup).** Every network `pull` +additionally calls `GET /v1/files/:key/variables/local` — the Enterprise +REST endpoint for full-fidelity variable and collection records: +collections, modes (e.g. light/dark), per-mode values, descriptions, +scopes. When it succeeds, those records are folded into the same sync and +kept live: a variable removed upstream is swept on the next pull, exactly +like a deleted node. On non-Enterprise plans the endpoint 403s (or 404s) +and the call is **silently skipped** — no error, no flag to set — falling +back to the two paths below. `--from-file` pulls never call it at all +(no network involved). + +Below that, variables are supported through two complementary fallback +paths that work on every plan: **Path 1 — mirrored bindings + inference (always on, zero setup).** Every variable-bound property in the file JSON carries a `boundVariables` @@ -229,14 +239,18 @@ the observed value(s) baked in there. This covers each variable's **default-mode value**; values from a non-default mode appear only where a frame explicitly overrides its mode. -**Path 2 — authoritative import (optional).** `figmog import-variables -` upserts full-fidelity variable and collection records: -collections, modes (e.g. light/dark), per-mode values, descriptions, -scopes. It accepts two shapes: the Enterprise REST `variables/local` -response, or the JSON produced by the free-plan escape hatch below — the -Figma Plugin API can read local variables on **any** plan, run from -Figma's own developer console. `figmog vars` prefers an imported -(authoritative) record over inference whenever one exists. +**Path 2 — manual import (optional).** `figmog import-variables +` upserts the same full-fidelity variable and collection +records the Enterprise auto-sync produces, by hand. It accepts two shapes: +the Enterprise REST `variables/local` response (the same shape auto-sync +already ingests, useful for a one-off import outside `pull`), or the JSON +produced by the free-plan escape hatch below — the Figma Plugin API can +read local variables on **any** plan, run from Figma's own developer +console. `figmog vars` prefers an authoritative record (auto-synced or +imported) over inference whenever one exists. Unlike auto-synced records, +manually imported ones are **not** swept by a later pull that has no +Enterprise export of its own (e.g. on a non-Enterprise plan) — they +persist until re-imported or `pull --fresh`. ```js // Figma → Plugins → Development → Open console, then paste: @@ -287,10 +301,11 @@ the mirrored file is. ## Limitations -- **Variables** — inference (Path 1, always on) covers each variable's - default-mode value; a non-default mode's value is visible only where a - frame explicitly overrides that mode. Full per-mode fidelity requires - `import-variables` (Path 2). +- **Variables** — on non-Enterprise plans (no automatic `variables/local` + sync), inference (always on) covers each variable's default-mode value; + a non-default mode's value is visible only where a frame explicitly + overrides that mode. Full per-mode fidelity requires either the + Enterprise auto-sync or a manual `import-variables`. - **No image renders** — figmog mirrors document structure and properties, not rendered pixels; there's no `GET /v1/images` integration. - **Style definitions are derived, not authoritative** — the file JSON's @@ -311,5 +326,6 @@ the mirrored file is. - **`pull --fresh` wipes imported variables** — `--fresh` deletes the whole store, including `import-variables` records that normally survive ordinary pulls (they're exempt from the file-sync sweep, not from a full - wipe). Re-run `import-variables` after a `--fresh` pull if you need - authoritative variable data back. + wipe). On an Enterprise plan the very next `pull` repopulates them + automatically (auto-sync); everywhere else, re-run `import-variables` + after a `--fresh` pull if you need authoritative variable data back. diff --git a/examples/figmog/src/api.rs b/examples/figmog/src/api.rs index a0f646e..e533fee 100644 --- a/examples/figmog/src/api.rs +++ b/examples/figmog/src/api.rs @@ -28,13 +28,24 @@ pub struct FileMetaResp { pub last_touched_at: String, } -/// The two calls figmog makes. `file_meta` is Tier 3 (cheap, poll it); -/// `file` is Tier 1 (expensive, call only on change). +/// The calls figmog makes. `file_meta` is Tier 3 (cheap, poll it); `file` +/// is Tier 1 (expensive, call only on change); `variables_local` is Tier 2 +/// and Enterprise-only, called opportunistically by `pull` (build design +/// §12). pub trait FigmaApi { /// `GET /v1/files/:key/meta` — Tier 3, cheap enough to poll. fn file_meta(&self, key: &str) -> Result; /// `GET /v1/files/:key` — Tier 1, the full document tree. fn file(&self, key: &str) -> Result; + /// `GET /v1/files/:key/variables/local` — Enterprise-only. `Ok(None)` + /// means "not available on this plan" (never an error: `pull` falls + /// back to v1 behavior). The default implementation always returns + /// `Ok(None)`, so test doubles that only care about `file`/`file_meta` + /// (e.g. `watch::tests::Script`) don't need to know this call exists. + fn variables_local(&self, key: &str) -> Result, ApiError> { + let _ = key; + Ok(None) + } } pub(crate) fn parse_meta_response(v: &Value) -> Result { @@ -105,6 +116,23 @@ impl FigmaApi for UreqApi { fn file(&self, key: &str) -> Result { self.get_json(&format!("/v1/files/{key}")) } + fn variables_local(&self, key: &str) -> Result, ApiError> { + match self.get_json(&format!("/v1/files/{key}/variables/local")) { + Ok(v) => Ok(Some(v)), + Err(e) if variables_local_is_gated(&e) => Ok(None), + Err(e) => Err(e), + } + } +} + +/// Whether a `variables_local` failure means "this plan can't see +/// variables" (403/404 — skip silently, v1 behavior holds) rather than a +/// real failure `pull` should propagate. By the time `variables_local` is +/// called, `file()` has already succeeded against the same token, so a 401/ +/// 403 here means plan gating, not a bad token — `error_from_status` maps +/// both to `ApiError::Auth`. +fn variables_local_is_gated(e: &ApiError) -> bool { + matches!(e, ApiError::Auth) || matches!(e, ApiError::Http { status: 404, .. }) } #[cfg(test)] @@ -149,4 +177,43 @@ mod tests { ApiError::Http { status: 500, .. } )); } + + #[test] + fn variables_local_gated_on_403_and_404_only() { + assert!(variables_local_is_gated(&ApiError::Auth)); + assert!(variables_local_is_gated(&ApiError::Http { + status: 404, + msg: String::new() + })); + assert!(!variables_local_is_gated(&ApiError::Http { + status: 500, + msg: "boom".into() + })); + assert!(!variables_local_is_gated(&ApiError::Network("down".into()))); + assert!(!variables_local_is_gated(&ApiError::RateLimited { + retry_after: Duration::from_secs(1) + })); + } + + /// A test double that only implements the two calls it needs — proving + /// the trait's default `variables_local` (used by e.g. + /// `watch::tests::Script`) is `Ok(None)` without requiring every + /// `FigmaApi` implementor to know the Enterprise endpoint exists. + struct NoVariablesOverride; + impl FigmaApi for NoVariablesOverride { + fn file_meta(&self, _key: &str) -> Result { + unimplemented!("not exercised by this test") + } + fn file(&self, _key: &str) -> Result { + unimplemented!("not exercised by this test") + } + } + + #[test] + fn default_variables_local_is_none() { + assert!(matches!( + NoVariablesOverride.variables_local("ABC123"), + Ok(None) + )); + } } diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index e5f1711..6e3a870 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -21,7 +21,7 @@ use crate::model::{ }; use crate::proxy; use crate::query::{self, TextReader}; -use crate::store::{Churn, collect_sweepable, sync}; +use crate::store::{Churn, collect_sweepable, collect_variable_ids, sync}; use crate::upstream::{HttpUpstream, UpstreamMcp}; use crate::watch::{BACKOFF_CAP, BACKOFF_START, Tick, Watcher}; @@ -458,12 +458,16 @@ pub(crate) fn do_pull( from_file: Option, fresh: bool, ) -> Result<(Churn, String, String), PullError> { - let resp: Value = match from_file { + // `vars_resp` is only ever `Some` on the network path — `--from-file` + // ingests a saved `GET /v1/files/:key` response and never touches the + // network at all, so it never calls `variables_local` either. + let (resp, vars_resp): (Value, Option) = match from_file { Some(path) => { let content = std::fs::read_to_string(&path) .map_err(|e| format!("reading {}: {e}", path.display()))?; - serde_json::from_str(&content) - .map_err(|e| format!("parsing {}: {e}", path.display()))? + let resp = serde_json::from_str(&content) + .map_err(|e| format!("parsing {}: {e}", path.display()))?; + (resp, None) } None => { let key = db @@ -473,7 +477,13 @@ pub(crate) fn do_pull( .ok_or_else(|| "no file key: pass a file key or figma.com URL".to_string())?; let token = std::env::var("FIGMA_TOKEN") .map_err(|_| "FIGMA_TOKEN not set — required for network pulls".to_string())?; - UreqApi::new(token).file(&key)? + let api = UreqApi::new(token); + let resp = api.file(&key)?; + // Opportunistic Enterprise variables sync (spec §12): `Ok(None)` + // on non-Enterprise plans is not an error — v1 behavior + // (import/inference, sweep-exempt) holds unchanged below. + let vars_resp = api.variables_local(&key)?; + (resp, vars_resp) } }; @@ -481,12 +491,21 @@ pub(crate) fn do_pull( std::fs::remove_dir_all(&db.path).ok(); } - let flattened = flatten_file(&resp).map_err(|e| e.to_string())?; + let mut flattened = flatten_file(&resp).map_err(|e| e.to_string())?; let mut st = crate::open_store!(&db.path); - let prior: BTreeSet = st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { - collect_sweepable(&nodes, &components, &component_sets, &styles) - }); + let mut prior: BTreeSet = + st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + if let Some(v) = &vars_resp { + let var_recs = crate::vars::parse_variables_export(v).map_err(|e| e.to_string())?; + flattened.recs.extend(var_recs); + let stored_var_ids = st.rtx(|(_, _, _, _, variables, variable_collections, _, _)| { + collect_variable_ids(&variables, &variable_collections) + }); + prior.extend(stored_var_ids); + } let prior_version = st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.version.clone())); let churn = sync(&mut st, &prior, &flattened, now_ms()); diff --git a/examples/figmog/src/serve.rs b/examples/figmog/src/serve.rs index e3b853e..8e81c45 100644 --- a/examples/figmog/src/serve.rs +++ b/examples/figmog/src/serve.rs @@ -190,11 +190,26 @@ pub(crate) fn run_serve( Tick::Changed { .. } => { let pull_result: Result = (|| { let resp = api_ref.file(watch_key)?; - let flattened = flatten_file(&resp).map_err(|e| e.to_string())?; - let prior: BTreeSet = + // Opportunistic Enterprise variables sync (spec + // §12): `Ok(None)` on non-Enterprise plans is not an + // error — v1 behavior (import/inference, + // sweep-exempt) holds unchanged below. + let vars_resp = api_ref.variables_local(watch_key)?; + let mut flattened = flatten_file(&resp).map_err(|e| e.to_string())?; + let mut prior: BTreeSet = st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { collect_sweepable(&nodes, &components, &component_sets, &styles) }); + if let Some(v) = &vars_resp { + let var_recs = crate::vars::parse_variables_export(v) + .map_err(|e| e.to_string())?; + flattened.recs.extend(var_recs); + let stored_var_ids = + st.rtx(|(_, _, _, _, variables, variable_collections, _, _)| { + store::collect_variable_ids(&variables, &variable_collections) + }); + prior.extend(stored_var_ids); + } Ok(store::sync(&mut st, &prior, &flattened, now_ms())) })(); match pull_result { @@ -248,11 +263,26 @@ pub(crate) fn run_serve( let sync_api = UreqApi::new(token); let pull_result: Result = (|| { let resp = sync_api.file(&sync_key)?; - let flattened = flatten_file(&resp).map_err(|e| e.to_string())?; - let prior: BTreeSet = + // Opportunistic Enterprise variables sync (spec §12): + // `Ok(None)` on non-Enterprise plans is not an error — + // v1 behavior (import/inference, sweep-exempt) holds + // unchanged below. + let vars_resp = sync_api.variables_local(&sync_key)?; + let mut flattened = flatten_file(&resp).map_err(|e| e.to_string())?; + let mut prior: BTreeSet = st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { collect_sweepable(&nodes, &components, &component_sets, &styles) }); + if let Some(v) = &vars_resp { + let var_recs = + crate::vars::parse_variables_export(v).map_err(|e| e.to_string())?; + flattened.recs.extend(var_recs); + let stored_var_ids = + st.rtx(|(_, _, _, _, variables, variable_collections, _, _)| { + store::collect_variable_ids(&variables, &variable_collections) + }); + prior.extend(stored_var_ids); + } Ok(store::sync(&mut st, &prior, &flattened, now_ms())) })(); // A failed manual sync still spends the same backoff diff --git a/examples/figmog/src/store.rs b/examples/figmog/src/store.rs index 97bfc6e..4485469 100644 --- a/examples/figmog/src/store.rs +++ b/examples/figmog/src/store.rs @@ -217,8 +217,11 @@ pub struct Churn { } /// Apply a flattened file in one atomic transaction: upsert every record -/// and the meta row, then remove previously-stored ids that vanished. -/// Variables, collections, and the meta row are exempt from the sweep. +/// and the meta row, then remove previously-stored ids that vanished. The +/// meta row is always exempt from the sweep; variables and collections are +/// exempt too *unless* the caller opted them in via `prior_sweepable` +/// (`collect_variable_ids`, spec §12 — an Enterprise `variables_local` +/// pull makes them file state for that cycle). pub fn sync>>( st: &mut KeyedStream, prior_sweepable: &BTreeSet, @@ -245,10 +248,11 @@ pub fn sync>>( tx.upsert(&Id::Meta, &Rec::Meta(meta)); for id in prior_sweepable { if !live.contains(id) { - debug_assert!(!matches!( - id, - Id::Variable(_) | Id::VariableCollection(_) | Id::Meta - )); + // The meta row is never sweepable — `collect_sweepable` and + // `collect_variable_ids` both draw only from the + // nodes/components/component_sets/styles/variables/ + // collections tables, never `meta`. + debug_assert!(!matches!(id, Id::Meta)); if tx.remove(id).is_some() { churn.removed += 1; } @@ -279,6 +283,29 @@ pub fn collect_sweepable( out } +/// Gather the currently-*stored* variable + collection ids (spec §12: +/// Enterprise variables in `pull`). Unlike [`collect_sweepable`], callers +/// must union this into the prior/sweepable set only on a pull that fetched +/// an Enterprise variables export *this cycle* — the `variables_local` call +/// returned `Some(..)` and its records were flattened into the same +/// `flattened.recs` passed to `sync`. On the `Ok(None)` (non-Enterprise or +/// import-only) path, callers must not call this — stored variables then +/// stay outside `sync`'s live/sweep accounting entirely, exactly as v1. +pub fn collect_variable_ids( + variables: &fold::pipeline::terminal::TableReader<'_, R, String, crate::model::VariableRec>, + collections: &fold::pipeline::terminal::TableReader< + '_, + R, + String, + crate::model::VariableCollectionRec, + >, +) -> BTreeSet { + let mut out = BTreeSet::new(); + out.extend(variables.iter().map(|(k, _)| Id::Variable(k))); + out.extend(collections.iter().map(|(k, _)| Id::VariableCollection(k))); + out +} + // ---- proxy cache eviction (spec §12) ---- // // Cache eviction is deliberately NOT folded into `sync`'s sweep: the sweep diff --git a/examples/figmog/tests/enterprise_vars.rs b/examples/figmog/tests/enterprise_vars.rs new file mode 100644 index 0000000..6d29e2e --- /dev/null +++ b/examples/figmog/tests/enterprise_vars.rs @@ -0,0 +1,213 @@ +//! Sync-level tests for opportunistic Enterprise variables (spec §12): +//! `pull` additionally calling `GET /v1/files/:key/variables/local` folds +//! the export's records into the same sync **and makes them sweepable for +//! that pull**, while an `Ok(None)` response (non-Enterprise plan, or a +//! `--from-file` pull that never calls the network endpoint at all) leaves +//! v1 behavior — import/inference, sweep-exempt — untouched. +//! +//! These tests prove the record-level wiring contract +//! (`parse_variables_export` into `flattened.recs`, +//! `store::collect_variable_ids` into the prior/sweepable set) directly, +//! since a fake `FigmaApi` is awkward to thread through at the sync layer. +//! The `Ok(None)`/403-404 gating on the network call itself is covered by +//! `api::tests` instead. + +#![recursion_limit = "256"] + +mod common; + +use std::collections::BTreeSet; + +use figmog::model::{Id, Rec}; +use figmog::store::{Churn, collect_sweepable, collect_variable_ids, sync}; +use figmog::vars::parse_variables_export; + +fn export() -> serde_json::Value { + serde_json::from_str(include_str!("fixtures/variables-export.json")).unwrap() +} + +#[test] +fn enterprise_export_syncs_with_zero_churn_on_identical_repull() { + let dir = tempfile::tempdir().unwrap(); + let mut st = figmog::open_store!(dir.path().join("db")); + + let var_recs = parse_variables_export(&export()).unwrap(); + + // First pull: nodes + the Enterprise export together (the + // `variables_local` `Some(v)` path). + let mut flattened = figmog::flatten::flatten_file(&common::fixture_v1()).unwrap(); + flattened.recs.extend(var_recs.clone()); + let prior1 = st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + // No stored variables yet on the very first pull, so + // `collect_variable_ids` would be empty here regardless — this + // matches what `do_pull` does (it still unions it in, this test + // just documents that the initial set is empty). + }); + let churn1 = sync(&mut st, &prior1, &flattened, 1_000); + assert_eq!( + churn1, + Churn { + added: 18 + 5, // 18 node/component/set/style recs + 2 collections + 3 variables + changed: 0, + removed: 0, + unchanged: 0 + } + ); + + // Second, identical pull: prior now unions in the stored variable ids + // too (this pull's `variables_local` returned `Some` again). + let mut flattened2 = figmog::flatten::flatten_file(&common::fixture_v1()).unwrap(); + flattened2.recs.extend(var_recs); + let prior2 = st.rtx( + |( + (nodes, ..), + components, + component_sets, + styles, + variables, + variable_collections, + _, + _, + )| { + let mut p = collect_sweepable(&nodes, &components, &component_sets, &styles); + p.extend(collect_variable_ids(&variables, &variable_collections)); + p + }, + ); + let churn2 = sync(&mut st, &prior2, &flattened2, 2_000); + assert_eq!( + churn2, + Churn { + added: 0, + changed: 0, + removed: 0, + unchanged: 23 + }, + "identical Enterprise export re-pull must cause zero churn, variables included" + ); +} + +#[test] +fn variable_removed_upstream_is_swept_when_export_present() { + let dir = tempfile::tempdir().unwrap(); + let mut st = figmog::open_store!(dir.path().join("db")); + + let mut flattened = figmog::flatten::flatten_file(&common::fixture_v1()).unwrap(); + flattened + .recs + .extend(parse_variables_export(&export()).unwrap()); + let prior = st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + sync(&mut st, &prior, &flattened, 1_000); + st.rtx(|(_, _, _, _, variables, _, _, _)| { + assert_eq!(variables.iter().count(), 3); + }); + + // Second pull's export drops "VariableID:200" (e.g. deleted upstream). + // Its collection ("VariableCollectionId:2") is left in place so only + // the variable itself is expected to be swept. + let mut export2 = export(); + export2["meta"]["variables"] + .as_object_mut() + .unwrap() + .remove("VariableID:200"); + let mut flattened2 = figmog::flatten::flatten_file(&common::fixture_v1()).unwrap(); + flattened2 + .recs + .extend(parse_variables_export(&export2).unwrap()); + + let prior2 = st.rtx( + |( + (nodes, ..), + components, + component_sets, + styles, + variables, + variable_collections, + _, + _, + )| { + let mut p = collect_sweepable(&nodes, &components, &component_sets, &styles); + p.extend(collect_variable_ids(&variables, &variable_collections)); + p + }, + ); + let churn = sync(&mut st, &prior2, &flattened2, 2_000); + assert_eq!(churn.removed, 1, "the dropped variable must be swept"); + + st.rtx(|(_, _, _, _, variables, collections, _, _)| { + assert!( + variables.get(&"VariableID:200".to_string()).is_none(), + "removed-upstream variable must be gone" + ); + assert!( + variables.get(&"VariableID:100".to_string()).is_some(), + "still-present variables must survive" + ); + assert!( + collections + .get(&"VariableCollectionId:2".to_string()) + .is_some(), + "the collection is still in the export, so it isn't swept" + ); + }); +} + +#[test] +fn imported_variables_survive_pulls_with_no_export() { + use figmog::model::{VariableCollectionRec, VariableRec}; + + let dir = tempfile::tempdir().unwrap(); + let mut st = figmog::open_store!(dir.path().join("db")); + + let flattened = figmog::flatten::flatten_file(&common::fixture_v1()).unwrap(); + sync(&mut st, &BTreeSet::new(), &flattened, 1_000); + + // A variable landed in the store some other way (manual `import-variables`, + // or an earlier Enterprise-synced pull) — same shape as + // `sync.rs::sweep_never_touches_variables`. + st.wtx(|tx| { + tx.upsert( + &Id::Variable("VariableID:100".into()), + &Rec::Variable(VariableRec { + id: "VariableID:100".into(), + name: "color/bg".into(), + resolved_type: "COLOR".into(), + collection_id: "VC:1".into(), + values_by_mode: vec![("M:1".into(), "{\"r\":0.06}".into())], + description: String::new(), + scopes: vec![], + }), + ); + tx.upsert( + &Id::VariableCollection("VC:1".into()), + &Rec::VariableCollection(VariableCollectionRec { + id: "VC:1".into(), + name: "core".into(), + modes: vec![("M:1".into(), "light".into())], + default_mode_id: "M:1".into(), + }), + ); + }); + + // Next pull is the `variables_local` `Ok(None)` path: no export recs + // folded into `flattened`, and — critically — the prior set is built + // WITHOUT `collect_variable_ids` (exactly what `do_pull` does when + // `vars_resp` is `None`). + let flattened2 = figmog::flatten::flatten_file(&common::fixture_v1()).unwrap(); + let prior = st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + let churn = sync(&mut st, &prior, &flattened2, 2_000); + assert_eq!(churn.removed, 0, "no export means nothing to sweep"); + + st.rtx(|(_, _, _, _, variables, collections, _, _)| { + assert!( + variables.get(&"VariableID:100".to_string()).is_some(), + "v1 behavior intact: imported variables survive a pull with no Enterprise export" + ); + assert!(collections.get(&"VC:1".to_string()).is_some()); + }); +} From a3120cc18627bc595a9ccc03433d692f3ce75c69 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 01:17:57 -0700 Subject: [PATCH 36/56] fix(figmog): final serve review fixes (lock handling, protocol header, cache collision guard) I-1: translate the fold locked-store panic (a CLI command opening a store `figmog serve`/`figmog watch` already holds) into a clean exit-1 error at every CLI store-opening call site, via a catch_unwind wrapper that only translates the lock case and re-raises any other panic unchanged; document the single-writer constraint in the README. I-2: capture the negotiated protocolVersion from the upstream's initialize response and send it as MCP-Protocol-Version on every later request, per the streamable-HTTP transport spec this client declares. I-3: cache::lookup now verifies the stored row's tool and args_canonical against the request before serving a hit, closing the FNV-64 key-collision gap where one tool's cached response could be served for another. Also documents that `figmog tools`/`figmog call` need a resolved mirror (--db or a prior pull). Co-Authored-By: Claude Fable 5 --- examples/figmog/README.md | 16 +++++ examples/figmog/src/cache.rs | 77 +++++++++++++++++++++-- examples/figmog/src/cli.rs | 108 +++++++++++++++++++++++++++++--- examples/figmog/src/serve.rs | 2 +- examples/figmog/src/upstream.rs | 41 +++++++++++- examples/figmog/tests/serve.rs | 54 ++++++++++++++++ 6 files changed, 282 insertions(+), 16 deletions(-) diff --git a/examples/figmog/README.md b/examples/figmog/README.md index 39f9d11..a71ee11 100644 --- a/examples/figmog/README.md +++ b/examples/figmog/README.md @@ -99,6 +99,16 @@ $ claude mcp add figmog -- /absolute/path/to/clog/target/debug/figmog serve --db `--interval N` (default 10s) controls the poll cadence, same as `watch`. +**Single-writer constraint:** because fjall allows only one open handle per +store, `figmog serve` (like `figmog watch`) holds an exclusive lock on its +`--db` for as long as it runs. A CLI read against the *same* store while +`serve` is up — `figmog status`, `figmog search`, `figmog call +figmog_status`, and any other command that opens the store — fails fast +with a clean `store is locked` error rather than a raw panic; drive the +running server through its own MCP tool calls instead, or stop `serve` +first. (`figmog tools` never opens the store, so it works fine even while +`serve` is running.) + ### The cached proxy Proxying targets **paid Dev/Full seats**: it requires the Figma desktop @@ -155,6 +165,12 @@ invocation (no persistent connection between CLI calls). There are deliberately no bespoke subcommands for upstream tools — Figma's tool list churns; `figmog call` is the stable, generic surface. +`figmog tools` and `figmog call` both require a resolved mirror — an +established `.figmog/current` (from a prior `pull`) or an explicit `--db +` — even though `figmog tools` itself never reads the store; with +neither, both exit 1 with `no mirror here — run figmog pull +first`. + ### Core read tools Each mirrors a CLI read command one-to-one and answers instantly from the diff --git a/examples/figmog/src/cache.rs b/examples/figmog/src/cache.rs index 1b7d370..74fdbdc 100644 --- a/examples/figmog/src/cache.rs +++ b/examples/figmog/src/cache.rs @@ -35,10 +35,14 @@ pub fn cache_key(tool: &str, args_canonical: &str) -> String { format!("{hash:016x}") } -/// Look up a cached response. A hit requires the stored row's -/// `file_version` to equal `current_version`; a miss (absent or stale) -/// returns `None` without evicting anything — eviction is a separate, -/// explicit step (see `store::evict_stale_cache`). +/// Look up a cached response. A hit requires the stored row's `tool` and +/// `args_canonical` to equal the request's (I-3: FNV-1a 64 is +/// non-cryptographic and trivially collidable, and tool arguments are +/// agent-authored strings — without this check, a colliding key could +/// silently serve one tool's cached response for a different tool/args +/// pair) and its `file_version` to equal `current_version`; a miss (absent, +/// collided, or stale) returns `None` without evicting anything — eviction +/// is a separate, explicit step (see `store::evict_stale_cache`). pub fn lookup( cache: &TableReader<'_, R, String, ProxyCacheRec>, tool: &str, @@ -46,6 +50,9 @@ pub fn lookup( current_version: &str, ) -> Option { let rec = cache.get(&cache_key(tool, args_canonical))?; + if rec.tool != tool || rec.args_canonical != args_canonical { + return None; + } if rec.file_version != current_version { return None; } @@ -98,4 +105,66 @@ mod tests { cache_key("get_variable_defs", "{\"nodeId\":\"1:2\"}") ); } + + /// I-3: `lookup` must not trust the key hash alone. Construct a row + /// directly under the exact key `lookup("get_code", ...)` will compute, + /// but tagged with a *different* tool (as a real FNV-64 collision + /// would look, without needing to actually find one) — the row must be + /// treated as a miss, not served as `get_code`'s cached response. + #[test] + fn lookup_misses_on_collision_where_key_matches_but_tool_and_args_dont() { + let dir = tempfile::tempdir().unwrap(); + let mut st = crate::open_store!(dir.path().join("db")); + + let requested_tool = "get_code"; + let requested_args = "{\"nodeId\":\"1:2\"}"; + let key = cache_key(requested_tool, requested_args); + + // A row that collides on `key_hash` but actually belongs to a + // different (tool, args) pair — exactly what a deliberate FNV-64 + // collision would produce. + let colliding_rec = ProxyCacheRec { + key_hash: key.clone(), + tool: "get_variable_defs".to_string(), + args_canonical: "{\"nodeId\":\"9:9\"}".to_string(), + file_version: "100".to_string(), + content: serde_json::to_string(&Value::String("wrong tool's data".into())).unwrap(), + }; + st.wtx(|tx| { + tx.upsert( + &Id::ProxyCache(key.clone()), + &Rec::ProxyCache(colliding_rec), + ); + }); + + let hit = st.rtx(|(_, _, _, _, _, _, _, cache)| { + lookup(&cache, requested_tool, requested_args, "100") + }); + assert_eq!(hit, None, "a key-hash collision must never be served"); + } + + /// Sanity complement to the collision test: a row that *does* match + /// `tool`/`args_canonical` at the same key is still a hit. + #[test] + fn lookup_hits_when_tool_and_args_match() { + let dir = tempfile::tempdir().unwrap(); + let mut st = crate::open_store!(dir.path().join("db")); + + let tool = "get_code"; + let args = "{\"nodeId\":\"1:2\"}"; + let key = cache_key(tool, args); + let rec = ProxyCacheRec { + key_hash: key.clone(), + tool: tool.to_string(), + args_canonical: args.to_string(), + file_version: "100".to_string(), + content: serde_json::to_string(&Value::String("real data".into())).unwrap(), + }; + st.wtx(|tx| { + tx.upsert(&Id::ProxyCache(key.clone()), &Rec::ProxyCache(rec)); + }); + + let hit = st.rtx(|(_, _, _, _, _, _, _, cache)| lookup(&cache, tool, args, "100")); + assert_eq!(hit, Some(Value::String("real data".into()))); + } } diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 6e3a870..3a60f20 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -230,7 +230,7 @@ fn dispatch(cli: Cli) -> Result<(), String> { // generic over `P: Push<..>` — `P::Reader<'tx, R>` would be an // opaque associated type there, and a tuple pattern can't // destructure an unconstrained associated type. - let st = crate::open_store!(&db.path); + let st = open_store_checked(|| crate::open_store!(&db.path))?; let json = cli.json; match other { Cmd::Status => st.rtx(|((nodes, _, _, _, _, _, _), _, _, _, _, _, meta, _)| { @@ -404,6 +404,49 @@ pub(crate) fn now_ms() -> u64 { .as_millis() as u64 } +/// The clean, user-facing error every CLI store-opening call site below +/// translates a locked-store panic into (I-1). `figmog serve`/`figmog +/// watch` hold fjall's single-writer lock for the life of the process — a +/// CLI command opening the same `--db` concurrently must not surface fold's +/// raw `unwrap()` panic (exit 101). +const STORE_LOCKED_MSG: &str = "store is locked — is `figmog serve` running? Query the server instead (figmog call/tools), or stop it first."; + +/// `open_store!` (via `fold::stream::Stream::new`) panics rather than +/// returning a `Result` when the underlying store can't be opened — most +/// commonly because another process (`figmog serve` or `figmog watch`) +/// already holds fjall's single-writer lock (`fjall::Error::Locked`). fold +/// itself stays untouched (its panic-on-open contract is intentional and +/// shared by `wtx`'s own rollback-on-panic path); this wrapper is figmog's +/// layer, catching that one specific panic and translating it into a clean +/// exit-1 error instead. Any *other* panic (a genuine bug — not lock +/// contention) is re-raised unchanged so it isn't silently swallowed. +/// +/// The default panic hook is suppressed for the duration of the call so a +/// caught, translated panic doesn't also print Rust's raw "thread 'main' +/// panicked at ..." line to stderr (stderr purity: only figmog's own +/// `figmog: ` line should appear). +pub(crate) fn open_store_checked( + open: impl FnOnce() -> T + std::panic::UnwindSafe, +) -> Result { + let prev_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let result = std::panic::catch_unwind(open); + std::panic::set_hook(prev_hook); + + result.map_err(|payload| { + let msg = payload + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_default(); + if msg.contains("Locked") { + STORE_LOCKED_MSG.to_string() + } else { + std::panic::resume_unwind(payload) + } + }) +} + // ---- engine commands ---- /// Errors from [`do_pull`]: either a typed API failure (so callers can act @@ -493,7 +536,7 @@ pub(crate) fn do_pull( let mut flattened = flatten_file(&resp).map_err(|e| e.to_string())?; - let mut st = crate::open_store!(&db.path); + let mut st = open_store_checked(|| crate::open_store!(&db.path))?; let mut prior: BTreeSet = st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { collect_sweepable(&nodes, &components, &component_sets, &styles) @@ -563,11 +606,11 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result .map_err(|_| "FIGMA_TOKEN not set — required for watch".to_string())?; let api = UreqApi::new(token); - if read_watermark(db).is_none() { + if read_watermark(db)?.is_none() { cmd_pull(db, Some(key.clone()), None, false, json)?; } - let mut stored = read_watermark(db); + let mut stored = read_watermark(db)?; let mut watcher = Watcher::new(stored.clone()); let interval = Duration::from_secs(interval); // Backoff for Tier-1 pull failures, independent of the Watcher's own @@ -596,7 +639,7 @@ fn cmd_watch(db: &Db, file: Option, interval: u64, json: bool) -> Result } match do_pull(db, Some(key.clone()), None, false) { Ok((churn, name, version)) => { - stored = read_watermark(db); + stored = read_watermark(db)?; pull_backoff = BACKOFF_START; if json { let mut v = serde_json::to_value(&churn).unwrap_or_default(); @@ -657,7 +700,7 @@ fn cmd_import_variables(db: &Db, path: PathBuf, json: bool) -> Result<(), String serde_json::from_str(&content).map_err(|e| format!("parsing {}: {e}", path.display()))?; let recs = crate::vars::parse_variables_export(&v).map_err(|e| e.to_string())?; - let mut st = crate::open_store!(&db.path); + let mut st = open_store_checked(|| crate::open_store!(&db.path))?; st.wtx(|tx| { for (id, rec) in &recs { tx.upsert(id, rec); @@ -679,9 +722,9 @@ fn cmd_import_variables(db: &Db, path: PathBuf, json: bool) -> Result<(), String Ok(()) } -pub(crate) fn read_watermark(db: &Db) -> Option { - let st = crate::open_store!(&db.path); - st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)) +pub(crate) fn read_watermark(db: &Db) -> Result, String> { + let st = open_store_checked(|| crate::open_store!(&db.path))?; + Ok(st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified))) } // ---- cached-proxy CLI parity: `figmog tools` / `figmog call` ---- @@ -789,7 +832,7 @@ fn cmd_call( } let (mut upstream, upstream_status) = attach_upstream(upstream_url, no_upstream); - let mut st = crate::open_store!(&db.path); + let mut st = open_store_checked(|| crate::open_store!(&db.path))?; let result: Result = if proxy::is_local_tool(&tool) { match st.rtx(|r| dispatch::dispatch_read_tool(&tool, &args, upstream_status, r)) { @@ -1259,6 +1302,51 @@ fn cmd_at( mod tests { use super::*; + /// I-1: a store opened a second time in-process while the first handle + /// is still held reproduces the exact panic a CLI command hits against + /// a running `figmog serve`/`figmog watch` (fjall's file lock conflicts + /// on the second `File::try_lock`, regardless of whether the two opens + /// are in the same process or different ones — see + /// `fjall::locked_file::LockedFileGuard`). `open_store_checked` must + /// translate that panic into the clean, exit-1-friendly message instead + /// of letting it propagate as a raw panic. + #[test] + fn open_store_checked_translates_locked_store_panic_to_clean_error() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("db"); + + // Hold the first handle open, exactly like `figmog serve` does for + // the life of its process. + let _held = crate::open_store!(&db_path); + + let result = open_store_checked(|| crate::open_store!(&db_path)); + assert_eq!(result.err().as_deref(), Some(STORE_LOCKED_MSG)); + } + + /// The happy path: no contention, no panic, the store opens normally. + #[test] + fn open_store_checked_passes_through_a_successful_open() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("db"); + let result = open_store_checked(|| crate::open_store!(&db_path)); + assert!(result.is_ok()); + } + + /// Only the locked-store panic is translated; any other panic (a real + /// bug, not lock contention) must propagate unchanged rather than being + /// silently reworded into the locked-store message. + #[test] + fn open_store_checked_reraises_non_lock_panics_unchanged() { + let outcome = std::panic::catch_unwind(|| open_store_checked(|| -> () { panic!("boom") })); + let payload = outcome.expect_err("non-lock panics must still panic"); + let msg = payload + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_default(); + assert_eq!(msg, "boom"); + } + #[test] fn rate_limited_waits_max_of_interval_and_retry_after() { let mut backoff = BACKOFF_START; diff --git a/examples/figmog/src/serve.rs b/examples/figmog/src/serve.rs index 8e81c45..084a36a 100644 --- a/examples/figmog/src/serve.rs +++ b/examples/figmog/src/serve.rs @@ -90,7 +90,7 @@ pub(crate) fn run_serve( .ok_or_else(|| "no file key: pass a file key or figma.com URL".to_string())?; let token = std::env::var("FIGMA_TOKEN") .map_err(|_| "FIGMA_TOKEN not set — required for watch".to_string())?; - if read_watermark(db).is_none() { + if read_watermark(db)?.is_none() { do_pull(db, Some(resolved), None, false).map_err(|e| e.to_string())?; } Some(UreqApi::new(token)) diff --git a/examples/figmog/src/upstream.rs b/examples/figmog/src/upstream.rs index 4d8c475..23d7727 100644 --- a/examples/figmog/src/upstream.rs +++ b/examples/figmog/src/upstream.rs @@ -55,6 +55,12 @@ pub struct HttpUpstream { url: String, agent: ureq::Agent, session_id: Option, + /// The `protocolVersion` the upstream's `initialize` response actually + /// negotiated (which may differ from [`PROTOCOL_VERSION`] if the + /// upstream negotiates down). `None` until `initialize` succeeds; once + /// set, sent as `MCP-Protocol-Version` on every later request per the + /// 2025-06-18 streamable-HTTP transport spec. + protocol_version: Option, next_id: u64, tools: Vec, } @@ -71,6 +77,7 @@ impl HttpUpstream { url, agent, session_id: None, + protocol_version: None, next_id: 1, tools: Vec::new(), } @@ -112,6 +119,14 @@ impl HttpUpstream { if let Some(session_id) = &self.session_id { req = req.set("Mcp-Session-Id", session_id); } + // Per the 2025-06-18 streamable-HTTP transport spec (the version + // this client declares, `PROTOCOL_VERSION`), every request after a + // successful `initialize` must carry the negotiated protocol + // version; servers may reject requests without it (I-2). Absent + // before `initialize` completes — there's nothing negotiated yet. + if let Some(protocol_version) = &self.protocol_version { + req = req.set("MCP-Protocol-Version", protocol_version); + } let resp = match req.send_json(body.clone()) { Ok(resp) => resp, Err(ureq::Error::Status(_, resp)) => resp, @@ -144,7 +159,13 @@ impl UpstreamMcp for HttpUpstream { }, }); let resp = self.send_request(&init_req)?; - extract_result(resp)?; + let result = extract_result(resp)?; + // Capture the negotiated version so every request from here on + // (including the `notifications/initialized` below) carries it. + self.protocol_version = result + .get("protocolVersion") + .and_then(Value::as_str) + .map(str::to_string); self.send_notification(&json!({ "jsonrpc": "2.0", @@ -622,6 +643,24 @@ mod tests { "expected Mcp-Session-Id header, got: {req}" ); } + + // I-2: the initialize request predates any negotiated protocol + // version, so it must not carry the header yet; every request from + // request 2 onward (notifications/initialized, tools/list, + // tools/call) must carry the version the fake server's initialize + // response negotiated ("2025-06-18"). + assert!( + !reqs[0] + .to_ascii_lowercase() + .contains("mcp-protocol-version") + ); + for req in &reqs[1..] { + assert!( + req.to_ascii_lowercase() + .contains("mcp-protocol-version: 2025-06-18"), + "expected MCP-Protocol-Version header, got: {req}" + ); + } } #[test] diff --git a/examples/figmog/tests/serve.rs b/examples/figmog/tests/serve.rs index b6a0233..a3aa2e6 100644 --- a/examples/figmog/tests/serve.rs +++ b/examples/figmog/tests/serve.rs @@ -255,6 +255,60 @@ fn serve_e2e_initialize_tools_list_and_tool_calls() { assert!(status.success(), "figmog serve exited with {status:?}"); } +/// I-1: while `figmog serve` holds the store's single-writer lock, a CLI +/// command opened against the same `--db` must fail with a clean, exit-1 +/// error — never fold's raw `unwrap()` panic (exit 101). Reproduces the +/// review's live repro (serve holding a fixture store, `figmog status +/// --db ` in a second process) as an automated test. +#[test] +fn cli_read_against_a_store_serve_holds_fails_clean_not_with_a_panic() { + let (_dir, db) = common::fixture_db(); + let (mut guard, mut stdin, rx) = spawn_serve(&db); + + // Complete the handshake before touching the store from a second + // process: `run_serve` opens the store synchronously, before it can + // ever respond to `initialize` (see serve.rs), so a response here + // proves the lock is already held. + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }), + ); + let resp = recv(&rx); + assert_eq!(resp["result"]["serverInfo"]["name"], json!("figmog")); + + let out = assert_cmd::Command::cargo_bin("figmog") + .unwrap() + .args(["status", "--db"]) + .arg(&db) + .assert() + .failure(); + let output = out.get_output(); + assert_eq!( + output.status.code(), + Some(1), + "expected a clean exit-1, not fold's raw panic exit (101); stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("store is locked"), + "expected the locked-store message, got: {stderr}" + ); + assert!( + !stderr.contains("panicked"), + "stderr must stay clean of the raw panic message: {stderr}" + ); + + drop(stdin); + let status = wait_with_timeout(&mut guard.0, TIMEOUT); + assert!(status.success(), "figmog serve exited with {status:?}"); +} + // ---- cached-proxy e2e: figmog serve against an in-process HTTP fake ---- // // Minimal hand-rolled HTTP/1.1 server (std `TcpListener`, no new deps) that From cff473cd6e7bc380a0b79fc03ec6e1666ef380f0 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 01:25:28 -0700 Subject: [PATCH 37/56] fix(figmog): honest panic passthrough and serve-open lock handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open_store_checked no longer swaps out the global panic hook: doing so silenced non-lock panics (corrupt store, disk error) entirely, turning a genuine bug into a silent exit 101 with zero stderr output, and mutating a process-global hook around one call was racy against other threads besides. The default hook now stays active throughout — a lock panic still prints fold's raw trace before the friendly STORE_LOCKED_MSG follows, and a non-lock panic prints and propagates exactly as it would with no wrapper at all. Also route run_serve's own long-lived store open through open_store_checked, closing the serve-vs-serve / serve-vs-watch double-owner case the CLI-read fix didn't cover: starting a second figmog serve or figmog watch against an already-owned store now gets the same clean locked-store error instead of a raw panic. Co-Authored-By: Claude Fable 5 --- examples/figmog/README.md | 4 +++- examples/figmog/src/cli.rs | 23 ++++++++++++----------- examples/figmog/src/serve.rs | 8 ++++++-- examples/figmog/tests/serve.rs | 19 +++++++++++-------- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/examples/figmog/README.md b/examples/figmog/README.md index a71ee11..554472d 100644 --- a/examples/figmog/README.md +++ b/examples/figmog/README.md @@ -107,7 +107,9 @@ figmog_status`, and any other command that opens the store — fails fast with a clean `store is locked` error rather than a raw panic; drive the running server through its own MCP tool calls instead, or stop `serve` first. (`figmog tools` never opens the store, so it works fine even while -`serve` is running.) +`serve` is running.) The same applies to `serve`/`watch` itself: starting +a second `figmog serve` or `figmog watch` against a store one of them +already owns fails with the same clean message rather than a raw panic. ### The cached proxy diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 3a60f20..a0c1930 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -419,21 +419,22 @@ const STORE_LOCKED_MSG: &str = "store is locked — is `figmog serve` running? Q /// shared by `wtx`'s own rollback-on-panic path); this wrapper is figmog's /// layer, catching that one specific panic and translating it into a clean /// exit-1 error instead. Any *other* panic (a genuine bug — not lock -/// contention) is re-raised unchanged so it isn't silently swallowed. +/// contention) is re-raised unchanged via `resume_unwind` so it isn't +/// silently swallowed. /// -/// The default panic hook is suppressed for the duration of the call so a -/// caught, translated panic doesn't also print Rust's raw "thread 'main' -/// panicked at ..." line to stderr (stderr purity: only figmog's own -/// `figmog: ` line should appear). +/// Deliberately does **not** touch the global panic hook: swapping it out +/// for the call's duration would suppress *every* panic's trace, including +/// non-lock ones that get re-raised — turning a genuine bug (corrupt +/// store, disk error) into a silent exit 101 with no stderr output at all, +/// which is worse than not catching anything. The default hook stays +/// active throughout, so a lock panic still prints fold's raw trace before +/// this function's friendly `STORE_LOCKED_MSG` follows (slightly noisy, +/// but honest); swapping a process-global hook around a call is also +/// inherently racy against other threads, which this avoids entirely. pub(crate) fn open_store_checked( open: impl FnOnce() -> T + std::panic::UnwindSafe, ) -> Result { - let prev_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(|_| {})); - let result = std::panic::catch_unwind(open); - std::panic::set_hook(prev_hook); - - result.map_err(|payload| { + std::panic::catch_unwind(open).map_err(|payload| { let msg = payload .downcast_ref::<&str>() .map(|s| s.to_string()) diff --git a/examples/figmog/src/serve.rs b/examples/figmog/src/serve.rs index 084a36a..7e19db9 100644 --- a/examples/figmog/src/serve.rs +++ b/examples/figmog/src/serve.rs @@ -42,7 +42,8 @@ use serde_json::Value; use crate::api::{FigmaApi, UreqApi}; use crate::cli::{ - Db, PullError, do_pull, now_ms, pull_failure_wait, read_watermark, write_current, + Db, PullError, do_pull, now_ms, open_store_checked, pull_failure_wait, read_watermark, + write_current, }; use crate::dispatch; use crate::flatten::flatten_file; @@ -151,7 +152,10 @@ pub(crate) fn run_serve( } }); - let mut st = crate::open_store!(&db.path); + // I-1: a second `figmog serve`/`figmog watch` against the same store + // hits the same fold panic-on-open a CLI read does — translate it the + // same way rather than letting the raw panic surface here. + let mut st = open_store_checked(|| crate::open_store!(&db.path))?; let mut stored: Option = st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)); let mut watcher = Watcher::new(stored.clone()); diff --git a/examples/figmog/tests/serve.rs b/examples/figmog/tests/serve.rs index a3aa2e6..3290c25 100644 --- a/examples/figmog/tests/serve.rs +++ b/examples/figmog/tests/serve.rs @@ -256,10 +256,17 @@ fn serve_e2e_initialize_tools_list_and_tool_calls() { } /// I-1: while `figmog serve` holds the store's single-writer lock, a CLI -/// command opened against the same `--db` must fail with a clean, exit-1 -/// error — never fold's raw `unwrap()` panic (exit 101). Reproduces the -/// review's live repro (serve holding a fixture store, `figmog status -/// --db ` in a second process) as an automated test. +/// command opened against the same `--db` must still exit 1 with figmog's +/// friendly locked-store message on stderr — never fold's raw `unwrap()` +/// panic *exit code* (101). Reproduces the review's live repro (serve +/// holding a fixture store, `figmog status --db ` in a second +/// process) as an automated test. +/// +/// `open_store_checked` deliberately leaves the default panic hook active +/// (see its doc comment in `cli.rs`), so fold's raw trace may legitimately +/// appear on stderr *before* the friendly line — this only asserts the +/// friendly message is present and the exit code is the clean 1, not that +/// stderr is free of the word "panicked". #[test] fn cli_read_against_a_store_serve_holds_fails_clean_not_with_a_panic() { let (_dir, db) = common::fixture_db(); @@ -299,10 +306,6 @@ fn cli_read_against_a_store_serve_holds_fails_clean_not_with_a_panic() { stderr.contains("store is locked"), "expected the locked-store message, got: {stderr}" ); - assert!( - !stderr.contains("panicked"), - "stderr must stay clean of the raw panic message: {stderr}" - ); drop(stdin); let status = wait_with_timeout(&mut guard.0, TIMEOUT); From 33b3bb77c3bae7df03dae826a26b487817d4a71c Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 17:16:28 -0700 Subject: [PATCH 38/56] =?UTF-8?q?spec+plan(figmog):=20figmog=20bench=20loa?= =?UTF-8?q?d-test=20demo=20(=C2=A713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-16-figmog-bench.md | 42 ++++++++++++++ .../specs/2026-08-15-figmog-build-design.md | 57 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-16-figmog-bench.md diff --git a/docs/superpowers/plans/2026-08-16-figmog-bench.md b/docs/superpowers/plans/2026-08-16-figmog-bench.md new file mode 100644 index 0000000..4a7f086 --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-figmog-bench.md @@ -0,0 +1,42 @@ +# figmog bench Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox syntax. + +**Goal:** `figmog bench` — the self-contained load-test demo of spec §13: synthetic corpus → timed cold sync → timed zero-churn re-pull → MCP serve load over real stdio → percentile report. + +**Architecture:** Spec §13 of `docs/superpowers/specs/2026-08-15-figmog-build-design.md` is the binding authority — read it first. One new module `src/bench.rs` (corpus generator + phase runners + report types), one CLI subcommand wiring, README demo section. + +**Tech Stack:** No new dependencies. Seeded LCG for determinism; `std::process` + `current_exe()` for the serve phase; `Instant` timing; sort-based percentiles. + +**Spec:** docs/superpowers/specs/2026-08-15-figmog-build-design.md §13 + +## Global Constraints + +- Zero new crates. Determinism: same `--nodes` → byte-identical corpus JSON (unit-tested). No wall-clock/randomness in the generator (LCG seed is a constant). +- Stdout purity: human table OR `--json` object, never both; diagnostics to stderr. Exit nonzero on any phase failure or any `isError` tool result. +- Temp dir cleaned unless `--keep` (which prints the path). Bench never touches the network. +- Existing 112 tests stay green unchanged. Gates: `cargo test -p figmog`, `cargo clippy -p figmog --no-deps -- -D warnings`, `cargo fmt -p figmog --check`. +- Commits end with the `Co-Authored-By: Claude Fable 5 ` trailer. + +--- + +### Task 1: the whole bench (module + CLI + tests + README) + +**Files:** +- Create: `examples/figmog/src/bench.rs`; Modify: `src/lib.rs` (`pub mod bench;`), `src/cli.rs` (Bench subcommand + dispatch), `examples/figmog/README.md` (Demo section), workspace `README.md` (bullet mentions the bench) +- Test: unit tests in `bench.rs` + one e2e smoke in `tests/cli.rs` + +**Interfaces:** +- `bench::generate_corpus(nodes: usize) -> serde_json::Value` — a full GET-file-shaped response. Structure: ~1 CANVAS page per 250 nodes; per page: auto-layout FRAMEs each containing TEXT children (characters = 4-8 words from a fixed 64-word const list, LCG-picked); one COMPONENT_SET ("Button", 2 variant COMPONENT children with `componentPropertyDefinitions`) on the first page; every ~20th frame child is an INSTANCE with `componentId` pointing at a variant and `componentProperties`; two styles (S:1 FILL, S:2 TEXT) referenced by ~each frame/text via `styles`; every ~10th frame carries a `boundVariables` fill binding to one of 3 variable ids; every node has `absoluteBoundingBox` laid out on a grid. Node ids `"p:i"` scheme, name pool from the word list. Exact node count == `nodes` (count nodes as you emit; stop precisely — unit-tested). +- `bench::run(opts: BenchOpts) -> Result` where `BenchOpts { nodes, calls, keep, exe: PathBuf }` and `BenchReport` (Serialize) carries: corpus {nodes, bytes, gen_ms}, cold {flatten_ms, sync_ms, records, records_per_s}, repull {ms, churn_zero: bool}, load {per_tool: Vec, total_calls, wall_s, req_per_s}. Phases per spec §13: cold sync + re-pull via the LIBRARY (flatten_file + open_store! + store::sync at a concrete site in bench.rs — same pattern as cli.rs); serve load by spawning `opts.exe` with `["serve","--no-upstream","--no-watch","--db",…]`, doing initialize + notifications/initialized, then M rotating tools/call frames (mix per spec: search w/ rotating words, node w/ LCG-picked real ids, where /layoutMode==VERTICAL, stats, tree depth 2, instances "Button"), timing write→response-line with Instant. Kill child via guard on drop; close stdin at end for clean exit. +- CLI: `Bench { #[arg(long, default_value="10000")] nodes: usize, #[arg(long, default_value="5000")] calls: usize, #[arg(long)] keep: bool }` — note bench does NOT need a resolved db/mirror: handle it BEFORE resolve_db in dispatch (like nothing else needs the Db) — `exe` = `std::env::current_exe()`. `--json` global flag prints `serde_json::to_string_pretty(&report)`; human mode prints the phase lines + per-tool table + headline: `figmog served {total} queries in {wall:.1}s ({req_per_s:.0} req/s). Figma's Tier-1 API budget on a free plan: ~10 file requests per MINUTE.` +- Percentiles: sort the per-tool Vec; pN = v[((n-1) * N / 100)] (document the convention). + +- [ ] **Step 1 (TDD, generator):** unit tests first: exact node count for 100/1000/10007 (odd number); byte-identical JSON across two calls; contains ≥1 COMPONENT_SET, ≥1 INSTANCE, TEXT nodes with non-empty characters; flatten_file succeeds on it and yields exactly `nodes` node records. Run RED, implement generator, GREEN. +- [ ] **Step 2 (phases):** implement cold-sync/re-pull phases (assert churn zero in-code, else Err) and the serve-load phase + report assembly. Unit-test percentile math on a known vector. +- [ ] **Step 3 (wiring + e2e):** CLI subcommand + dispatch (before resolve_db); e2e smoke in tests/cli.rs: `figmog bench --nodes 300 --calls 60 --json` → exit 0, stdout parses as JSON, `repull.churn_zero == true`, `load.total_calls == 60`, every per-tool p50 ≥ 0. Keep it <30s in CI (300 nodes is plenty). +- [ ] **Step 4 (docs):** README "Demo: load-testing the server" section — the one command (`cargo run --release -p figmog -- bench`), what it does (4 phases), sample output block (from a real run on this machine, dev profile is fine — note profile), the headline framing. Workspace README figmog bullet gains "with a built-in load-test demo (`figmog bench`)". +- [ ] **Step 5:** full gates, commit `feat(figmog): figmog bench — self-contained load-test demo` + trailer. + +## Self-review checklist +- Spec §13 coverage: corpus determinism → Step 1; phases/report/headline → Steps 2-4; constraints (no deps, stdout purity, cleanup, nonzero exit) → all steps; non-goals respected (sequential single pipe; no proxy benchmarking). diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index 545c40c..f992583 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -693,3 +693,60 @@ holds unchanged. Proxying the remote server (OAuth); mid-session upstream re-attach / `listChanged` notifications; caching selection-based calls; multi-file. + +## 13. `figmog bench` — the load-test demo + +One self-contained command that makes the value proposition measurable: +local reads at memory speed against a rate-limited API that allows ~10 +file requests per minute. + +`figmog bench [--nodes N] [--calls M] [--json] [--keep]` +(defaults: N=10000, M=5000; `--keep` leaves the temp store on disk and +prints its path). + +### Phases (all timed, all reported) + +1. **Corpus** — generate a deterministic synthetic Figma file JSON with N + nodes: pages of auto-layout frames, TEXT nodes whose characters are + drawn from a fixed word list (so BM25 has real queries), one + COMPONENT_SET with variants plus INSTANCE nodes referencing them, + fill/text styles, and `boundVariables` bindings. Determinism: a seeded + LCG in the generator, no wall-clock, no `rand` dep — the same `--nodes` + always yields byte-identical JSON. Generator lives in the crate + (`src/bench.rs` or a `corpus` module) and is unit-testable + (node count exact, determinism byte-checked). +2. **Cold sync** — flatten + `sync` the corpus into a temp store via the + library (not a child process): report flatten ms, sync ms, records/s. +3. **No-churn re-pull** — sync the identical corpus again: report ms and + assert-in-code churn is zero (the engine's headline invariant, timed). +4. **Serve load** — spawn `current_exe()` as + `serve --no-upstream --no-watch --db `, complete the MCP + handshake, then issue M tools/call frames in a fixed rotating mix + (`figmog_search` with rotating corpus words, `figmog_node`, + `figmog_where`, `figmog_stats`, `figmog_tree` (depth 2), + `figmog_instances`), measuring wall time per request (write→response + line). Sequential over one stdio pipe — that matches the server's + single-threaded loop, so the numbers are honest. + +### Report + +Per-tool table: calls, p50 / p95 / p99 / max (ms), plus overall +sustained req/s and total wall time. `--json` emits one JSON object with +the same fields (stdout purity as elsewhere: human table OR json, never +both). Ends with the headline line comparing sustained req/s against +Figma's Tier-1 budget ("~10 file requests/min on Starter"). + +### Constraints + +No new dependencies. Percentiles via sort. `Instant`-based timing only +(no SystemTime in the measurement path). The bench must not touch the +network (`--no-upstream`, corpus from memory) and must clean up its temp +dir unless `--keep`. Exit nonzero if any phase fails or any tool call +returns `isError`. + +### Non-goals + +Concurrent client simulation (stdio is one pipe; the server is +single-threaded by design); benchmarking the proxy path (network-bound, +not ours to measure); comparing against a live Figma API call (the +rate-limit number is documented, not re-measured). From 04beae75c9acf227597d7ceccce8c863b3dedd91 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 17:17:47 -0700 Subject: [PATCH 39/56] spec(figmog): bench real-file mode + Figma API comparison phase Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-15-figmog-build-design.md | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index f992583..9fff893 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -700,9 +700,32 @@ One self-contained command that makes the value proposition measurable: local reads at memory speed against a rate-limited API that allows ~10 file requests per minute. -`figmog bench [--nodes N] [--calls M] [--json] [--keep]` -(defaults: N=10000, M=5000; `--keep` leaves the temp store on disk and -prints its path). +`figmog bench [FILE] [--nodes N] [--calls M] [--api-calls K] [--skip-api] +[--json] [--keep]` +(defaults: N=10000, M=5000, K=5; `--keep` leaves the temp store on disk +and prints its path). + +**Two sources.** With no `FILE` argument, the corpus is synthetic +(deterministic, phase 1 below). With a Figma URL/key, the corpus is the +real file: fetched once via `FIGMA_TOKEN` (exactly one Tier-1 call — the +no-churn re-pull phase reuses the same in-memory JSON rather than +fetching twice), and the load-test query mix derives its parameters from +the flattened data itself: search words sampled from real layer +names/text, node ids from real ids, the instances target from a real +component name (each falling back gracefully when a category is absent). +The same derivation runs in synthetic mode, so the two modes share one +code path. + +**API comparison phase** (real-file mode only, unless `--skip-api`): +after the serve load, issue K sequential calls to +`GET /v1/files/:key/nodes?ids=` — the native API's closest +equivalent of `figmog_node` — timing each, plus one Tier-3 +`GET /v1/files/:key/meta` for reference. K defaults to 5 because this +spends the user's real Tier-1 budget (~10/min); a 429 is recorded (with +its Retry-After) and ends the phase gracefully, reporting whatever was +measured. The report then shows figmog vs API latency side by side and +computes the budget math: how long the M-call load test would take at +the API's rate limit versus figmog's measured wall time. ### Phases (all timed, all reported) @@ -731,18 +754,24 @@ prints its path). ### Report Per-tool table: calls, p50 / p95 / p99 / max (ms), plus overall -sustained req/s and total wall time. `--json` emits one JSON object with -the same fields (stdout purity as elsewhere: human table OR json, never -both). Ends with the headline line comparing sustained req/s against -Figma's Tier-1 budget ("~10 file requests/min on Starter"). +sustained req/s and total wall time. In real-file mode with the API +phase: an additional side-by-side block — `figmog_node p50` vs +`API /nodes p50`, the speedup factor, and the budget line ("the M-call +load test at ~10 req/min would take ≈X; figmog: Ts"). `--json` emits one +JSON object with the same fields (stdout purity as elsewhere: human +table OR json, never both). Ends with the headline comparison in both +modes. ### Constraints No new dependencies. Percentiles via sort. `Instant`-based timing only -(no SystemTime in the measurement path). The bench must not touch the -network (`--no-upstream`, corpus from memory) and must clean up its temp -dir unless `--keep`. Exit nonzero if any phase fails or any tool call -returns `isError`. +(no SystemTime in the measurement path). Synthetic mode must not touch +the network at all; real-file mode makes exactly 1 Tier-1 file fetch, an +opportunistic variables call, and (unless `--skip-api`) K+1 comparison +calls — the report states every API call it spent. Temp dir cleaned +unless `--keep`. Exit nonzero if any phase fails or any tool call +returns `isError` (a graceful 429 in the comparison phase is a recorded +result, not a failure). ### Non-goals From 6deb76993aa1da65ca956cee04dfac43b4300f9d Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 17:36:09 -0700 Subject: [PATCH 40/56] =?UTF-8?q?feat(figmog):=20figmog=20bench=20?= =?UTF-8?q?=E2=80=94=20self-contained=20load-test=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- README.md | 3 +- examples/figmog/README.md | 79 +++ examples/figmog/src/api.rs | 8 + examples/figmog/src/bench.rs | 1214 ++++++++++++++++++++++++++++++++++ examples/figmog/src/cli.rs | 88 ++- examples/figmog/src/lib.rs | 1 + examples/figmog/tests/cli.rs | 36 + 7 files changed, 1427 insertions(+), 2 deletions(-) create mode 100644 examples/figmog/src/bench.rs diff --git a/README.md b/README.md index 834dfad..0d73609 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ In this directory you'll find a few examples that show bog style databases in va - `search` — text search three ways over one document stream: BM25 keyword search, HNSW semantic search over ese embeddings, and hybrid rank fusion. A good base for agent memory or document search projects. `cargo run -p search` - `figmog` — a local mirror of a Figma file: sync once, then search, walk, and query components/styles/variables with zero API calls, and an MCP server - (`figmog serve`). `cargo run -p figmog -- --help` + (`figmog serve`), with a built-in load-test demo (`figmog bench`). + `cargo run -p figmog -- --help` ## More about Bog Bog is a database runtime that makes every attempt to do as much work as possible as early as possible, to make reads incredibly fast. This means compiling queries into functions that eagerly update their output as mutations occur. diff --git a/examples/figmog/README.md b/examples/figmog/README.md index 554472d..1bf77b5 100644 --- a/examples/figmog/README.md +++ b/examples/figmog/README.md @@ -50,6 +50,7 @@ store location (default `.figmog//db`). | `figmog text [--page ]` | by_type + nodes | every TEXT node's `(id, characters, page_id)`, sorted by id | | `figmog where --pointer

[--equals ] [--page ]` | nodes | nodes whose raw JSON matches an RFC 6901 `pointer`, optionally filtered by `equals` (parsed as JSON, falling back to a bare string so `--equals VERTICAL` works) | | `figmog at --x N --y N` | nodes | nodes whose absolute bounds contain the point, sorted by area ascending (deepest/smallest first) | +| `figmog bench [file] [--nodes N] [--calls M] [--api-calls K] [--skip-api] [--keep]` | — | self-contained load-test demo (see "Demo: load-testing the server" below) — needs no mirror/`--db` | Node ids accept both `12:34` and `12-34` forms everywhere. Auth is a personal access token from `FIGMA_TOKEN`. Since `pull`/`watch` are the only @@ -298,6 +299,84 @@ call's output into it by hand. Figma's *remote* MCP server (as opposed to the local desktop one figmog proxies) caps Starter users at 6 tool calls a *month* and isn't something figmog talks to at all. +## Demo: load-testing the server + +`figmog bench` makes the value proposition measurable without needing a +real Figma file or token — one self-contained command: + +```console +$ cargo run --release -p figmog -- bench +``` + +It runs four phases against a fresh temp store (cleaned up afterward +unless `--keep`), all `Instant`-timed: + +1. **Corpus** — a deterministic synthetic Figma file (`--nodes`, default + 10000): pages of auto-layout frames, TEXT nodes drawn from a fixed + 64-word pool (so BM25 has real queries), a "Button" `COMPONENT_SET` + with variants and INSTANCE nodes referencing them, fill/text styles, + and `boundVariables` bindings. A seeded LCG makes it byte-identical + across runs of the same `--nodes` — no wall-clock, no `rand` dependency. +2. **Cold sync** — `flatten` + `sync` the corpus into the temp store via + the library (not a subprocess). +3. **No-churn re-pull** — `sync` the identical corpus again and assert in + code that churn is zero: the engine's headline invariant, timed. +4. **Serve load** — spawn the real `figmog serve --no-upstream --no-watch` + binary and drive it over its actual stdio pipe with `--calls` (default + 5000) tool calls in a fixed rotating mix (`figmog_search`, + `figmog_node`, `figmog_where`, `figmog_stats`, `figmog_tree`, + `figmog_instances`) — every parameter (search words, node ids, the + instances target) is *derived* from the corpus's own flattened + records, not hardcoded. Sequential over one pipe, matching the + server's real single-threaded loop, so the numbers are honest. + +Sample output (this machine: Apple M4, 16GB, dev profile — `cargo run -p +figmog -- bench --nodes 10000 --calls 5000`; dev profile is `opt-level = +3` in this workspace, so the numbers are respectable even without +`--release`): + +``` +corpus [synthetic] 10000 nodes, 2391449 bytes, 44.7ms +cold sync 37.3ms flatten + 98.9ms sync, 10005 records (101140 records/s) +re-pull 7.0ms, churn zero: true + +tool calls p50 (ms) p95 (ms) p99 (ms) max (ms) +figmog_search 834 0.399 0.531 0.911 3.654 +figmog_node 834 0.043 0.058 0.152 8.374 +figmog_where 833 15.302 17.161 25.223 48.124 +figmog_stats 833 24.089 27.427 47.298 160.400 +figmog_tree 833 3.971 5.014 9.264 20.122 +figmog_instances 833 0.749 0.903 1.647 4.295 + +figmog served 5000 queries in 38.4s (130 req/s). Figma's Tier-1 API budget on a free plan: ~10 file requests per MINUTE. +``` + +(`figmog_node` — an indexed point lookup — is the fastest tool by a wide +margin; `figmog_where`/`figmog_stats` scan every node and are +correspondingly slower, but still complete a 5000-call load test in +under 40 seconds against a 10000-node file. All of it is local: zero +Figma API calls, zero rate-limit exposure.) + +**Against a real file** (`figmog bench `, needs +`FIGMA_TOKEN`): the corpus becomes the real file — fetched once (exactly +one Tier-1 call, plus the same opportunistic Enterprise `variables_local` +call `pull` makes), reused in memory for the re-pull phase (no second +fetch) — and the load test's query mix derives its parameters from +whatever's actually in that file (falling back gracefully, e.g. dropping +`figmog_instances` from the mix with a stderr note if the file has no +components). Unless `--skip-api`, a fifth phase follows the serve load: +`--api-calls` (default 5) sequential `GET /v1/files/:key/nodes?ids=` +calls — Figma's native equivalent of `figmog_node` — timed the same way, +plus one `GET /meta` call for reference, so the report can show +`figmog_node` p50 next to the real API's p50 side by side, with a +speedup factor and the budget math (how long the same `--calls` load +test would take at Figma's ~10 Tier-1 requests/minute). **This spends +real rate-limit budget**: 1 file fetch + 1 opportunistic +`variables_local` + `K` `/nodes` calls + 1 `/meta` call — the report +states every call it made; a 429 mid-phase is recorded (with its +`Retry-After`) and ends the phase gracefully rather than failing the +whole bench. + ## Manual live check Not run in CI (needs a real `FIGMA_TOKEN` and a real file); this is how to diff --git a/examples/figmog/src/api.rs b/examples/figmog/src/api.rs index e533fee..3fb5aa3 100644 --- a/examples/figmog/src/api.rs +++ b/examples/figmog/src/api.rs @@ -107,6 +107,14 @@ impl UreqApi { Err(e) => Err(ApiError::Network(e.to_string())), } } + + /// `GET /v1/files/:key/nodes?ids=` — the native API's closest + /// equivalent of `figmog_node`. Used only by `figmog bench`'s real-file + /// API-comparison phase (build design §13); no other call site needs + /// this endpoint, so it isn't part of the [`FigmaApi`] trait. + pub(crate) fn file_nodes(&self, key: &str, id: &str) -> Result { + self.get_json(&format!("/v1/files/{key}/nodes?ids={id}")) + } } impl FigmaApi for UreqApi { diff --git a/examples/figmog/src/bench.rs b/examples/figmog/src/bench.rs new file mode 100644 index 0000000..11bd1d2 --- /dev/null +++ b/examples/figmog/src/bench.rs @@ -0,0 +1,1214 @@ +//! `figmog bench` — the self-contained load-test demo (build design §13). +//! +//! Four (five, in real-file mode) timed phases against one temp store: +//! +//! 1. **Corpus** — a deterministic synthetic Figma file (seeded LCG, no +//! wall-clock) or, given a file key/URL, one real Tier-1 fetch. +//! 2. **Cold sync** — `flatten_file` + `store::sync` into a fresh store. +//! 3. **No-churn re-pull** — `sync` the identical (in-memory, not +//! re-fetched) data again; the engine's headline invariant, timed and +//! asserted in code. +//! 4. **Serve load** — spawn the real `figmog serve --no-upstream +//! --no-watch` binary and drive it over its real stdio pipe with a +//! fixed rotating tool-call mix, timing write→response-line per call. +//! 5. **API comparison** (real-file mode only, unless `--skip-api`) — a +//! handful of native `GET /nodes` calls timed the same way, so the +//! report can show figmog's local reads next to Figma's rate-limited +//! API side by side. +//! +//! Synthetic and real-file mode share one code path from flatten onward: +//! the load-test's query mix (search words, node ids, the instances +//! target) is always *derived* from the flattened records, never +//! hardcoded — see [`derive_query_pool`]. + +use std::collections::BTreeSet; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::api::{ApiError, FigmaApi, UreqApi}; +use crate::cli::open_store_checked; +use crate::flatten::{Flattened, flatten_file}; +use crate::model::{Id, Rec}; +use crate::store::{self, collect_sweepable}; + +// ---- deterministic LCG ---- + +/// Fixed seed and constants: no wall-clock, no `rand` dep — the same +/// `--nodes` always yields byte-identical corpus JSON (spec §13). +const LCG_SEED: u64 = 0x243F_6A88_85A3_08D3; +const LCG_MUL: u64 = 6364136223846793005; +const LCG_INC: u64 = 1442695040888963407; + +struct Lcg(u64); + +impl Lcg { + fn new(seed: u64) -> Self { + Lcg(seed) + } + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_mul(LCG_MUL).wrapping_add(LCG_INC); + self.0 + } + /// Uniform pick in `0..n`. Panics if `n == 0` (never called that way + /// below — every call site checks non-emptiness first). + fn next_range(&mut self, n: usize) -> usize { + (self.next_u64() % n as u64) as usize + } +} + +/// Fixed 64-word pool: plausible design vocabulary so BM25 search has real +/// queries against the synthetic corpus (spec §13). +const WORDS: [&str; 64] = [ + "Button", + "Label", + "Header", + "Footer", + "Card", + "Icon", + "Input", + "Modal", + "Nav", + "Menu", + "Title", + "Subtitle", + "Body", + "Caption", + "Badge", + "Avatar", + "Toggle", + "Slider", + "Tab", + "Panel", + "Sidebar", + "Toolbar", + "Dialog", + "Tooltip", + "Dropdown", + "Checkbox", + "Radio", + "Switch", + "Field", + "Form", + "Table", + "Row", + "Column", + "Grid", + "List", + "Item", + "Link", + "Divider", + "Spacer", + "Container", + "Wrapper", + "Section", + "Hero", + "Banner", + "Alert", + "Notification", + "Progress", + "Spinner", + "Loader", + "Chip", + "Tag", + "Pill", + "Breadcrumb", + "Pagination", + "Stepper", + "Accordion", + "Carousel", + "Gallery", + "Thumbnail", + "Preview", + "Overlay", + "Backdrop", + "Popover", + "Snackbar", +]; + +/// Grid position -> `absoluteBoundingBox`. Every generated node calls this +/// once, in emission order, so the whole file lays out on a simple grid. +fn grid_bounds(pos: &mut u64) -> Value { + let p = *pos; + *pos += 1; + let col = p % 20; + let row = p / 20; + json!({ + "x": (col * 100) as f64, + "y": (row * 100) as f64, + "width": 90.0, + "height": 90.0, + }) +} + +// ---- corpus generation ---- + +/// Generate a deterministic synthetic `GET /v1/files/:key`-shaped response +/// with exactly `nodes` flattened node records: pages of auto-layout +/// frames (~1 page per 250 nodes), TEXT children drawn from [`WORDS`], one +/// "Button" `COMPONENT_SET` (two variants) on the first page, INSTANCE +/// nodes referencing it (every ~20th frame child), two styles referenced +/// by ~every frame/TEXT node, and `boundVariables` fill bindings on ~every +/// 10th frame. Pure and wall-clock free: two calls with the same `nodes` +/// produce byte-identical JSON. +pub fn generate_corpus(nodes: usize) -> Value { + let mut rng = Lcg::new(LCG_SEED); + let mut pos: u64 = 0; + + let doc_bounds = grid_bounds(&mut pos); + let mut components = serde_json::Map::new(); + let mut component_sets = serde_json::Map::new(); + let styles = json!({ + "S:1": {"key": "sk1", "name": "Bench/Fill", "styleType": "FILL", "description": "", "remote": false}, + "S:2": {"key": "sk2", "name": "Bench/Text", "styleType": "TEXT", "description": "", "remote": false}, + }); + + const VARIABLE_IDS: [&str; 3] = ["VariableID:100", "VariableID:101", "VariableID:102"]; + let mut variants: Vec<(String, &'static str)> = Vec::new(); // (component node id, state label) + + let mut pages: Vec = Vec::new(); + let mut remaining = nodes.saturating_sub(1); // minus DOCUMENT + let mut page_num = 0usize; + let mut global_frame_idx = 0usize; + let mut global_child_idx = 0usize; + + while remaining > 0 { + page_num += 1; + let mut local = 0usize; // per-page "p:i" id counter + remaining -= 1; // the CANVAS node itself + let page_id = format!("{page_num}:0"); + let mut page_children: Vec = Vec::new(); + + if page_num == 1 && remaining >= 3 { + local += 1; + let set_id = format!("{page_num}:{local}"); + local += 1; + let variant1_id = format!("{page_num}:{local}"); + local += 1; + let variant2_id = format!("{page_num}:{local}"); + remaining -= 3; + + variants.push((variant1_id.clone(), "Default")); + variants.push((variant2_id.clone(), "Hover")); + + component_sets.insert( + set_id.clone(), + json!({"key": "keyset-button", "name": "Button", "description": "", "remote": false}), + ); + components.insert( + variant1_id.clone(), + json!({"key": "key-button-default", "name": "State=Default", "description": "", "componentSetId": set_id, "remote": false}), + ); + components.insert( + variant2_id.clone(), + json!({"key": "key-button-hover", "name": "State=Hover", "description": "", "componentSetId": set_id, "remote": false}), + ); + + page_children.push(json!({ + "id": set_id, + "name": "Button", + "type": "COMPONENT_SET", + "absoluteBoundingBox": grid_bounds(&mut pos), + "componentPropertyDefinitions": { + "State": {"type": "VARIANT", "defaultValue": "Default", "variantOptions": ["Default", "Hover"]} + }, + "children": [ + { + "id": variant1_id, + "name": "State=Default", + "type": "COMPONENT", + "absoluteBoundingBox": grid_bounds(&mut pos), + "children": [], + }, + { + "id": variant2_id, + "name": "State=Hover", + "type": "COMPONENT", + "absoluteBoundingBox": grid_bounds(&mut pos), + "children": [], + }, + ], + })); + } + + let mut page_budget = page_children.len(); + while remaining > 0 && page_budget < 250 { + local += 1; + let frame_id = format!("{page_num}:{local}"); + remaining -= 1; + page_budget += 1; + global_frame_idx += 1; + + let want = 3 + rng.next_range(4); // 3..=6 children + let child_count = want.min(remaining); + + let mut children: Vec = Vec::with_capacity(child_count); + for _ in 0..child_count { + local += 1; + let child_id = format!("{page_num}:{local}"); + remaining -= 1; + page_budget += 1; + global_child_idx += 1; + + if global_child_idx.is_multiple_of(20) && !variants.is_empty() { + let (comp_id, state) = &variants[rng.next_range(variants.len())]; + children.push(json!({ + "id": child_id, + "name": "Button", + "type": "INSTANCE", + "componentId": comp_id, + "componentProperties": { + "State": {"value": state, "type": "VARIANT"} + }, + "absoluteBoundingBox": grid_bounds(&mut pos), + "children": [], + })); + } else { + let word_count = 4 + rng.next_range(5); // 4..=8 words + let text = (0..word_count) + .map(|_| WORDS[rng.next_range(WORDS.len())]) + .collect::>() + .join(" "); + children.push(json!({ + "id": child_id, + "name": text, + "type": "TEXT", + "characters": text, + "styles": {"text": "S:2"}, + "absoluteBoundingBox": grid_bounds(&mut pos), + "children": [], + })); + } + } + + let mut frame = json!({ + "id": frame_id, + "name": format!("{} Frame", WORDS[rng.next_range(WORDS.len())]), + "type": "FRAME", + "layoutMode": "VERTICAL", + "styles": {"fill": "S:1"}, + "absoluteBoundingBox": grid_bounds(&mut pos), + "children": children, + }); + if global_frame_idx.is_multiple_of(10) { + let var_id = VARIABLE_IDS[rng.next_range(VARIABLE_IDS.len())]; + frame["fills"] = json!([{ + "type": "SOLID", + "color": {"r": 0.2, "g": 0.2, "b": 0.2, "a": 1.0}, + "boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": var_id}}, + }]); + } + page_children.push(frame); + } + + pages.push(json!({ + "id": page_id, + "name": format!("Page {page_num}"), + "type": "CANVAS", + "absoluteBoundingBox": grid_bounds(&mut pos), + "children": page_children, + })); + } + + json!({ + "name": format!("Bench Corpus ({nodes} nodes)"), + "version": "1", + "lastModified": "2026-01-01T00:00:00Z", + "document": { + "id": "0:0", + "name": "Document", + "type": "DOCUMENT", + "absoluteBoundingBox": doc_bounds, + "children": pages, + }, + "components": components, + "componentSets": component_sets, + "styles": styles, + }) +} + +// ---- derived query mix (shared by synthetic and real-file mode) ---- + +/// The load-test's tool-call parameters, always *derived* from the +/// flattened records rather than hardcoded — the same derivation runs in +/// synthetic mode (the corpus's own generated names/text) and real-file +/// mode (whatever's actually in the file), so bench exercises one code +/// path regardless of source. +struct QueryPool { + /// Distinct words drawn from node names and TEXT `characters`, sorted + /// (a `BTreeSet` collection — deterministic, never a `HashMap`). + words: Vec, + /// Every node id in the file, in flatten order. + node_ids: Vec, + /// A real component or component-set name, if the file has one — + /// `figmog_instances`'s target. `None` means the file has no + /// components; that tool is skipped from the load mix. + instances_target: Option, +} + +fn derive_query_pool(flattened: &Flattened) -> QueryPool { + let mut words_set: BTreeSet = BTreeSet::new(); + let mut node_ids: Vec = Vec::new(); + // Prefer a component *set* name (groups variants — a more useful + // `figmog_instances` target) over a standalone component's; flatten + // order lists individual `components` map entries before + // `componentSets` ones, so picking "whichever comes first" would + // otherwise favor a single variant's name over the set's. + let mut set_name: Option = None; + let mut standalone_component_name: Option = None; + + for (id, rec) in &flattened.recs { + match (id, rec) { + (Id::Node(nid), Rec::Node(n)) => { + node_ids.push(nid.clone()); + for w in n.name.split_whitespace() { + words_set.insert(w.to_string()); + } + if let Some(t) = &n.text { + for w in t.split_whitespace() { + words_set.insert(w.to_string()); + } + } + } + (Id::ComponentSet(_), Rec::ComponentSet(cs)) if set_name.is_none() => { + set_name = Some(cs.name.clone()); + } + (Id::Component(_), Rec::Component(c)) if standalone_component_name.is_none() => { + standalone_component_name = Some(c.name.clone()); + } + _ => {} + } + } + + QueryPool { + words: words_set.into_iter().collect(), + node_ids, + instances_target: set_name.or(standalone_component_name), + } +} + +// ---- percentiles ---- + +/// pN of a **sorted** slice: `v[(n - 1) * N / 100]` (integer floor +/// division). Empty input reports 0ms across the board. +fn percentile_ms(sorted: &[Duration], p: usize) -> f64 { + if sorted.is_empty() { + return 0.0; + } + let n = sorted.len(); + let idx = (n - 1) * p / 100; + sorted[idx].as_secs_f64() * 1000.0 +} + +fn max_ms(sorted: &[Duration]) -> f64 { + sorted + .last() + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or(0.0) +} + +// ---- report types ---- + +#[derive(Debug, Serialize)] +pub struct CorpusStats { + pub nodes: usize, + pub bytes: usize, + /// Synthetic mode: generation time. Real-file mode: the one Tier-1 + /// `GET /v1/files/:key` fetch time. + pub gen_ms: f64, +} + +#[derive(Debug, Serialize)] +pub struct ColdStats { + pub flatten_ms: f64, + pub sync_ms: f64, + pub records: usize, + pub records_per_s: f64, +} + +#[derive(Debug, Serialize)] +pub struct RepullStats { + pub ms: f64, + pub churn_zero: bool, +} + +#[derive(Debug, Serialize)] +pub struct ToolStats { + pub tool: String, + pub calls: usize, + pub p50_ms: f64, + pub p95_ms: f64, + pub p99_ms: f64, + pub max_ms: f64, +} + +#[derive(Debug, Serialize)] +pub struct LoadStats { + pub per_tool: Vec, + pub total_calls: usize, + pub wall_s: f64, + pub req_per_s: f64, +} + +/// Real-file-mode API comparison phase (spec §13): K sequential +/// `GET /nodes` calls timed the same way the serve load's `figmog_node` +/// calls are, plus figmog's own p50 for the side-by-side and the budget +/// math. `figmog_node_p50_ms`/`speedup_factor` are `None` when zero calls +/// succeeded (e.g. an immediate 429) — nothing to compare against. +#[derive(Debug, Serialize)] +pub struct ApiStats { + pub calls: usize, + pub p50_ms: f64, + pub max_ms: f64, + pub rate_limited: bool, + pub retry_after_s: Option, + pub figmog_node_p50_ms: Option, + pub speedup_factor: Option, + /// How long the serve load's `--calls` would take at Figma's ~10 + /// Tier-1 requests/minute budget, in minutes. + pub budget_minutes_at_tier1_limit: f64, +} + +#[derive(Debug, Serialize)] +pub struct BenchReport { + /// `"synthetic"` or `"real"`. + pub source: String, + pub corpus: CorpusStats, + pub cold: ColdStats, + pub repull: RepullStats, + pub load: LoadStats, + /// `Some` only in real-file mode when the API comparison phase ran + /// (i.e. not `--skip-api`). + pub api: Option, +} + +/// `figmog bench` options. `exe` is the binary to spawn for the serve-load +/// phase: `figmog bench` itself passes `std::env::current_exe()` (correct +/// when a user runs `figmog bench` directly); tests pass the real compiled +/// binary via `assert_cmd::cargo::cargo_bin` so the path is right in both +/// contexts — `run` never tries to resolve it itself. +pub struct BenchOpts { + pub nodes: usize, + pub calls: usize, + pub keep: bool, + pub exe: PathBuf, + /// Real Figma file key (already resolved from a bare key or URL — see + /// `ident::parse_file_ref`). `None` selects synthetic mode. + pub file: Option, + /// Real-file mode only: number of `GET /nodes` comparison calls. + pub api_calls: usize, + /// Real-file mode only: skip the API comparison phase entirely. + pub skip_api: bool, +} + +// ---- run ---- + +/// Run every phase and return the assembled report, or `Err` if any phase +/// fails or any tool call comes back `isError` (a graceful 429 in the API +/// comparison phase is a *recorded* result, not a failure — see +/// [`ApiStats`]). +pub fn run(opts: BenchOpts) -> Result { + // ---- phase 1: corpus ---- + // `vars_resp` carries the opportunistic Enterprise `variables_local` + // response (real-file mode only, like `do_pull` — spec §12); `Ok(None)` + // on non-Enterprise plans is not an error. `api_for_comparison` is kept + // alive only so phase 5 can reuse the same authenticated client instead + // of re-reading `FIGMA_TOKEN`. + let (resp, vars_resp, api_for_comparison, source, gen_ms): ( + Value, + Option, + Option, + &str, + f64, + ) = match &opts.file { + Some(key) => { + let token = std::env::var("FIGMA_TOKEN").map_err(|_| { + "FIGMA_TOKEN not set — required for `figmog bench `".to_string() + })?; + let api = UreqApi::new(token); + let fetch_start = Instant::now(); + let resp = api.file(key).map_err(|e| e.to_string())?; + let gen_ms = fetch_start.elapsed().as_secs_f64() * 1000.0; + let vars_resp = api.variables_local(key).map_err(|e| e.to_string())?; + (resp, vars_resp, Some(api), "real", gen_ms) + } + None => { + let gen_start = Instant::now(); + let resp = generate_corpus(opts.nodes); + let gen_ms = gen_start.elapsed().as_secs_f64() * 1000.0; + (resp, None, None, "synthetic", gen_ms) + } + }; + + // ---- phase 2: cold sync ---- + let flatten_start = Instant::now(); + let mut flattened = flatten_file(&resp).map_err(|e| e.to_string())?; + if let Some(v) = &vars_resp { + let var_recs = crate::vars::parse_variables_export(v).map_err(|e| e.to_string())?; + flattened.recs.extend(var_recs); + } + let flatten_ms = flatten_start.elapsed().as_secs_f64() * 1000.0; + + let node_count = flattened + .recs + .iter() + .filter(|(id, _)| matches!(id, Id::Node(_))) + .count(); + let bytes = serde_json::to_vec(&resp).map_err(|e| e.to_string())?.len(); + + let tmp_dir = make_temp_dir()?; + let db_path = tmp_dir.join("db"); + let cleanup = TempDirGuard { + path: tmp_dir.clone(), + keep: opts.keep, + }; + + let mut st = open_store_checked(|| crate::open_store!(&db_path))?; + + let sync_start = Instant::now(); + let churn = store::sync(&mut st, &BTreeSet::new(), &flattened, 0); + let sync_ms = sync_start.elapsed().as_secs_f64() * 1000.0; + let records = flattened.recs.len(); + let records_per_s = if sync_ms > 0.0 { + records as f64 / (sync_ms / 1000.0) + } else { + 0.0 + }; + debug_assert!(churn.removed == 0, "fresh store: nothing to remove"); + + // ---- phase 3: no-churn re-pull (same in-memory data, no re-fetch) ---- + let prior = st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + let repull_start = Instant::now(); + let churn2 = store::sync(&mut st, &prior, &flattened, 0); + let repull_ms = repull_start.elapsed().as_secs_f64() * 1000.0; + if churn2.added != 0 || churn2.changed != 0 || churn2.removed != 0 { + return Err(format!( + "bench invariant violated: non-zero churn on re-pull of identical data: {churn2:?}" + )); + } + + // Store must be closed before the serve child opens the same path — + // fjall allows only one writer. + drop(st); + + // ---- derived query mix (one code path for both modes) ---- + let pool = derive_query_pool(&flattened); + + // ---- phase 4: serve load ---- + let load = run_load_phase(&opts, &db_path, &pool)?; + + // ---- phase 5: API comparison (real-file mode only) ---- + let api = match (&opts.file, api_for_comparison) { + (Some(key), Some(api)) if !opts.skip_api => Some(run_api_comparison_phase( + &api, + key, + opts.api_calls, + &pool, + &load, + )?), + _ => None, + }; + + if opts.keep { + eprintln!("figmog: kept temp store at {}", tmp_dir.display()); + } + drop(cleanup); + + Ok(BenchReport { + source: source.to_string(), + corpus: CorpusStats { + nodes: node_count, + bytes, + gen_ms, + }, + cold: ColdStats { + flatten_ms, + sync_ms, + records, + records_per_s, + }, + repull: RepullStats { + ms: repull_ms, + churn_zero: true, + }, + load, + api, + }) +} + +// ---- temp dir management ---- + +struct TempDirGuard { + path: PathBuf, + keep: bool, +} + +impl Drop for TempDirGuard { + fn drop(&mut self) { + if !self.keep { + let _ = std::fs::remove_dir_all(&self.path); + } + } +} + +/// A fresh, empty temp directory for this bench run. Not derived from +/// `tempfile` (a dev-only dependency elsewhere in this crate) — a +/// process-id-qualified path under the system temp dir is unique enough +/// for one bench run per process, and this isn't part of any timed or +/// deterministic path (spec §13's "no SystemTime in the measurement +/// path" is about the timed phases, not directory naming). +fn make_temp_dir() -> Result { + let dir = std::env::temp_dir().join(format!("figmog-bench-{}", std::process::id())); + if dir.exists() { + std::fs::remove_dir_all(&dir).map_err(|e| format!("clearing stale temp dir: {e}"))?; + } + std::fs::create_dir_all(&dir).map_err(|e| format!("creating temp dir: {e}"))?; + Ok(dir) +} + +// ---- serve load phase ---- + +/// Kills the spawned `figmog serve` child on drop, so a failed assertion +/// or early `?` return never leaves an orphaned process behind (mirrors +/// `tests/serve.rs`'s `ChildGuard`). +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +const CALL_TIMEOUT: Duration = Duration::from_secs(10); +const EXIT_TIMEOUT: Duration = Duration::from_secs(10); + +fn spawn_serve( + exe: &std::path::Path, + db: &std::path::Path, +) -> (ChildGuard, ChildStdin, Receiver) { + let mut child = Command::new(exe) + .args(["serve", "--no-upstream", "--no-watch", "--db"]) + .arg(db) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn figmog serve for bench"); + + let stdin = child.stdin.take().expect("child stdin"); + let stdout = child.stdout.take().expect("child stdout"); + let stderr = child.stderr.take().expect("child stderr"); + + // Drain stderr purely so the child never blocks on a full pipe; never + // asserted on (it's just serve's own startup log line). + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + eprintln!("[figmog serve] {line}"); + } + }); + + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if tx.send(line).is_err() { + break; + } + } + }); + + (ChildGuard(child), stdin, rx) +} + +fn send(stdin: &mut ChildStdin, msg: &Value) -> Result<(), String> { + writeln!(stdin, "{msg}").map_err(|e| format!("writing to serve child: {e}"))?; + stdin + .flush() + .map_err(|e| format!("flushing serve child stdin: {e}")) +} + +fn recv(rx: &Receiver) -> Result { + let line = rx + .recv_timeout(CALL_TIMEOUT) + .map_err(|_| "figmog serve did not respond within the timeout".to_string())?; + serde_json::from_str(&line).map_err(|e| format!("response line was not valid JSON: {e}")) +} + +fn wait_with_timeout(child: &mut Child, timeout: Duration) -> Result<(), String> { + let start = Instant::now(); + loop { + if let Some(status) = child.try_wait().map_err(|e| e.to_string())? { + return if status.success() { + Ok(()) + } else { + Err(format!("figmog serve exited with {status:?}")) + }; + } + if start.elapsed() > timeout { + let _ = child.kill(); + return Err(format!( + "figmog serve did not exit within {timeout:?} of stdin EOF" + )); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +/// The fixed rotating tool-call mix (spec §13), narrowed to what the file +/// actually supports: `figmog_search`/`figmog_instances` are dropped (with +/// a stderr note) when the corpus has no searchable words / no components. +fn tool_rotation(pool: &QueryPool) -> Vec<&'static str> { + let mut kinds = Vec::new(); + if pool.words.is_empty() { + eprintln!( + "figmog: bench corpus has no searchable words — dropping figmog_search from the load mix" + ); + } else { + kinds.push("figmog_search"); + } + if pool.node_ids.is_empty() { + eprintln!("figmog: bench corpus has no nodes — dropping figmog_node from the load mix"); + } else { + kinds.push("figmog_node"); + } + kinds.push("figmog_where"); + kinds.push("figmog_stats"); + kinds.push("figmog_tree"); + if pool.instances_target.is_some() { + kinds.push("figmog_instances"); + } else { + eprintln!( + "figmog: bench corpus has no components — dropping figmog_instances from the load mix" + ); + } + kinds +} + +fn call_args(tool: &str, pool: &QueryPool, rng: &mut Lcg) -> Value { + match tool { + "figmog_search" => { + let word = &pool.words[rng.next_range(pool.words.len())]; + json!({"query": word}) + } + "figmog_node" => { + let id = &pool.node_ids[rng.next_range(pool.node_ids.len())]; + json!({"id": id}) + } + "figmog_where" => json!({"pointer": "/layoutMode", "equals": "VERTICAL"}), + "figmog_stats" => json!({}), + "figmog_tree" => json!({"depth": 2}), + "figmog_instances" => { + json!({"target": pool.instances_target.as_deref().unwrap_or_default()}) + } + other => unreachable!("tool_rotation only emits known tool names, got {other}"), + } +} + +fn run_load_phase( + opts: &BenchOpts, + db_path: &std::path::Path, + pool: &QueryPool, +) -> Result { + let rotation = tool_rotation(pool); + if rotation.is_empty() { + return Err("bench corpus has neither searchable words, nodes, nor components — nothing to load-test".into()); + } + + let (mut guard, mut stdin, rx) = spawn_serve(&opts.exe, db_path); + + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }), + )?; + recv(&rx)?; // initialize response; contents not needed here + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + )?; + + let mut rng = Lcg::new(LCG_SEED); + let mut timings: Vec> = vec![Vec::new(); rotation.len()]; + + let wall_start = Instant::now(); + for i in 0..opts.calls { + let tool_idx = i % rotation.len(); + let tool = rotation[tool_idx]; + let args = call_args(tool, pool, &mut rng); + let req_id = (i as i64) + 2; + + let start = Instant::now(); + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": req_id, + "method": "tools/call", + "params": {"name": tool, "arguments": args}, + }), + )?; + let resp = recv(&rx)?; + let elapsed = start.elapsed(); + + if resp["result"]["isError"] == json!(true) { + let text = resp["result"]["content"][0]["text"] + .as_str() + .unwrap_or(""); + return Err(format!( + "bench load call #{i} ({tool}) returned isError: {text}" + )); + } + timings[tool_idx].push(elapsed); + } + let wall = wall_start.elapsed(); + + drop(stdin); // stdin EOF: how `--no-watch` serve exits cleanly + wait_with_timeout(&mut guard.0, EXIT_TIMEOUT)?; + + let mut per_tool = Vec::with_capacity(rotation.len()); + for (tool, mut durations) in rotation.into_iter().zip(timings) { + durations.sort(); + per_tool.push(ToolStats { + tool: tool.to_string(), + calls: durations.len(), + p50_ms: percentile_ms(&durations, 50), + p95_ms: percentile_ms(&durations, 95), + p99_ms: percentile_ms(&durations, 99), + max_ms: max_ms(&durations), + }); + } + + let wall_s = wall.as_secs_f64(); + let req_per_s = if wall_s > 0.0 { + opts.calls as f64 / wall_s + } else { + 0.0 + }; + + Ok(LoadStats { + per_tool, + total_calls: opts.calls, + wall_s, + req_per_s, + }) +} + +// ---- API comparison phase (real-file mode) ---- + +fn run_api_comparison_phase( + api: &UreqApi, + key: &str, + api_calls: usize, + pool: &QueryPool, + load: &LoadStats, +) -> Result { + // Continue the same deterministic stream the load phase's arg-picking + // used, rather than reusing its exact seed — the two phases just need + // *a* reproducible id sequence each, not a shared one. + let mut rng = Lcg::new(LCG_SEED.wrapping_add(1)); + let mut durations: Vec = Vec::new(); + let mut rate_limited = false; + let mut retry_after_s: Option = None; + + let api_calls = if pool.node_ids.is_empty() { + eprintln!("figmog: bench corpus has no nodes — skipping the API comparison phase"); + 0 + } else { + api_calls + }; + for _ in 0..api_calls { + let id = &pool.node_ids[rng.next_range(pool.node_ids.len())]; + let start = Instant::now(); + match api.file_nodes(key, id) { + Ok(_) => durations.push(start.elapsed()), + Err(ApiError::RateLimited { retry_after }) => { + rate_limited = true; + retry_after_s = Some(retry_after.as_secs()); + eprintln!( + "figmog: bench API comparison phase rate-limited (429) after {} call(s); retry after {}s — ending the phase gracefully", + durations.len(), + retry_after.as_secs() + ); + break; + } + Err(e) => return Err(format!("API comparison phase failed: {e}")), + } + } + + if !rate_limited { + // One Tier-3 meta call for reference (spec §13); skipped after a + // 429 to avoid spending more of the just-exhausted budget. + if let Err(e) = api.file_meta(key) { + eprintln!("figmog: bench API comparison reference meta call failed (non-fatal): {e}"); + } + } + + // `run_api_comparison_phase` only ever runs in real-file mode, which + // always attempts exactly one `file()` fetch and one `variables_local` + // call up front (see `run`'s corpus phase) — both already spent by the + // time this phase starts. + eprintln!( + "figmog: API calls spent — file:1 variables:1 nodes:{} meta:{}", + durations.len(), + if rate_limited { 0 } else { 1 } + ); + + durations.sort(); + let calls = durations.len(); + let p50 = percentile_ms(&durations, 50); + let max = max_ms(&durations); + + let figmog_node_p50_ms = load + .per_tool + .iter() + .find(|t| t.tool == "figmog_node") + .map(|t| t.p50_ms); + let speedup_factor = match (figmog_node_p50_ms, calls > 0) { + (Some(fig), true) if fig > 0.0 => Some(p50 / fig), + _ => None, + }; + + Ok(ApiStats { + calls, + p50_ms: p50, + max_ms: max, + rate_limited, + retry_after_s, + figmog_node_p50_ms, + speedup_factor, + budget_minutes_at_tier1_limit: load.total_calls as f64 / 10.0, + }) +} + +// ---- human-readable report ---- + +/// The phase lines + per-tool table + headline (`--json`'s alternative; +/// stdout purity means callers pick exactly one). +pub fn print_human(report: &BenchReport) { + println!( + "corpus [{}] {} nodes, {} bytes, {:.1}ms", + report.source, report.corpus.nodes, report.corpus.bytes, report.corpus.gen_ms + ); + println!( + "cold sync {:.1}ms flatten + {:.1}ms sync, {} records ({:.0} records/s)", + report.cold.flatten_ms, report.cold.sync_ms, report.cold.records, report.cold.records_per_s + ); + println!( + "re-pull {:.1}ms, churn zero: {}", + report.repull.ms, report.repull.churn_zero + ); + println!(); + println!( + "{:<18} {:>8} {:>10} {:>10} {:>10} {:>10}", + "tool", "calls", "p50 (ms)", "p95 (ms)", "p99 (ms)", "max (ms)" + ); + for t in &report.load.per_tool { + println!( + "{:<18} {:>8} {:>10.3} {:>10.3} {:>10.3} {:>10.3}", + t.tool, t.calls, t.p50_ms, t.p95_ms, t.p99_ms, t.max_ms + ); + } + println!(); + println!( + "figmog served {} queries in {:.1}s ({:.0} req/s). Figma's Tier-1 API budget on a free plan: ~10 file requests per MINUTE.", + report.load.total_calls, report.load.wall_s, report.load.req_per_s + ); + + if let Some(api) = &report.api { + println!(); + println!( + "API comparison {} call(s), p50 {:.1}ms, max {:.1}ms{}", + api.calls, + api.p50_ms, + api.max_ms, + if api.rate_limited { + format!( + " — rate-limited (429), retry after {}s", + api.retry_after_s.unwrap_or_default() + ) + } else { + String::new() + } + ); + match (api.figmog_node_p50_ms, api.speedup_factor) { + (Some(fig), Some(speedup)) => { + println!( + "figmog_node p50 {:.3}ms vs API /nodes p50 {:.1}ms — figmog is {:.0}x faster", + fig, api.p50_ms, speedup + ); + } + (Some(fig), None) => { + println!("figmog_node p50 {fig:.3}ms; no successful API call to compare against"); + } + _ => {} + } + println!( + "at Figma's ~10 Tier-1 requests/minute, {} calls would take ≈{:.1} minutes — figmog's serve load: {:.1}s", + report.load.total_calls, api.budget_minutes_at_tier1_limit, report.load.wall_s + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- corpus generation ---- + + fn node_ids(v: &Value) -> Vec { + let flattened = flatten_file(v).expect("bench corpus flattens"); + flattened + .recs + .iter() + .filter_map(|(id, _)| match id { + Id::Node(s) => Some(s.clone()), + _ => None, + }) + .collect() + } + + #[test] + fn exact_node_count_100() { + assert_eq!(node_ids(&generate_corpus(100)).len(), 100); + } + + #[test] + fn exact_node_count_1000() { + assert_eq!(node_ids(&generate_corpus(1000)).len(), 1000); + } + + #[test] + fn exact_node_count_10007_odd() { + assert_eq!(node_ids(&generate_corpus(10007)).len(), 10007); + } + + #[test] + fn deterministic_byte_identical_across_calls() { + let a = serde_json::to_vec(&generate_corpus(500)).unwrap(); + let b = serde_json::to_vec(&generate_corpus(500)).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn contains_component_set_instance_and_nonempty_text() { + let corpus = generate_corpus(500); + let flattened = flatten_file(&corpus).unwrap(); + + let has_component_set = flattened + .recs + .iter() + .any(|(id, _)| matches!(id, Id::ComponentSet(_))); + assert!(has_component_set, "expected at least one COMPONENT_SET"); + + let mut has_instance = false; + let mut text_nodes_nonempty = true; + let mut saw_text_node = false; + for (_, rec) in &flattened.recs { + if let Rec::Node(n) = rec { + if n.node_type == "INSTANCE" { + has_instance = true; + } + if n.node_type == "TEXT" { + saw_text_node = true; + if n.text.as_deref().unwrap_or("").is_empty() { + text_nodes_nonempty = false; + } + } + } + } + assert!(has_instance, "expected at least one INSTANCE"); + assert!(saw_text_node, "expected at least one TEXT node"); + assert!( + text_nodes_nonempty, + "every TEXT node should have non-empty characters" + ); + } + + #[test] + fn flatten_file_succeeds_and_yields_exact_node_records() { + for n in [100usize, 1000, 10007] { + let corpus = generate_corpus(n); + let flattened = flatten_file(&corpus).expect("bench corpus flattens"); + let count = flattened + .recs + .iter() + .filter(|(id, _)| matches!(id, Id::Node(_))) + .count(); + assert_eq!(count, n, "node count mismatch for nodes={n}"); + } + } + + // ---- percentiles ---- + + #[test] + fn percentile_math_on_a_known_vector() { + let sorted: Vec = (1..=10).map(Duration::from_millis).collect(); + // pN = v[(n-1)*N/100], n=10: idx = 9*N/100 (integer floor). + assert_eq!(percentile_ms(&sorted, 50), 5.0); // idx (9*50)/100=4 -> v[4]=5ms + assert_eq!(percentile_ms(&sorted, 95), 9.0); // idx (9*95)/100=8 -> v[8]=9ms + assert_eq!(percentile_ms(&sorted, 99), 9.0); // idx (9*99)/100=8 -> v[8]=9ms + assert_eq!(max_ms(&sorted), 10.0); + } + + #[test] + fn percentile_of_empty_is_zero() { + let empty: Vec = Vec::new(); + assert_eq!(percentile_ms(&empty, 50), 0.0); + assert_eq!(max_ms(&empty), 0.0); + } + + #[test] + fn percentile_of_single_value() { + let one = vec![Duration::from_millis(7)]; + assert_eq!(percentile_ms(&one, 50), 7.0); + assert_eq!(percentile_ms(&one, 99), 7.0); + assert_eq!(max_ms(&one), 7.0); + } + + // ---- derived query pool ---- + + #[test] + fn query_pool_derives_from_flattened_records_not_hardcoded() { + let corpus = generate_corpus(500); + let flattened = flatten_file(&corpus).unwrap(); + let pool = derive_query_pool(&flattened); + assert!(!pool.words.is_empty()); + assert!(!pool.node_ids.is_empty()); + assert_eq!(pool.instances_target.as_deref(), Some("Button")); + } + + #[test] + fn query_pool_handles_a_file_with_no_components() { + let file = json!({ + "name": "F", "version": "1", "lastModified": "t", + "document": { + "id": "0:0", "name": "Document", "type": "DOCUMENT", + "children": [ + {"id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [ + {"id": "1:1", "name": "Hello World", "type": "TEXT", "characters": "Hello World", "children": []} + ]} + ] + }, + "components": {}, "componentSets": {}, "styles": {}, + }); + let flattened = flatten_file(&file).unwrap(); + let pool = derive_query_pool(&flattened); + assert!(pool.instances_target.is_none()); + assert!(pool.words.contains(&"Hello".to_string())); + + let rotation = tool_rotation(&pool); + assert!(!rotation.contains(&"figmog_instances")); + assert!(rotation.contains(&"figmog_search")); + } +} diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index a0c1930..04c297f 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -176,6 +176,29 @@ enum Cmd { #[arg(long)] y: f64, }, + /// Self-contained load-test demo (build design §13): synthetic corpus + /// (or a real file's, given one), cold sync, no-churn re-pull, and an + /// MCP serve load test over real stdio, plus (real-file mode) a Figma + /// API comparison — no mirror/`--db` required. + Bench { + /// Figma file key or figma.com URL — fetches once (one Tier-1 + /// call) and benches against the real file. Omitted: a + /// deterministic synthetic corpus. + file: Option, + #[arg(long, default_value = "10000")] + nodes: usize, + #[arg(long, default_value = "5000")] + calls: usize, + /// Real-file mode only: number of `GET /nodes` API-comparison calls. + #[arg(long, default_value = "5")] + api_calls: usize, + /// Real-file mode only: skip the API-comparison phase entirely. + #[arg(long)] + skip_api: bool, + /// Leave the temp store on disk and print its path. + #[arg(long)] + keep: bool, + }, } /// Parse `argv`, dispatch, and return the process exit code (0 on success, @@ -197,6 +220,31 @@ pub fn run() -> i32 { } fn dispatch(cli: Cli) -> Result<(), String> { + // `bench` needs no mirror/db (it builds its own temp store) — handled + // here, before `resolve_db`, exactly like the note on `open_store!`'s + // unnameable pipeline type below explains for everything else. Matched + // by reference so a non-match leaves `cli` untouched for the rest of + // this function. + if let Cmd::Bench { + file, + nodes, + calls, + api_calls, + skip_api, + keep, + } = &cli.cmd + { + return cmd_bench( + file.clone(), + *nodes, + *calls, + *api_calls, + *skip_api, + *keep, + cli.json, + ); + } + let db = resolve_db(&cli)?; match cli.cmd { Cmd::Pull { @@ -318,7 +366,8 @@ fn dispatch(cli: Cli) -> Result<(), String> { | Cmd::ImportVariables { .. } | Cmd::Serve { .. } | Cmd::Tools { .. } - | Cmd::Call { .. } => { + | Cmd::Call { .. } + | Cmd::Bench { .. } => { unreachable!("handled above") } } @@ -723,6 +772,43 @@ fn cmd_import_variables(db: &Db, path: PathBuf, json: bool) -> Result<(), String Ok(()) } +/// `figmog bench [file] [--nodes N] [--calls M] [--api-calls K] [--skip-api] +/// [--keep]` (build design §13). Needs no resolved `Db` — see `dispatch`'s +/// early handling — so it never touches `.figmog/current` or `--db`. +#[allow(clippy::too_many_arguments)] +fn cmd_bench( + file: Option, + nodes: usize, + calls: usize, + api_calls: usize, + skip_api: bool, + keep: bool, + json: bool, +) -> Result<(), String> { + let file = file + .map(|f| parse_file_ref(&f).ok_or_else(|| format!("not a Figma file key or URL: {f}"))) + .transpose()?; + let exe = std::env::current_exe().map_err(|e| format!("resolving current exe: {e}"))?; + let report = crate::bench::run(crate::bench::BenchOpts { + nodes, + calls, + keep, + exe, + file, + api_calls, + skip_api, + })?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&report).map_err(|e| e.to_string())? + ); + } else { + crate::bench::print_human(&report); + } + Ok(()) +} + pub(crate) fn read_watermark(db: &Db) -> Result, String> { let st = open_store_checked(|| crate::open_store!(&db.path))?; Ok(st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified))) diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs index c135e55..de42f7e 100644 --- a/examples/figmog/src/lib.rs +++ b/examples/figmog/src/lib.rs @@ -10,6 +10,7 @@ //! See `docs/superpowers/specs/2026-08-15-figmog-build-design.md`. pub mod api; +pub mod bench; pub mod cache; pub mod cli; mod dispatch; diff --git a/examples/figmog/tests/cli.rs b/examples/figmog/tests/cli.rs index dd9d787..7d431bf 100644 --- a/examples/figmog/tests/cli.rs +++ b/examples/figmog/tests/cli.rs @@ -506,3 +506,39 @@ fn cli_pull_evicts_stale_cache_rows_on_version_change() { "the v1-tagged cache row must be evicted by the v2 `figmog pull`" ); } + +/// End-to-end smoke for `figmog bench` (build design §13), synthetic-mode +/// only (no `FIGMA_TOKEN` in CI): a small corpus/call count keeps this fast +/// while still exercising every phase — corpus generation, cold sync, +/// no-churn re-pull, and a real MCP `serve` child driven over stdio. +#[test] +fn bench_e2e_synthetic_json_report() { + let out = Command::cargo_bin("figmog") + .unwrap() + .args(["bench", "--nodes", "300", "--calls", "60", "--json"]) + .assert() + .success(); + let output = out.get_output(); + + // stdout purity: --json means exactly one JSON object, nothing else. + let stdout = String::from_utf8_lossy(&output.stdout); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("stdout was not exactly one JSON object: {e}\nstdout: {stdout}") + }); + + assert_eq!(v["source"], serde_json::json!("synthetic")); + assert_eq!(v["corpus"]["nodes"], serde_json::json!(300)); + assert_eq!(v["repull"]["churn_zero"], serde_json::json!(true)); + assert_eq!(v["load"]["total_calls"], serde_json::json!(60)); + assert!( + v["api"].is_null(), + "synthetic mode never runs the API comparison phase" + ); + + let per_tool = v["load"]["per_tool"].as_array().expect("per_tool array"); + assert!(!per_tool.is_empty()); + for tool in per_tool { + let p50 = tool["p50_ms"].as_f64().expect("p50_ms is a number"); + assert!(p50 >= 0.0, "p50 should be non-negative: {tool}"); + } +} From 27cf0d1f3745fa224a445f424500551238f7474e Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 17:40:39 -0700 Subject: [PATCH 41/56] spec(figmog): align bench non-goals with API comparison amendment Co-Authored-By: Claude Fable 5 --- docs/superpowers/specs/2026-08-15-figmog-build-design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index 9fff893..0abe824 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -777,5 +777,6 @@ result, not a failure). Concurrent client simulation (stdio is one pipe; the server is single-threaded by design); benchmarking the proxy path (network-bound, -not ours to measure); comparing against a live Figma API call (the -rate-limit number is documented, not re-measured). +not ours to measure); measuring Figma's rate limit itself (the +comparison phase measures API *latency* with K small calls; the +~10/min budget number is documented, never probed to exhaustion). From 3c7cfcc6f32d111c8b5f5f67639b030850177822 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 18:05:23 -0700 Subject: [PATCH 42/56] spec+plan(figmog): interactive bench REPL Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-16-figmog-repl.md | 42 +++++++++++++++++++ .../specs/2026-08-15-figmog-build-design.md | 38 ++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-08-16-figmog-repl.md diff --git a/docs/superpowers/plans/2026-08-16-figmog-repl.md b/docs/superpowers/plans/2026-08-16-figmog-repl.md new file mode 100644 index 0000000..0799be5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-figmog-repl.md @@ -0,0 +1,42 @@ +# figmog bench --interactive (REPL) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. + +**Goal:** The interactive REPL mode of spec §13 "Interactive mode" — requests visible as they fire, live API side-by-side, session percentiles. + +**Architecture:** Spec §13 "Interactive mode (`--interactive`)" is the binding authority — read it in full. One new module `src/repl.rs` driven from `bench::run`'s setup (reuse the corpus/real-file setup, serve child spawn, frame pump, and derived query pool exactly as built — refactor shared pieces out of `bench.rs` rather than duplicating; the one-shot path must keep behaving identically). + +**Tech Stack:** No new deps. `std::io::IsTerminal` for TTY detection; raw ANSI escapes; plain `stdin().lines()` (no readline). + +**Spec:** docs/superpowers/specs/2026-08-15-figmog-build-design.md §13 + +## Global Constraints +- Zero new crates. One-shot bench behavior and ALL existing tests unchanged. `--interactive` + `--json` = usage error (exit 1, message on stderr). +- Non-TTY stdout → no ANSI codes (the e2e depends on this). EOF or `quit` → child reaped, clean exit 0. +- Gates: `cargo test -p figmog`, `cargo clippy -p figmog --no-deps -- -D warnings`, `cargo fmt -p figmog --check`. +- Commit trailer: `Co-Authored-By: Claude Fable 5 `. + +--- + +### Task 1: the REPL (module + wiring + tests + README) + +**Files:** +- Create: `examples/figmog/src/repl.rs`; Modify: `src/bench.rs` (extract/reuse setup + frame-pump + query-pool helpers; make the pieces `pub(crate)`), `src/lib.rs`, `src/cli.rs` (`--interactive` flag on Bench + the json-conflict check), `examples/figmog/README.md` (Demo section gains the REPL walkthrough), workspace README bullet unchanged. +- Test: unit tests for the command parser (line → parsed command, incl. bad input errors) and the latency-line formatter (plain mode); one scripted e2e in `tests/cli.rs` piping `help\nstats\nsearch garden\nrun 20\nreport\nquit\n` into `figmog bench --nodes 300 --interactive` (non-TTY → plain output): assert exit 0, output contains the per-request lines (e.g. `figmog_search`), a `run` burst of 20 numbered lines, a report table, no ANSI escape bytes (`\x1b` absent), and the serve child exits (no zombie — process table not asserted, rely on guard + clean exit). + +**Interfaces:** +- `repl::run(session: &mut BenchSession, real_file: Option) -> Result<(), String>` where `BenchSession` is the extracted struct owning the serve child + pump + derived query pool + cumulative `Vec<(tool, Duration)>` stats; `RealFileCtx { key, api: UreqApi }` enables the `api …` commands. +- Command enum + `parse_line(&str) -> Result` (unit-tested): Help, Quit, Run(usize), Report, Api(ApiCmd), Call{tool, args}, Tool{name, args} for the shorthands per spec's list (each shorthand builds the tool's JSON args; `where [value]` value parsed as JSON with bare-word→string fallback like the CLI's --equals; `node children` sets children:true). +- Latency line: `#{seq:>4} {tool:<18} {args_digest:<32} {ms:>8.2}ms {digest}` — args digest truncated at 32 chars; result digest = hits count for array results, `name` for node results, `isError` text (red) for errors. Color thresholds per spec (10ms green / 100ms yellow / else red) via a `fn paint(s, color, tty)` helper. +- `run N`: reuse the one-shot mixed-workload rotation, print each line as fired, then a burst percentile table (reuse the existing ToolStats/percentile code). +- `report`: same table over the session's cumulative stats. +- `api node ` / `api meta`: call the existing UreqApi helpers, print with an `API` tag + "spent 1 Tier-1/Tier-3 call" note; 429 prints Retry-After in red and continues. + +- [ ] **Step 1 (TDD):** parser + formatter unit tests RED → implement → GREEN. +- [ ] **Step 2:** extract `BenchSession` from bench.rs (one-shot path re-verified: full `cargo test -p figmog` green before proceeding). +- [ ] **Step 3:** repl loop + CLI wiring + json-conflict error; manual smoke (pipe commands, eyeball output); scripted e2e added and green. +- [ ] **Step 4:** README walkthrough (commands table + a short sample transcript from a real run, incl. the `api node` side-by-side framing for real-file mode). +- [ ] **Step 5:** full gates; commit `feat(figmog): interactive bench REPL — watch requests fire live`. + +## Self-review checklist +- Spec §13 interactive coverage: every listed shorthand parses; run/report/api/call/help/quit; color TTY-gating; json-conflict; EOF clean exit. Non-goals respected (no readline). One-shot path byte-identical behavior (existing tests prove). diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index 0abe824..f0b8db7 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -773,10 +773,46 @@ unless `--keep`. Exit nonzero if any phase fails or any tool call returns `isError` (a graceful 429 in the comparison phase is a recorded result, not a failure). +### Interactive mode (`--interactive`) + +`figmog bench [FILE] --interactive` runs the same setup (corpus or real +file → cold sync → spawn serve child) and then, instead of the automated +phases, drops into a REPL on the user's terminal so requests are visible +as they fire: + +- **Tool shorthands** mapping to the local tools with light arg parsing: + `search `, `node [children]`, `tree [id] [depth]`, + `find [page]`, `where [value]`, `stats`, `path `, + `text [page]`, `at `, `instances `, `components`, + `styles [type]`, `uses `, `vars [id]`, `pages`, `status`. Each + prints one aligned line: sequence number, tool, arg summary, latency in + ms, and (dim) a one-line result digest (hit count / name / isError). +- **`run N`** — fire N requests of the derived mixed workload, streaming + one line per request in real time, then print the session percentile + table for the burst. +- **`api node ` / `api meta`** — real-file mode only: fire one actual + Figma API call (`/nodes` or `/meta`), timed the same way, each line + labeled with the API cost it spent. The live side-by-side is the demo's + centerpiece; 429s print their Retry-After and do not exit. +- **`call `** — raw escape hatch (works for proxied + tools too when an upstream is attached). +- **`report`** — cumulative per-tool percentiles for everything fired + this session; **`help`**; **`quit`**/EOF exits cleanly (child reaped). + +Colors: raw ANSI escapes only (no deps), emitted only when stdout is a +terminal (`IsTerminal`); latency lines green under 10ms, yellow under +100ms, red above; errors red. Non-TTY stdout gets plain text. The +interactive mode is human-only: `--json` combined with `--interactive` +is a usage error. The one-shot mode is unchanged (CI/e2e cover it); +interactive gets a scripted e2e (commands piped via stdin, non-TTY plain +output asserted, clean exit on EOF). + ### Non-goals Concurrent client simulation (stdio is one pipe; the server is single-threaded by design); benchmarking the proxy path (network-bound, not ours to measure); measuring Figma's rate limit itself (the comparison phase measures API *latency* with K small calls; the -~10/min budget number is documented, never probed to exhaustion). +~10/min budget number is documented, never probed to exhaustion); +readline niceties (history/completion — plain stdin lines are enough +for a demo REPL). From c264704e7fb7ce75df045761ae36373d605e093c Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 18:25:05 -0700 Subject: [PATCH 43/56] =?UTF-8?q?feat(figmog):=20interactive=20bench=20REP?= =?UTF-8?q?L=20=E2=80=94=20watch=20requests=20fire=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- examples/figmog/README.md | 105 +++- examples/figmog/src/bench.rs | 393 +++++++++++--- examples/figmog/src/cli.rs | 32 +- examples/figmog/src/lib.rs | 1 + examples/figmog/src/repl.rs | 998 +++++++++++++++++++++++++++++++++++ examples/figmog/tests/cli.rs | 51 ++ 6 files changed, 1486 insertions(+), 94 deletions(-) create mode 100644 examples/figmog/src/repl.rs diff --git a/examples/figmog/README.md b/examples/figmog/README.md index 1bf77b5..64af071 100644 --- a/examples/figmog/README.md +++ b/examples/figmog/README.md @@ -50,7 +50,7 @@ store location (default `.figmog//db`). | `figmog text [--page ]` | by_type + nodes | every TEXT node's `(id, characters, page_id)`, sorted by id | | `figmog where --pointer

[--equals ] [--page ]` | nodes | nodes whose raw JSON matches an RFC 6901 `pointer`, optionally filtered by `equals` (parsed as JSON, falling back to a bare string so `--equals VERTICAL` works) | | `figmog at --x N --y N` | nodes | nodes whose absolute bounds contain the point, sorted by area ascending (deepest/smallest first) | -| `figmog bench [file] [--nodes N] [--calls M] [--api-calls K] [--skip-api] [--keep]` | — | self-contained load-test demo (see "Demo: load-testing the server" below) — needs no mirror/`--db` | +| `figmog bench [file] [--nodes N] [--calls M] [--api-calls K] [--skip-api] [--keep] [--interactive]` | — | self-contained load-test demo, or (`--interactive`) a live REPL — see "Demo: load-testing the server" below — needs no mirror/`--db` | Node ids accept both `12:34` and `12-34` forms everywhere. Auth is a personal access token from `FIGMA_TOKEN`. Since `pull`/`watch` are the only @@ -377,6 +377,109 @@ states every call it made; a 429 mid-phase is recorded (with its `Retry-After`) and ends the phase gracefully rather than failing the whole bench. +### Interactive mode (`--interactive`) + +`figmog bench [file] --interactive` runs the same setup (corpus/real file +→ cold sync → no-churn re-pull → serve child spawn) and then, instead of +the automated load/API phases, drops into a REPL so requests are visible +as they fire — one aligned line per call: sequence number, tool, arg +digest, latency, and a result digest. Colors (green under 10ms, yellow +under 100ms, red above; errors always red) are raw ANSI, emitted only +when stdout is a real terminal — piped/non-TTY output (CI, this README's +transcripts) is always plain text. + +| command | does | +| --- | --- | +| `search ` | BM25 search over layer names/text | +| `node [children]` | full node JSON | +| `tree [id] [depth]` | subtree outline | +| `find [page]` | nodes by Figma node type | +| `where [value]` | nodes matching an RFC 6901 pointer (`value` parsed as JSON, falling back to a bare string) | +| `stats` | node counts, totals, max depth | +| `path ` | ancestor chain to a node | +| `text [page]` | every TEXT node's characters | +| `at ` | nodes containing a point | +| `instances ` | instances of a component | +| `components` | design-system inventory | +| `styles [type]` | styles with usage counts | +| `uses ` | nodes using a style/variable id | +| `vars [id]` | variables | +| `pages` | list pages | +| `status` | file name/version/node count | +| `run ` | fire N requests of the derived mixed workload, streaming each line live, then a burst percentile table | +| `report` | cumulative per-tool percentiles for everything fired this session | +| `api node ` / `api meta` | real-file mode only — one live Figma API call (`/nodes` or `/meta`), timed the same way and labeled with the API cost it spent; a 429 prints its `Retry-After` in red and the REPL keeps going | +| `call ` | raw escape hatch (works for proxied tools too, when an upstream is attached) | +| `help` | this table | +| `quit` | exit cleanly (EOF also works — the serve child is always reaped, never left a zombie) | + +Sample transcript (same machine as above: Apple M4, 16GB, `cargo run +--release -p figmog -- bench --nodes 10000 --interactive`, piped +non-interactively so this is plain text — a real terminal shows it in +color): + +```console +$ printf 'search garden\nnode 1:1\nrun 8\nreport\nquit\n' \ + | cargo run --release -p figmog -- bench --nodes 10000 --interactive +corpus [synthetic] 10000 nodes, 2391449 bytes, 54.7ms +cold sync 36.9ms flatten + 139.2ms sync, 10005 records (71850 records/s) +re-pull 6.3ms, churn zero: true + +figmog bench --interactive — type `help` for commands, `quit` to exit. +# 1 figmog_search {"query":"garden"} 0.07ms 0 hits +# 2 figmog_node {"id":"1:1"} 0.08ms Button +# 3 figmog_search {"query":"Nav"} 0.42ms 10 hits +# 4 figmog_node {"id":"25:177"} 0.04ms Slider Body Banner Table Toolbar Button +# 5 figmog_where {"equals":"VERTICAL","pointer":" 13.51ms 1809 hits +# 6 figmog_stats {} 20.49ms ok +# 7 figmog_tree {"depth":2} 3.37ms Document +# 8 figmog_instances {"target":"Button"} 0.66ms 407 hits +# 9 figmog_search {"query":"12"} 0.04ms 1 hits +# 10 figmog_node {"id":"37:78"} 0.05ms Grid Field Preview Progress Divider Toggle Row Header + +tool calls p50 (ms) p95 (ms) p99 (ms) max (ms) +figmog_search 2 0.040 0.040 0.040 0.422 +figmog_node 2 0.041 0.041 0.041 0.051 +figmog_where 1 13.508 13.508 13.508 13.508 +figmog_stats 1 20.490 20.490 20.490 20.490 +figmog_tree 1 3.369 3.369 3.369 3.369 +figmog_instances 1 0.656 0.656 0.656 0.656 + +tool calls p50 (ms) p95 (ms) p99 (ms) max (ms) +figmog_search 3 0.071 0.071 0.071 0.422 +figmog_node 3 0.051 0.051 0.051 0.076 +figmog_where 1 13.508 13.508 13.508 13.508 +figmog_stats 1 20.490 20.490 20.490 20.490 +figmog_tree 1 3.369 3.369 3.369 3.369 +figmog_instances 1 0.656 0.656 0.656 0.656 +``` + +`search garden`/`node 1:1` are the first two typed commands; `run 8` +streams 8 requests of the derived mixed workload live (lines 3-10) then +prints its own burst table; `report` prints the session's cumulative +table (same six tools, now 3 `figmog_search`/3 `figmog_node` calls +counted). `quit` closes stdin to the serve child and waits for it to +exit — no zombie process left behind. + +**Against a real file** (`figmog bench --interactive`, needs +`FIGMA_TOKEN`), the `api node ` / `api meta` commands become the +demo's centerpiece: firing one alongside a `node ` for the same id +puts figmog's local read and Figma's real Tier-1 API call side by side, +live. Illustrative shape (not a captured run — no token in this repo's +CI/dev environment — but the format is exactly what `format_latency_line` +and the `api node` line print): + +``` +# 11 figmog_node {"id":"1:234"} 0.05ms Icon/Star +# 12 API node 1:234 ~400.00ms ok (spent 1 Tier-1 call) +``` + +figmog's read is a local, indexed point lookup (sub-millisecond); the API +call pays a real network round trip — that gap, live, is the whole +pitch. Every `api …` call spends real rate-limit budget (Figma's Tier-1 files +allow ~10/minute) — the line's `(spent …)` note says exactly what it +cost, and a 429 prints its `Retry-After` in red instead of exiting. + ## Manual live check Not run in CI (needs a real `FIGMA_TOKEN` and a real file); this is how to diff --git a/examples/figmog/src/bench.rs b/examples/figmog/src/bench.rs index 11bd1d2..1886595 100644 --- a/examples/figmog/src/bench.rs +++ b/examples/figmog/src/bench.rs @@ -20,6 +20,14 @@ //! the load-test's query mix (search words, node ids, the instances //! target) is always *derived* from the flattened records, never //! hardcoded — see [`derive_query_pool`]. +//! +//! Phases 1-3 (`prepare`) are shared by two entry points: [`run`] (this +//! module's automated one-shot phases 4/5 above) and [`run_interactive`] +//! (`--interactive`, build design §13 "Interactive mode"), which spawns +//! the same serve child via [`BenchSession`] and hands it to +//! [`crate::repl::run`] for a live REPL instead of the automated load/API +//! phases. [`BenchSession::fire`] — one raw `tools/call` frame, timed — is +//! the primitive both the one-shot load phase and the REPL drive. use std::collections::BTreeSet; use std::io::{BufRead, BufReader, Write}; @@ -338,8 +346,11 @@ pub fn generate_corpus(nodes: usize) -> Value { /// flattened records rather than hardcoded — the same derivation runs in /// synthetic mode (the corpus's own generated names/text) and real-file /// mode (whatever's actually in the file), so bench exercises one code -/// path regardless of source. -struct QueryPool { +/// path regardless of source. `Clone` so [`BenchSession`] can own its own +/// copy while [`PreparedBench`] keeps the original for the API comparison +/// phase. +#[derive(Clone)] +pub(crate) struct QueryPool { /// Distinct words drawn from node names and TEXT `characters`, sorted /// (a `BTreeSet` collection — deterministic, never a `HashMap`). words: Vec, @@ -506,19 +517,43 @@ pub struct BenchOpts { pub skip_api: bool, } -// ---- run ---- +// ---- setup shared by one-shot `run` and interactive `run_interactive` ---- + +/// Phases 1-3 (corpus → cold sync → no-churn re-pull), assembled once and +/// reused by both entry points: [`run`]'s automated phase 4/5, and +/// [`run_interactive`]'s REPL. Owns the temp store's cleanup ([`TempDirGuard`]) +/// so it survives exactly as long as whichever caller holds this struct. +struct PreparedBench { + source: &'static str, + corpus: CorpusStats, + cold: ColdStats, + repull: RepullStats, + pool: QueryPool, + db_path: PathBuf, + exe: PathBuf, + /// Real-file mode only (see [`BenchOpts::file`]). + file_key: Option, + /// Real-file mode only: the same authenticated client phase 1 used to + /// fetch the file, reused for the API comparison phase (one-shot) or + /// the REPL's `api …` commands (interactive) instead of re-reading + /// `FIGMA_TOKEN`. + api_for_comparison: Option, + tmp_dir: PathBuf, + keep: bool, + #[allow(dead_code)] // held only for its Drop + cleanup: TempDirGuard, +} -/// Run every phase and return the assembled report, or `Err` if any phase -/// fails or any tool call comes back `isError` (a graceful 429 in the API -/// comparison phase is a *recorded* result, not a failure — see -/// [`ApiStats`]). -pub fn run(opts: BenchOpts) -> Result { +/// Phases 1-3 of [`run`]/[`run_interactive`]: corpus (synthetic generation +/// or one real-file Tier-1 fetch), cold sync into a fresh temp store, and a +/// no-churn re-pull of the identical in-memory data (the engine's headline +/// invariant, asserted in code). Returns `Err` if any phase fails or the +/// re-pull churns. +fn prepare(opts: &BenchOpts) -> Result { // ---- phase 1: corpus ---- // `vars_resp` carries the opportunistic Enterprise `variables_local` // response (real-file mode only, like `do_pull` — spec §12); `Ok(None)` - // on non-Enterprise plans is not an error. `api_for_comparison` is kept - // alive only so phase 5 can reuse the same authenticated client instead - // of re-reading `FIGMA_TOKEN`. + // on non-Enterprise plans is not an error. let (resp, vars_resp, api_for_comparison, source, gen_ms): ( Value, Option, @@ -601,28 +636,8 @@ pub fn run(opts: BenchOpts) -> Result { // ---- derived query mix (one code path for both modes) ---- let pool = derive_query_pool(&flattened); - // ---- phase 4: serve load ---- - let load = run_load_phase(&opts, &db_path, &pool)?; - - // ---- phase 5: API comparison (real-file mode only) ---- - let api = match (&opts.file, api_for_comparison) { - (Some(key), Some(api)) if !opts.skip_api => Some(run_api_comparison_phase( - &api, - key, - opts.api_calls, - &pool, - &load, - )?), - _ => None, - }; - - if opts.keep { - eprintln!("figmog: kept temp store at {}", tmp_dir.display()); - } - drop(cleanup); - - Ok(BenchReport { - source: source.to_string(), + Ok(PreparedBench { + source, corpus: CorpusStats { nodes: node_count, bytes, @@ -638,9 +653,203 @@ pub fn run(opts: BenchOpts) -> Result { ms: repull_ms, churn_zero: true, }, + pool, + db_path, + exe: opts.exe.clone(), + file_key: opts.file.clone(), + api_for_comparison, + tmp_dir, + keep: opts.keep, + cleanup, + }) +} + +/// Owns the spawned `figmog serve` child, its stdio pump, the derived query +/// pool, and cumulative per-call stats — the shared primitive behind both +/// the one-shot load phase ([`run_load_phase`]) and the interactive REPL +/// ([`crate::repl::run`]). [`BenchSession::fire`] is the one thing both +/// drive a `tools/call` frame over the child's stdio pipe with. +pub(crate) struct BenchSession { + guard: ChildGuard, + stdin: Option, + rx: Receiver, + pool: QueryPool, + rotation: Vec<&'static str>, + rng: Lcg, + next_id: i64, + mix_counter: usize, + stats: Vec<(String, Duration)>, +} + +impl BenchSession { + /// Spawn `figmog serve --no-upstream --no-watch --db ` and complete + /// the MCP handshake. `Err` if the pool has nothing to query at all + /// (spec §13: "nothing to load-test") or the handshake fails. + pub(crate) fn start( + exe: &std::path::Path, + db_path: &std::path::Path, + pool: QueryPool, + ) -> Result { + let rotation = tool_rotation(&pool); + if rotation.is_empty() { + return Err("bench corpus has neither searchable words, nodes, nor components — nothing to load-test".into()); + } + let (guard, mut stdin, rx) = spawn_serve(exe, db_path); + + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }), + )?; + recv(&rx)?; // initialize response; contents not needed here + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + )?; + + Ok(BenchSession { + guard, + stdin: Some(stdin), + rx, + pool, + rotation, + rng: Lcg::new(LCG_SEED), + next_id: 2, + mix_counter: 0, + stats: Vec::new(), + }) + } + + /// Fire one raw `tools/call` frame, timed write→response-line, and + /// record it into the session's cumulative stats. `Err` only on a + /// transport failure (send/recv/parse) — a tool result with + /// `isError: true` is still `Ok`, so callers (the one-shot load loop, + /// the REPL) each decide how to react to it. + pub(crate) fn fire(&mut self, tool: &str, args: Value) -> Result<(Duration, Value), String> { + let stdin = self + .stdin + .as_mut() + .ok_or_else(|| "bench session already finished".to_string())?; + let req_id = self.next_id; + self.next_id += 1; + + let start = Instant::now(); + send( + stdin, + &json!({ + "jsonrpc": "2.0", + "id": req_id, + "method": "tools/call", + "params": {"name": tool, "arguments": args}, + }), + )?; + let resp = recv(&self.rx)?; + let elapsed = start.elapsed(); + self.stats.push((tool.to_string(), elapsed)); + Ok((elapsed, resp)) + } + + /// Next `(tool, args)` pair in the fixed rotating mix (spec §13), + /// derived from this session's query pool — continues the same + /// rotation across however many calls this session has already fired. + pub(crate) fn next_mixed_call(&mut self) -> (&'static str, Value) { + let tool = self.rotation[self.mix_counter % self.rotation.len()]; + self.mix_counter += 1; + let args = call_args(tool, &self.pool, &mut self.rng); + (tool, args) + } + + /// Every `(tool, elapsed)` fired this session, in fire order. + pub(crate) fn stats(&self) -> &[(String, Duration)] { + &self.stats + } + + /// Close stdin (EOF — how `--no-watch` serve exits cleanly) and wait + /// for the child to exit. Idempotent: a second call just re-waits an + /// already-exited child. `ChildGuard`'s `Drop` is the safety net if + /// this is never reached (an early `?` return, a panic) — it + /// kills+waits unconditionally, so the child is never left a zombie. + pub(crate) fn finish(&mut self) -> Result<(), String> { + self.stdin.take(); // dropped here -> EOF on the child's stdin + wait_with_timeout(&mut self.guard.0, EXIT_TIMEOUT) + } +} + +// ---- run ---- + +/// Run every phase and return the assembled report, or `Err` if any phase +/// fails or any tool call comes back `isError` (a graceful 429 in the API +/// comparison phase is a *recorded* result, not a failure — see +/// [`ApiStats`]). +pub fn run(opts: BenchOpts) -> Result { + let prepared = prepare(&opts)?; + + // ---- phase 4: serve load ---- + let load = run_load_phase(&opts, &prepared.exe, &prepared.db_path, &prepared.pool)?; + + // ---- phase 5: API comparison (real-file mode only) ---- + let api = match (&prepared.file_key, &prepared.api_for_comparison) { + (Some(key), Some(api)) if !opts.skip_api => Some(run_api_comparison_phase( + api, + key, + opts.api_calls, + &prepared.pool, + &load, + )?), + _ => None, + }; + + let report = BenchReport { + source: prepared.source.to_string(), + corpus: prepared.corpus, + cold: prepared.cold, + repull: prepared.repull, load, api, - }) + }; + + if prepared.keep { + eprintln!("figmog: kept temp store at {}", prepared.tmp_dir.display()); + } + // `prepared` (and its `TempDirGuard`) drops here, cleaning up the temp + // store unless `--keep`. + + Ok(report) +} + +/// `figmog bench --interactive` (build design §13 "Interactive mode"): the +/// same setup as [`run`] (corpus/real file → cold sync → no-churn re-pull → +/// serve child spawn), then a REPL on the terminal instead of the +/// automated load/API phases — requests visible as they fire. +pub fn run_interactive(opts: BenchOpts) -> Result<(), String> { + let mut prepared = prepare(&opts)?; + print_setup_human( + prepared.source, + &prepared.corpus, + &prepared.cold, + &prepared.repull, + ); + println!(); + + let real_file = match (prepared.file_key.take(), prepared.api_for_comparison.take()) { + (Some(key), Some(api)) => Some(crate::repl::RealFileCtx { key, api }), + _ => None, + }; + + let mut session = BenchSession::start(&prepared.exe, &prepared.db_path, prepared.pool.clone())?; + let repl_result = crate::repl::run(&mut session, real_file); + let finish_result = session.finish(); + + if prepared.keep { + eprintln!("figmog: kept temp store at {}", prepared.tmp_dir.display()); + } + + repl_result?; + finish_result } // ---- temp dir management ---- @@ -813,53 +1022,20 @@ fn call_args(tool: &str, pool: &QueryPool, rng: &mut Lcg) -> Value { fn run_load_phase( opts: &BenchOpts, + exe: &std::path::Path, db_path: &std::path::Path, pool: &QueryPool, ) -> Result { - let rotation = tool_rotation(pool); - if rotation.is_empty() { - return Err("bench corpus has neither searchable words, nodes, nor components — nothing to load-test".into()); - } - - let (mut guard, mut stdin, rx) = spawn_serve(&opts.exe, db_path); - - send( - &mut stdin, - &json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, - }), - )?; - recv(&rx)?; // initialize response; contents not needed here - send( - &mut stdin, - &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), - )?; - - let mut rng = Lcg::new(LCG_SEED); - let mut timings: Vec> = vec![Vec::new(); rotation.len()]; + let mut session = BenchSession::start(exe, db_path, pool.clone())?; + let rotation = session.rotation.clone(); + let rotation_len = rotation.len(); + let mut timings: Vec> = vec![Vec::new(); rotation_len]; let wall_start = Instant::now(); for i in 0..opts.calls { - let tool_idx = i % rotation.len(); - let tool = rotation[tool_idx]; - let args = call_args(tool, pool, &mut rng); - let req_id = (i as i64) + 2; - - let start = Instant::now(); - send( - &mut stdin, - &json!({ - "jsonrpc": "2.0", - "id": req_id, - "method": "tools/call", - "params": {"name": tool, "arguments": args}, - }), - )?; - let resp = recv(&rx)?; - let elapsed = start.elapsed(); + let tool_idx = i % rotation_len; + let (tool, args) = session.next_mixed_call(); + let (elapsed, resp) = session.fire(tool, args)?; if resp["result"]["isError"] == json!(true) { let text = resp["result"]["content"][0]["text"] @@ -873,10 +1049,9 @@ fn run_load_phase( } let wall = wall_start.elapsed(); - drop(stdin); // stdin EOF: how `--no-watch` serve exits cleanly - wait_with_timeout(&mut guard.0, EXIT_TIMEOUT)?; + session.finish()?; - let mut per_tool = Vec::with_capacity(rotation.len()); + let mut per_tool = Vec::with_capacity(rotation_len); for (tool, mut durations) in rotation.into_iter().zip(timings) { durations.sort(); per_tool.push(ToolStats { @@ -904,6 +1079,36 @@ fn run_load_phase( }) } +/// Group timed calls by tool, preserving each tool's first-appearance +/// order (never a `HashMap` — spec's no-HashMap-iteration-order-at-an- +/// output-boundary rule applies here too). Used by the REPL's `run N` +/// burst table and cumulative `report` table, where (unlike the one-shot +/// load phase's fixed rotation) the set of tools fired isn't known ahead +/// of time. +pub(crate) fn group_tool_stats(entries: &[(String, Duration)]) -> Vec { + let mut grouped: Vec<(String, Vec)> = Vec::new(); + for (tool, d) in entries { + match grouped.iter_mut().find(|(t, _)| t == tool) { + Some((_, durations)) => durations.push(*d), + None => grouped.push((tool.clone(), vec![*d])), + } + } + grouped + .into_iter() + .map(|(tool, mut durations)| { + durations.sort(); + ToolStats { + tool, + calls: durations.len(), + p50_ms: percentile_ms(&durations, 50), + p95_ms: percentile_ms(&durations, 95), + p99_ms: percentile_ms(&durations, 99), + max_ms: max_ms(&durations), + } + }) + .collect() +} + // ---- API comparison phase (real-file mode) ---- fn run_api_comparison_phase( @@ -993,32 +1198,46 @@ fn run_api_comparison_phase( // ---- human-readable report ---- -/// The phase lines + per-tool table + headline (`--json`'s alternative; -/// stdout purity means callers pick exactly one). -pub fn print_human(report: &BenchReport) { +/// The corpus/cold-sync/re-pull setup lines, shared by [`print_human`] +/// (the one-shot report) and [`run_interactive`] (printed once before the +/// REPL takes over). +fn print_setup_human(source: &str, corpus: &CorpusStats, cold: &ColdStats, repull: &RepullStats) { println!( - "corpus [{}] {} nodes, {} bytes, {:.1}ms", - report.source, report.corpus.nodes, report.corpus.bytes, report.corpus.gen_ms + "corpus [{source}] {} nodes, {} bytes, {:.1}ms", + corpus.nodes, corpus.bytes, corpus.gen_ms ); println!( "cold sync {:.1}ms flatten + {:.1}ms sync, {} records ({:.0} records/s)", - report.cold.flatten_ms, report.cold.sync_ms, report.cold.records, report.cold.records_per_s + cold.flatten_ms, cold.sync_ms, cold.records, cold.records_per_s ); println!( "re-pull {:.1}ms, churn zero: {}", - report.repull.ms, report.repull.churn_zero + repull.ms, repull.churn_zero ); - println!(); +} + +/// The per-tool percentile table, shared by [`print_human`] (the one-shot +/// load phase's fixed rotation) and the REPL's `run N`/`report` commands +/// (an arbitrary set of tools, grouped by [`group_tool_stats`]). +pub(crate) fn print_tool_table(per_tool: &[ToolStats]) { println!( "{:<18} {:>8} {:>10} {:>10} {:>10} {:>10}", "tool", "calls", "p50 (ms)", "p95 (ms)", "p99 (ms)", "max (ms)" ); - for t in &report.load.per_tool { + for t in per_tool { println!( "{:<18} {:>8} {:>10.3} {:>10.3} {:>10.3} {:>10.3}", t.tool, t.calls, t.p50_ms, t.p95_ms, t.p99_ms, t.max_ms ); } +} + +/// The phase lines + per-tool table + headline (`--json`'s alternative; +/// stdout purity means callers pick exactly one). +pub fn print_human(report: &BenchReport) { + print_setup_human(&report.source, &report.corpus, &report.cold, &report.repull); + println!(); + print_tool_table(&report.load.per_tool); println!(); println!( "figmog served {} queries in {:.1}s ({:.0} req/s). Figma's Tier-1 API budget on a free plan: ~10 file requests per MINUTE.", diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index 04c297f..bb01932 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -198,6 +198,11 @@ enum Cmd { /// Leave the temp store on disk and print its path. #[arg(long)] keep: bool, + /// Drop into a live REPL instead of the automated phases (build + /// design §13 "Interactive mode") — watch tool calls fire in real + /// time. Human-only: combining with `--json` is a usage error. + #[arg(long)] + interactive: bool, }, } @@ -232,6 +237,7 @@ fn dispatch(cli: Cli) -> Result<(), String> { api_calls, skip_api, keep, + interactive, } = &cli.cmd { return cmd_bench( @@ -241,6 +247,7 @@ fn dispatch(cli: Cli) -> Result<(), String> { *api_calls, *skip_api, *keep, + *interactive, cli.json, ); } @@ -773,8 +780,9 @@ fn cmd_import_variables(db: &Db, path: PathBuf, json: bool) -> Result<(), String } /// `figmog bench [file] [--nodes N] [--calls M] [--api-calls K] [--skip-api] -/// [--keep]` (build design §13). Needs no resolved `Db` — see `dispatch`'s -/// early handling — so it never touches `.figmog/current` or `--db`. +/// [--keep] [--interactive]` (build design §13). Needs no resolved `Db` — +/// see `dispatch`'s early handling — so it never touches `.figmog/current` +/// or `--db`. #[allow(clippy::too_many_arguments)] fn cmd_bench( file: Option, @@ -783,13 +791,19 @@ fn cmd_bench( api_calls: usize, skip_api: bool, keep: bool, + interactive: bool, json: bool, ) -> Result<(), String> { + if interactive && json { + return Err( + "--interactive is a human-only REPL and cannot be combined with --json".to_string(), + ); + } let file = file .map(|f| parse_file_ref(&f).ok_or_else(|| format!("not a Figma file key or URL: {f}"))) .transpose()?; let exe = std::env::current_exe().map_err(|e| format!("resolving current exe: {e}"))?; - let report = crate::bench::run(crate::bench::BenchOpts { + let opts = crate::bench::BenchOpts { nodes, calls, keep, @@ -797,7 +811,11 @@ fn cmd_bench( file, api_calls, skip_api, - })?; + }; + if interactive { + return crate::bench::run_interactive(opts); + } + let report = crate::bench::run(opts)?; if json { println!( "{}", @@ -1240,8 +1258,10 @@ fn cmd_vars( // ---- whole-file structural queries ---- /// `--equals `: parse as JSON, falling back to treating the bare word -/// as a JSON string (so `--equals VERTICAL` works without quoting). -fn parse_equals(raw: &str) -> Value { +/// as a JSON string (so `--equals VERTICAL` works without quoting). Also +/// used by the interactive REPL's `where [value]` shorthand +/// (`repl::parse_line`) — same fallback semantics there. +pub(crate) fn parse_equals(raw: &str) -> Value { serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string())) } diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs index de42f7e..d0c2982 100644 --- a/examples/figmog/src/lib.rs +++ b/examples/figmog/src/lib.rs @@ -20,6 +20,7 @@ pub mod mcp; pub mod model; mod proxy; pub mod query; +mod repl; pub mod serve; pub mod store; pub mod upstream; diff --git a/examples/figmog/src/repl.rs b/examples/figmog/src/repl.rs new file mode 100644 index 0000000..b3ef343 --- /dev/null +++ b/examples/figmog/src/repl.rs @@ -0,0 +1,998 @@ +//! `figmog bench --interactive` — the live REPL (build design §13 +//! "Interactive mode"). Requests visible as they fire: one aligned line per +//! tool call (sequence number, tool, arg digest, latency, result digest), +//! a live mixed-workload burst (`run N`), cumulative session percentiles +//! (`report`), and (real-file mode only) live Figma API comparison calls +//! (`api node ` / `api meta`). +//! +//! Colors are raw ANSI escapes, emitted only when stdout is a terminal +//! ([`std::io::IsTerminal`]) — piped/non-TTY stdout is always plain text, +//! zero `\x1b` bytes (the scripted e2e in `tests/cli.rs` asserts this). +//! No readline: plain `stdin().read_line` is enough for a demo REPL (spec +//! §13's non-goals). + +use std::io::{self, BufRead, IsTerminal, Write}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use crate::api::{ApiError, FigmaApi, UreqApi}; +use crate::bench::{BenchSession, group_tool_stats, print_tool_table}; +use crate::cli::parse_equals; + +/// Enables the `api node ` / `api meta` commands — real-file mode +/// only. `key` is the Figma file key `figmog bench` resolved at startup; +/// `api` is the same authenticated client phase 1 used for the initial +/// fetch (no extra `FIGMA_TOKEN` read). +pub(crate) struct RealFileCtx { + pub(crate) key: String, + pub(crate) api: UreqApi, +} + +/// One parsed REPL input line. Every tool shorthand (`search`, `node`, +/// `tree`, …) parses straight to `Tool { name, args }` — a ready-to-fire +/// `figmog_*` tool name plus the JSON arguments the shorthand builds, so +/// the dispatch loop doesn't need to know the shorthand grammar at all. +#[derive(Debug, PartialEq)] +pub(crate) enum Command { + Help, + Quit, + Run(usize), + Report, + Api(ApiCmd), + /// The raw `call ` escape hatch — a caller-supplied + /// tool name (works for proxied tools too, when an upstream is + /// attached) and already-parsed JSON arguments. + Call { + tool: String, + args: Value, + }, + /// A tool shorthand, already resolved to a `figmog_*` tool name and + /// its JSON arguments. + Tool { + name: String, + args: Value, + }, +} + +#[derive(Debug, PartialEq)] +pub(crate) enum ApiCmd { + Node(String), + Meta, +} + +fn tool_cmd(name: &str, args: Value) -> Command { + Command::Tool { + name: name.to_string(), + args, + } +} + +/// Parse one REPL input line into a [`Command`]. `Err` carries a +/// human-readable message the REPL prints as-is (never a panic on bad +/// input — this is the only thing standing between a typo and a crashed +/// demo). +pub(crate) fn parse_line(line: &str) -> Result { + let line = line.trim(); + let mut tokens = line.split_whitespace(); + let cmd = tokens.next().ok_or_else(|| "empty command".to_string())?; + let rest: Vec<&str> = tokens.collect(); + + match cmd { + "help" => Ok(Command::Help), + "quit" => Ok(Command::Quit), + "report" => Ok(Command::Report), + + "run" => { + let n = rest + .first() + .ok_or_else(|| "run: missing count, e.g. `run 20`".to_string())?; + let n: usize = n.parse().map_err(|_| format!("run: not a number: {n}"))?; + Ok(Command::Run(n)) + } + + "api" => match rest.first() { + Some(&"node") => { + let id = rest + .get(1) + .ok_or_else(|| "api node: missing id".to_string())?; + Ok(Command::Api(ApiCmd::Node((*id).to_string()))) + } + Some(&"meta") => Ok(Command::Api(ApiCmd::Meta)), + Some(other) => Err(format!("api: unknown subcommand: {other}")), + None => Err("api: missing subcommand (node | meta)".to_string()), + }, + + "call" => { + // Reconstruct from the original (untokenized) line so JSON + // arguments keep their exact spacing/quoting — `rest.join(" ")` + // would collapse runs of whitespace inside string literals. + let after_cmd = line["call".len()..].trim_start(); + let mut it = after_cmd.splitn(2, char::is_whitespace); + let tool = it + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| "call: missing tool name".to_string())?; + let args_raw = it.next().unwrap_or("").trim(); + let args: Value = if args_raw.is_empty() { + json!({}) + } else { + serde_json::from_str(args_raw) + .map_err(|e| format!("call: invalid JSON args: {e}"))? + }; + Ok(Command::Call { + tool: tool.to_string(), + args, + }) + } + + // ---- tool shorthands (spec §13's list) ---- + "status" => Ok(tool_cmd("figmog_status", json!({}))), + "pages" => Ok(tool_cmd("figmog_pages", json!({}))), + "components" => Ok(tool_cmd("figmog_components", json!({}))), + "stats" => Ok(tool_cmd("figmog_stats", json!({}))), + + "search" => { + if rest.is_empty() { + return Err("search: missing query words, e.g. `search button`".to_string()); + } + Ok(tool_cmd("figmog_search", json!({"query": rest.join(" ")}))) + } + + "node" => { + let id = rest.first().ok_or_else(|| "node: missing id".to_string())?; + let mut args = json!({"id": id}); + if rest.get(1) == Some(&"children") { + args["children"] = json!(true); + } + Ok(tool_cmd("figmog_node", args)) + } + + "tree" => { + let mut args = json!({}); + if let Some(id) = rest.first() { + args["id"] = json!(*id); + } + if let Some(depth) = rest.get(1) { + let d: usize = depth + .parse() + .map_err(|_| format!("tree: depth not a number: {depth}"))?; + args["depth"] = json!(d); + } + Ok(tool_cmd("figmog_tree", args)) + } + + "find" => { + let ty = rest + .first() + .ok_or_else(|| "find: missing TYPE, e.g. `find FRAME`".to_string())?; + let mut args = json!({"type": ty}); + if let Some(page) = rest.get(1) { + args["page"] = json!(*page); + } + Ok(tool_cmd("figmog_find", args)) + } + + "where" => { + let pointer = rest + .first() + .ok_or_else(|| "where: missing pointer, e.g. `where /layoutMode`".to_string())?; + let mut args = json!({"pointer": pointer}); + if rest.len() > 1 { + args["equals"] = parse_equals(&rest[1..].join(" ")); + } + Ok(tool_cmd("figmog_where", args)) + } + + "path" => { + let id = rest.first().ok_or_else(|| "path: missing id".to_string())?; + Ok(tool_cmd("figmog_path", json!({"id": id}))) + } + + "text" => { + let mut args = json!({}); + if let Some(page) = rest.first() { + args["page"] = json!(*page); + } + Ok(tool_cmd("figmog_text", args)) + } + + "at" => { + let x: f64 = rest + .first() + .ok_or_else(|| "at: missing x, e.g. `at 100 200`".to_string())? + .parse() + .map_err(|_| "at: x is not a number".to_string())?; + let y: f64 = rest + .get(1) + .ok_or_else(|| "at: missing y, e.g. `at 100 200`".to_string())? + .parse() + .map_err(|_| "at: y is not a number".to_string())?; + Ok(tool_cmd("figmog_at", json!({"x": x, "y": y}))) + } + + "instances" => { + if rest.is_empty() { + return Err("instances: missing target".to_string()); + } + Ok(tool_cmd( + "figmog_instances", + json!({"target": rest.join(" ")}), + )) + } + + "styles" => { + let mut args = json!({}); + if let Some(t) = rest.first() { + args["type"] = json!(*t); + } + Ok(tool_cmd("figmog_styles", args)) + } + + "uses" => { + let id = rest.first().ok_or_else(|| "uses: missing id".to_string())?; + Ok(tool_cmd("figmog_uses", json!({"id": id}))) + } + + "vars" => { + let mut args = json!({}); + if let Some(id) = rest.first() { + args["id"] = json!(*id); + } + Ok(tool_cmd("figmog_vars", args)) + } + + other => Err(format!("unknown command: {other} (try `help`)")), + } +} + +// ---- color / formatting ---- + +enum Color { + Green, + Yellow, + Red, +} + +/// Raw ANSI escapes, only when `tty` — non-TTY stdout gets `s` back +/// unchanged (zero `\x1b` bytes, spec §13). +fn paint(s: &str, color: Color, tty: bool) -> String { + if !tty { + return s.to_string(); + } + let code = match color { + Color::Green => "32", + Color::Yellow => "33", + Color::Red => "31", + }; + format!("\x1b[{code}m{s}\x1b[0m") +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + s.chars().take(max).collect() + } +} + +fn compact_json(v: &Value) -> String { + serde_json::to_string(v).unwrap_or_default() +} + +/// Latency thresholds per spec §13: green under 10ms, yellow under 100ms, +/// red at/above — errors are always red regardless of how fast they came +/// back. +fn colorize_ms(ms: f64, tty: bool, is_error: bool) -> String { + let s = format!("{ms:>8.2}ms"); + let color = if is_error { + Color::Red + } else if ms < 10.0 { + Color::Green + } else if ms < 100.0 { + Color::Yellow + } else { + Color::Red + }; + paint(&s, color, tty) +} + +/// Result digest per spec §13: hit count for array results, `name` for +/// node-shaped (or any named-object) results, the `isError` text for +/// errors. Anything else (an object with no `name`, e.g. `figmog_stats`) +/// falls back to `"ok"` — the digest's job is a live pulse, not a full +/// dump. +fn result_digest(resp: &Value) -> (String, bool) { + let is_error = resp["result"]["isError"] == json!(true); + let text = resp["result"]["content"][0]["text"].as_str().unwrap_or(""); + if is_error { + return (text.to_string(), true); + } + let digest = match serde_json::from_str::(text) { + Ok(Value::Array(a)) => format!("{} hits", a.len()), + Ok(Value::Object(o)) => o + .get("name") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| "ok".to_string()), + _ => "ok".to_string(), + }; + (digest, false) +} + +/// `#{seq:>4} {tool:<18} {args_digest:<32} {ms:>8.2}ms {digest}` (spec +/// §13). Plain-mode (`tty: false`) output is asserted byte-for-byte in +/// unit tests; TTY mode only adds ANSI color around the latency/digest +/// tokens, so the column layout is identical either way. +pub(crate) fn format_latency_line( + seq: usize, + tool: &str, + args: &Value, + elapsed: Duration, + resp: &Value, + tty: bool, +) -> String { + let ms = elapsed.as_secs_f64() * 1000.0; + let args_digest = truncate(&compact_json(args), 32); + let (digest, is_error) = result_digest(resp); + let ms_str = colorize_ms(ms, tty, is_error); + let digest_str = if is_error { + paint(&digest, Color::Red, tty) + } else { + digest + }; + format!("#{seq:>4} {tool:<18} {args_digest:<32} {ms_str} {digest_str}") +} + +#[allow(clippy::too_many_arguments)] +fn print_api_line( + seq: usize, + label: &str, + arg_digest: &str, + ms: f64, + is_error: bool, + digest: &str, + note: &str, + tty: bool, +) { + let ms_str = colorize_ms(ms, tty, is_error); + let digest_str = if is_error { + paint(digest, Color::Red, tty) + } else { + digest.to_string() + }; + println!("#{seq:>4} {label:<18} {arg_digest:<32} {ms_str} {digest_str} ({note})"); +} + +fn cmd_api_node(ctx: &RealFileCtx, id: &str, tty: bool, seq: &mut usize) { + *seq += 1; + let arg_digest = truncate(id, 32); + let start = Instant::now(); + match ctx.api.file_nodes(&ctx.key, id) { + Ok(_) => { + let ms = start.elapsed().as_secs_f64() * 1000.0; + print_api_line( + *seq, + "API node", + &arg_digest, + ms, + false, + "ok", + "spent 1 Tier-1 call", + tty, + ); + } + Err(ApiError::RateLimited { retry_after }) => { + let ms = start.elapsed().as_secs_f64() * 1000.0; + let msg = format!("429 — retry after {}s", retry_after.as_secs()); + print_api_line( + *seq, + "API node", + &arg_digest, + ms, + true, + &msg, + "spent 1 Tier-1 call (rate-limited)", + tty, + ); + } + Err(e) => { + let ms = start.elapsed().as_secs_f64() * 1000.0; + print_api_line( + *seq, + "API node", + &arg_digest, + ms, + true, + &e.to_string(), + "spent 1 Tier-1 call", + tty, + ); + } + } +} + +fn cmd_api_meta(ctx: &RealFileCtx, tty: bool, seq: &mut usize) { + *seq += 1; + let start = Instant::now(); + match ctx.api.file_meta(&ctx.key) { + Ok(m) => { + let ms = start.elapsed().as_secs_f64() * 1000.0; + print_api_line( + *seq, + "API meta", + "", + ms, + false, + &m.name, + "spent 1 Tier-3 call", + tty, + ); + } + Err(ApiError::RateLimited { retry_after }) => { + let ms = start.elapsed().as_secs_f64() * 1000.0; + let msg = format!("429 — retry after {}s", retry_after.as_secs()); + print_api_line( + *seq, + "API meta", + "", + ms, + true, + &msg, + "spent 1 Tier-3 call (rate-limited)", + tty, + ); + } + Err(e) => { + let ms = start.elapsed().as_secs_f64() * 1000.0; + print_api_line( + *seq, + "API meta", + "", + ms, + true, + &e.to_string(), + "spent 1 Tier-3 call", + tty, + ); + } + } +} + +fn fire_and_print(session: &mut BenchSession, tool: &str, args: Value, tty: bool, seq: &mut usize) { + *seq += 1; + match session.fire(tool, args.clone()) { + Ok((elapsed, resp)) => println!( + "{}", + format_latency_line(*seq, tool, &args, elapsed, &resp, tty) + ), + Err(e) => println!( + "{}", + paint( + &format!("#{seq:>4} {tool:<18} transport error: {e}"), + Color::Red, + tty + ) + ), + } +} + +/// `run N`: fire N requests of the derived mixed workload, streaming one +/// line per request as it fires, then the burst's own percentile table. +fn run_burst(session: &mut BenchSession, n: usize, tty: bool, seq: &mut usize) { + let start_idx = session.stats().len(); + for _ in 0..n { + let (tool, args) = session.next_mixed_call(); + *seq += 1; + match session.fire(tool, args.clone()) { + Ok((elapsed, resp)) => println!( + "{}", + format_latency_line(*seq, tool, &args, elapsed, &resp, tty) + ), + Err(e) => { + println!( + "{}", + paint( + &format!("#{seq:>4} {tool:<18} transport error: {e}"), + Color::Red, + tty + ) + ); + break; // the child is gone; no point spamming N more errors + } + } + } + let burst = &session.stats()[start_idx..]; + println!(); + print_tool_table(&group_tool_stats(burst)); +} + +fn print_help() { + println!("commands:"); + println!(" search BM25 search over layer names/text"); + println!(" node [children] full node JSON"); + println!(" tree [id] [depth] subtree outline"); + println!(" find [page] nodes by Figma node type"); + println!(" where [value] nodes matching an RFC 6901 pointer"); + println!(" stats node counts, totals, max depth"); + println!(" path ancestor chain to a node"); + println!(" text [page] every TEXT node's characters"); + println!(" at nodes containing a point"); + println!(" instances instances of a component"); + println!(" components design-system inventory"); + println!(" styles [type] styles with usage counts"); + println!(" uses nodes using a style/variable id"); + println!(" vars [id] variables"); + println!(" pages list pages"); + println!(" status file name/version/node count"); + println!(" run fire N requests of the mixed workload"); + println!(" report cumulative per-tool percentiles this session"); + println!(" api node | api meta real Figma API call (real-file mode only)"); + println!(" call raw escape hatch"); + println!(" help this text"); + println!(" quit exit (EOF also works)"); +} + +/// Drive the REPL to completion: reads lines from stdin until `quit` or +/// EOF, firing tool calls against `session` and (in real-file mode) +/// `real_file`'s API client. Never touches the child's lifecycle beyond +/// `session.fire` — the caller owns spawn/finish (`bench::run_interactive`). +pub fn run(session: &mut BenchSession, real_file: Option) -> Result<(), String> { + let tty = io::stdout().is_terminal(); + let stdin = io::stdin(); + let mut seq: usize = 0; + + println!("figmog bench --interactive — type `help` for commands, `quit` to exit."); + + loop { + if tty { + print!("figmog> "); + io::stdout().flush().map_err(|e| e.to_string())?; + } + + let mut line = String::new(); + let n = stdin + .lock() + .read_line(&mut line) + .map_err(|e| format!("reading stdin: {e}"))?; + if n == 0 { + break; // EOF + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + match parse_line(trimmed) { + Err(msg) => println!("{}", paint(&format!("error: {msg}"), Color::Red, tty)), + Ok(Command::Help) => print_help(), + Ok(Command::Quit) => break, + Ok(Command::Run(n)) => run_burst(session, n, tty, &mut seq), + Ok(Command::Report) => { + println!(); + print_tool_table(&group_tool_stats(session.stats())); + } + Ok(Command::Api(api_cmd)) => match &real_file { + None => println!( + "api: real-file mode only — pass a Figma file to `figmog bench --interactive`" + ), + Some(ctx) => match api_cmd { + ApiCmd::Node(id) => cmd_api_node(ctx, &id, tty, &mut seq), + ApiCmd::Meta => cmd_api_meta(ctx, tty, &mut seq), + }, + }, + Ok(Command::Call { tool, args }) | Ok(Command::Tool { name: tool, args }) => { + fire_and_print(session, &tool, args, tty, &mut seq); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- parse_line: happy paths ---- + + #[test] + fn parses_help_quit_report() { + assert_eq!(parse_line("help"), Ok(Command::Help)); + assert_eq!(parse_line("quit"), Ok(Command::Quit)); + assert_eq!(parse_line("report"), Ok(Command::Report)); + } + + #[test] + fn parses_run_n() { + assert_eq!(parse_line("run 20"), Ok(Command::Run(20))); + assert_eq!(parse_line(" run 5 "), Ok(Command::Run(5))); + } + + #[test] + fn parses_api_node_and_meta() { + assert_eq!( + parse_line("api node 12:34"), + Ok(Command::Api(ApiCmd::Node("12:34".to_string()))) + ); + assert_eq!(parse_line("api meta"), Ok(Command::Api(ApiCmd::Meta))); + } + + #[test] + fn parses_call_with_json_args() { + assert_eq!( + parse_line(r#"call figmog_node {"id":"12:34"}"#), + Ok(Command::Call { + tool: "figmog_node".to_string(), + args: json!({"id": "12:34"}), + }) + ); + } + + #[test] + fn parses_call_with_no_args_defaults_to_empty_object() { + assert_eq!( + parse_line("call figmog_stats"), + Ok(Command::Call { + tool: "figmog_stats".to_string(), + args: json!({}), + }) + ); + } + + #[test] + fn parses_every_shorthand_in_spec_13s_list() { + assert_eq!( + parse_line("search garden gnome"), + Ok(Command::Tool { + name: "figmog_search".to_string(), + args: json!({"query": "garden gnome"}), + }) + ); + assert_eq!( + parse_line("node 12:34"), + Ok(Command::Tool { + name: "figmog_node".to_string(), + args: json!({"id": "12:34"}), + }) + ); + assert_eq!( + parse_line("node 12:34 children"), + Ok(Command::Tool { + name: "figmog_node".to_string(), + args: json!({"id": "12:34", "children": true}), + }) + ); + assert_eq!( + parse_line("tree"), + Ok(Command::Tool { + name: "figmog_tree".to_string(), + args: json!({}), + }) + ); + assert_eq!( + parse_line("tree 0:0 2"), + Ok(Command::Tool { + name: "figmog_tree".to_string(), + args: json!({"id": "0:0", "depth": 2}), + }) + ); + assert_eq!( + parse_line("find FRAME"), + Ok(Command::Tool { + name: "figmog_find".to_string(), + args: json!({"type": "FRAME"}), + }) + ); + assert_eq!( + parse_line("find FRAME 1:0"), + Ok(Command::Tool { + name: "figmog_find".to_string(), + args: json!({"type": "FRAME", "page": "1:0"}), + }) + ); + assert_eq!( + parse_line("where /layoutMode"), + Ok(Command::Tool { + name: "figmog_where".to_string(), + args: json!({"pointer": "/layoutMode"}), + }) + ); + assert_eq!( + parse_line("where /layoutMode VERTICAL"), + Ok(Command::Tool { + name: "figmog_where".to_string(), + args: json!({"pointer": "/layoutMode", "equals": "VERTICAL"}), + }) + ); + assert_eq!( + parse_line("where /width 100"), + Ok(Command::Tool { + name: "figmog_where".to_string(), + args: json!({"pointer": "/width", "equals": 100}), + }) + ); + assert_eq!( + parse_line("stats"), + Ok(Command::Tool { + name: "figmog_stats".to_string(), + args: json!({}), + }) + ); + assert_eq!( + parse_line("path 12:34"), + Ok(Command::Tool { + name: "figmog_path".to_string(), + args: json!({"id": "12:34"}), + }) + ); + assert_eq!( + parse_line("text"), + Ok(Command::Tool { + name: "figmog_text".to_string(), + args: json!({}), + }) + ); + assert_eq!( + parse_line("text 1:0"), + Ok(Command::Tool { + name: "figmog_text".to_string(), + args: json!({"page": "1:0"}), + }) + ); + assert_eq!( + parse_line("at 100 200"), + Ok(Command::Tool { + name: "figmog_at".to_string(), + args: json!({"x": 100.0, "y": 200.0}), + }) + ); + assert_eq!( + parse_line("instances Button"), + Ok(Command::Tool { + name: "figmog_instances".to_string(), + args: json!({"target": "Button"}), + }) + ); + assert_eq!( + parse_line("components"), + Ok(Command::Tool { + name: "figmog_components".to_string(), + args: json!({}), + }) + ); + assert_eq!( + parse_line("styles"), + Ok(Command::Tool { + name: "figmog_styles".to_string(), + args: json!({}), + }) + ); + assert_eq!( + parse_line("styles FILL"), + Ok(Command::Tool { + name: "figmog_styles".to_string(), + args: json!({"type": "FILL"}), + }) + ); + assert_eq!( + parse_line("uses S:1"), + Ok(Command::Tool { + name: "figmog_uses".to_string(), + args: json!({"id": "S:1"}), + }) + ); + assert_eq!( + parse_line("vars"), + Ok(Command::Tool { + name: "figmog_vars".to_string(), + args: json!({}), + }) + ); + assert_eq!( + parse_line("vars VariableID:1"), + Ok(Command::Tool { + name: "figmog_vars".to_string(), + args: json!({"id": "VariableID:1"}), + }) + ); + assert_eq!( + parse_line("pages"), + Ok(Command::Tool { + name: "figmog_pages".to_string(), + args: json!({}), + }) + ); + assert_eq!( + parse_line("status"), + Ok(Command::Tool { + name: "figmog_status".to_string(), + args: json!({}), + }) + ); + } + + // ---- parse_line: bad input ---- + + #[test] + fn empty_line_is_an_error() { + assert!(parse_line("").is_err()); + assert!(parse_line(" ").is_err()); + } + + #[test] + fn unknown_command_is_an_error() { + assert!(parse_line("frobnicate").is_err()); + } + + #[test] + fn missing_required_args_are_errors() { + assert!(parse_line("node").is_err()); + assert!(parse_line("search").is_err()); + assert!(parse_line("find").is_err()); + assert!(parse_line("where").is_err()); + assert!(parse_line("path").is_err()); + assert!(parse_line("at").is_err()); + assert!(parse_line("at 1").is_err()); + assert!(parse_line("instances").is_err()); + assert!(parse_line("uses").is_err()); + assert!(parse_line("run").is_err()); + assert!(parse_line("api").is_err()); + assert!(parse_line("api node").is_err()); + assert!(parse_line("call").is_err()); + } + + #[test] + fn non_numeric_run_and_at_and_tree_depth_are_errors() { + assert!(parse_line("run abc").is_err()); + assert!(parse_line("at abc 200").is_err()); + assert!(parse_line("at 100 abc").is_err()); + assert!(parse_line("tree 0:0 abc").is_err()); + } + + #[test] + fn invalid_json_call_args_is_an_error() { + assert!(parse_line("call figmog_node {not json}").is_err()); + } + + #[test] + fn unknown_api_subcommand_is_an_error() { + assert!(parse_line("api bogus").is_err()); + } + + // ---- latency-line formatter (plain mode) ---- + + fn ok_resp(text: &str) -> Value { + json!({"result": {"content": [{"type": "text", "text": text}], "isError": false}}) + } + + fn err_resp(text: &str) -> Value { + json!({"result": {"content": [{"type": "text", "text": text}], "isError": true}}) + } + + #[test] + fn plain_mode_has_no_ansi_bytes() { + let resp = ok_resp(r#"[{"id":"1"},{"id":"2"}]"#); + let line = format_latency_line( + 1, + "figmog_search", + &json!({"query": "hi"}), + Duration::from_millis(3), + &resp, + false, + ); + assert!( + !line.contains('\x1b'), + "plain mode must emit zero ANSI bytes: {line:?}" + ); + } + + #[test] + fn array_result_digest_is_hit_count() { + let resp = ok_resp(r#"[{"id":"1"},{"id":"2"},{"id":"3"}]"#); + let line = format_latency_line( + 1, + "figmog_search", + &json!({"query": "hi"}), + Duration::from_millis(3), + &resp, + false, + ); + assert!( + line.contains("3 hits"), + "expected a hit count digest: {line:?}" + ); + } + + #[test] + fn named_object_result_digest_is_the_name() { + let resp = ok_resp(r#"{"id":"12:34","name":"Button Frame","type":"FRAME"}"#); + let line = format_latency_line( + 1, + "figmog_node", + &json!({"id": "12:34"}), + Duration::from_millis(3), + &resp, + false, + ); + assert!( + line.contains("Button Frame"), + "expected the node's name as digest: {line:?}" + ); + } + + #[test] + fn error_result_digest_is_the_error_text() { + let resp = err_resp("unknown node: 99:99"); + let line = format_latency_line( + 1, + "figmog_node", + &json!({"id": "99:99"}), + Duration::from_millis(3), + &resp, + false, + ); + assert!( + line.contains("unknown node: 99:99"), + "expected the isError text as digest: {line:?}" + ); + } + + #[test] + fn args_digest_is_truncated_to_32_chars() { + let long_query = "a".repeat(64); + let resp = ok_resp("[]"); + let line = format_latency_line( + 1, + "figmog_search", + &json!({"query": long_query}), + Duration::from_millis(1), + &resp, + false, + ); + // The compact JSON of {"query": "aaa...a"} is longer than 64 chars + // itself; just assert the 65-a run got cut down well below its + // untruncated length so it can't have been emitted whole. + assert!( + !line.contains(&"a".repeat(60)), + "args digest should be truncated: {line:?}" + ); + } + + #[test] + fn seq_and_tool_and_ms_are_formatted_and_aligned() { + let resp = ok_resp("[]"); + let line = format_latency_line( + 7, + "figmog_stats", + &json!({}), + Duration::from_micros(1500), + &resp, + false, + ); + assert!( + line.starts_with("# 7 figmog_stats"), + "unexpected line prefix: {line:?}" + ); + assert!( + line.contains("1.50ms"), + "expected a 2-decimal ms value: {line:?}" + ); + } + + // ---- group_tool_stats reachability check (used by run/report) ---- + + #[test] + fn group_tool_stats_preserves_first_appearance_order() { + let entries = vec![ + ("figmog_search".to_string(), Duration::from_millis(1)), + ("figmog_node".to_string(), Duration::from_millis(2)), + ("figmog_search".to_string(), Duration::from_millis(3)), + ]; + let stats = group_tool_stats(&entries); + let names: Vec<&str> = stats.iter().map(|t| t.tool.as_str()).collect(); + assert_eq!(names, vec!["figmog_search", "figmog_node"]); + assert_eq!(stats[0].calls, 2); + assert_eq!(stats[1].calls, 1); + } +} diff --git a/examples/figmog/tests/cli.rs b/examples/figmog/tests/cli.rs index 7d431bf..a8bcfe9 100644 --- a/examples/figmog/tests/cli.rs +++ b/examples/figmog/tests/cli.rs @@ -542,3 +542,54 @@ fn bench_e2e_synthetic_json_report() { assert!(p50 >= 0.0, "p50 should be non-negative: {tool}"); } } + +/// End-to-end smoke for `figmog bench --interactive` (build design §13 +/// "Interactive mode"), driven non-interactively by piping a command +/// script into stdin — this is exactly how CI (a non-TTY pipe) exercises +/// it, and it's also the scenario the "no ANSI in plain mode" guarantee +/// matters for. +#[test] +fn bench_interactive_e2e_scripted_session_is_plain_and_clean() { + let script = "help\nstats\nsearch garden\nrun 20\nreport\nquit\n"; + + let out = Command::cargo_bin("figmog") + .unwrap() + .args(["bench", "--nodes", "300", "--interactive"]) + .write_stdin(script) + .assert() + .success(); + let output = out.get_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + !stdout.as_bytes().contains(&0x1b), + "non-TTY stdout must contain zero ANSI escape bytes:\n{stdout}" + ); + + assert!( + stdout.contains("figmog_search"), + "expected a per-request line naming figmog_search:\n{stdout}" + ); + + // `run 20`: 20 numbered per-request lines, `# 1` through `# 20`. + for n in [1, 20] { + let needle = format!("#{n:>4}"); + assert!( + stdout.contains(&needle), + "expected a `run 20` burst line numbered {n} ({needle:?}):\n{stdout}" + ); + } + + // A report table (headers shared by both `run`'s burst table and + // `report`'s cumulative one). + assert!( + stdout.contains("p50 (ms)") && stdout.contains("p95 (ms)"), + "expected a percentile report table:\n{stdout}" + ); + + // `help`'s command list and a clean `quit`. + assert!( + stdout.contains("commands:"), + "expected `help`'s output:\n{stdout}" + ); +} From 301ee48f6bb471d57b2f90818bb14c6da5831a37 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 18:34:42 -0700 Subject: [PATCH 44/56] test(figmog): cover interactive/json conflict Co-Authored-By: Claude Fable 5 --- examples/figmog/tests/cli.rs | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/examples/figmog/tests/cli.rs b/examples/figmog/tests/cli.rs index a8bcfe9..53002db 100644 --- a/examples/figmog/tests/cli.rs +++ b/examples/figmog/tests/cli.rs @@ -543,6 +543,41 @@ fn bench_e2e_synthetic_json_report() { } } +/// `--interactive` is a human-only REPL (build design §13): combined with +/// `--json` it's a usage error, not a silent pick-one. Exit 1, nothing on +/// stdout, and (since `--json` was set) the error is JSON on stderr — +/// `cli::run`'s top-level error handler emits `{"error": …}` there when +/// `cli.json` is true, matching every other command's `--json` error +/// convention. +#[test] +fn bench_interactive_and_json_is_a_usage_error() { + let assert = Command::cargo_bin("figmog") + .unwrap() + .args(["bench", "--nodes", "300", "--interactive", "--json"]) + .assert() + .failure() + .code(1); + let output = assert.get_output(); + + assert!( + output.stdout.is_empty(), + "stdout must stay empty on a usage error: {:?}", + String::from_utf8_lossy(&output.stdout) + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + let v: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap_or_else(|e| { + panic!("stderr was not exactly one JSON object: {e}\nstderr: {stderr}") + }); + let msg = v["error"] + .as_str() + .expect("stderr JSON has an `error` string field"); + assert!( + msg.contains("--interactive") && msg.contains("--json"), + "expected the error to name both conflicting flags: {msg:?}" + ); +} + /// End-to-end smoke for `figmog bench --interactive` (build design §13 /// "Interactive mode"), driven non-interactively by piping a command /// script into stdin — this is exactly how CI (a non-TTY pipe) exercises @@ -571,12 +606,17 @@ fn bench_interactive_e2e_scripted_session_is_plain_and_clean() { "expected a per-request line naming figmog_search:\n{stdout}" ); - // `run 20`: 20 numbered per-request lines, `# 1` through `# 20`. + // Sequence numbers are session-wide, not per-command: `stats` fires + // #1, `search garden` fires #2, so `run 20`'s 20 numbered per-request + // lines are #3 through #22. `#1` and `#20` are both still present + // somewhere in that combined stream — the first from `stats`, the + // second from partway through the burst — which is enough to confirm + // both the pre-burst call and the burst itself actually fired. for n in [1, 20] { let needle = format!("#{n:>4}"); assert!( stdout.contains(&needle), - "expected a `run 20` burst line numbered {n} ({needle:?}):\n{stdout}" + "expected a numbered line #{n} ({needle:?}) somewhere in the session:\n{stdout}" ); } From 74b187e12122e5587578a04b08020b9484675b2e Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 22:16:29 -0700 Subject: [PATCH 45/56] =?UTF-8?q?spec(figmog):=20v4=20multi-file=20serve?= =?UTF-8?q?=20=E2=80=94=20URL-addressed=20tools,=20figmog=5Fopen/files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-15-figmog-build-design.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index f0b8db7..f2f0fd8 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -816,3 +816,70 @@ comparison phase measures API *latency* with K small calls; the ~10/min budget number is documented, never probed to exhaustion); readline niceties (history/completion — plain stdin lines are enough for a demo REPL). + +## 14. v4: multi-file serve + +Agents address Figma by URL — a server bound to one file at startup +breaks that habit. v4 makes `figmog serve` a multi-file server; the +per-file-key store layout (`.figmog//db`) has anticipated this +since v1. + +### Surface + +- `figmog serve [FILE]...` — zero or more files at startup. Zero is + valid: the server starts empty and mirrors files as agents reference + them. Each startup FILE is pulled if its store is empty. +- **Every local tool gains an optional `file` argument** (URL or key, + parsed with `ident::parse_file_ref`). Resolution: explicit `file` arg → + that mirror (auto-opening it if unknown, which spends one Tier-1 + pull); omitted → the default file (first startup FILE, else the single + mirrored file, else an isError naming `figmog_files`/`figmog_open`). +- New tools: `figmog_open {file}` — mirror a file now (one Tier-1 pull; + returns churn + node count), and `figmog_files` — list mirrored files + (key, name, version, nodes, last synced, default flag). Local tool + count becomes 19. +- The steering `instructions` text is extended with one sentence: "Pass + the Figma file URL as the `file` argument when you have one; figmog + mirrors files on first reference." + +### Mechanics + +- **Sessions:** each mirrored file is a `FileSession` whose store lives + captured inside boxed closures (`dispatch(tool, args)`, + `pull()`, `watermark()`) — the established answer to the unnameable + pipeline type; sessions live in a `Vec` keyed by file key, ordered by + open time (first = default). Opening a session = the do_pull-equivalent + sequence at a concrete `open_store!` site inside the closure factory. +- **Watch:** one `Watcher` + backoff per session; the tick visits + sessions round-robin (one meta poll per tick, deadline = interval / + live-session-count, floor 2s) so total Tier-3 spend stays ≈ one file's + worth per interval times the file count — well inside 50–150/min for + dozens of files. `--no-watch` unchanged. +- **Cache / eviction:** unchanged — each session's store carries its own + `proxy_cache`, evicted by that file's own version changes. +- **CLI:** unchanged single-file semantics (`--db`/`.figmog/current`); + multi-file is a serve capability. `figmog call`/`tools` against a + running multi-file config still address one store. +- **Proxied tools caveat (documented, not fixed):** the desktop server + operates on the file open in the Figma app; the `file` argument does + not route proxied tools. README states this plainly. + +### Non-goals (v4) + +Cross-file queries (joins/search spanning mirrors); mirroring whole +teams/projects by enumeration; eviction of idle sessions (a session +opened stays open for the process lifetime); CLI multi-file addressing. + +### Testing + +- Session-resolution unit tests (explicit file, default, unknown-file + isError text, auto-open path with a scripted pull closure). +- Serve e2e: start with no FILE against two pre-built fixture stores' + keys… (stores are per-key temp dirs; the e2e uses `--from-file`-built + stores by pre-creating them under a temp `.figmog` root and passing + `--figmog-root ` — add that hidden flag for testability, default + `.figmog`), then: `figmog_files` lists both, a tool with `file` routes + to the right mirror (distinct fixture names prove it), omitted `file` + errors when two mirrors exist and no default was given, `figmog_open` + with `--from-file`-shaped… (network-free e2e: `figmog_open` is + network-only; e2e covers its isError on missing token instead). From 78c3b74ded8e566c505d8a931537b12d696709ab Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 22:17:22 -0700 Subject: [PATCH 46/56] plan(figmog): multi-file serve (2 tasks) Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-16-figmog-multifile.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-16-figmog-multifile.md diff --git a/docs/superpowers/plans/2026-08-16-figmog-multifile.md b/docs/superpowers/plans/2026-08-16-figmog-multifile.md new file mode 100644 index 0000000..69c8006 --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-figmog-multifile.md @@ -0,0 +1,50 @@ +# figmog multi-file serve Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. + +**Goal:** Spec §14 — `figmog serve` becomes a multi-file server: every local tool takes an optional `file` (URL/key), files auto-mirror on first reference, `figmog_open`/`figmog_files` tools, round-robin watch. + +**Architecture:** Spec §14 of `docs/superpowers/specs/2026-08-15-figmog-build-design.md` is the binding authority — read it in full. Core move: extract each mirrored file into a `FileSession` whose unnameable store is captured in boxed closures (the crate's established pattern); serve routes by resolving the `file` arg to a session. + +**Tech Stack:** No new deps. + +**Spec:** docs/superpowers/specs/2026-08-15-figmog-build-design.md §14 + +## Global Constraints +- Zero new crates. All 145 existing tests green unchanged EXCEPT: the serve e2e's tools/list count assertions move 17→19 and any initialize-instructions assertion must keep passing (the sentence is appended, existing substring stays) — those specific edits are authorized; list every test edit in the report. +- Single-file behavior preserved: `figmog serve ` behaves exactly as today (default file = that file; no `file` arg needed on any tool). +- Stdout purity, determinism (session listing sorted by open order; `figmog_files` output deterministic), clean EOF exit unchanged. +- Gates: `cargo test -p figmog`, `cargo clippy -p figmog --no-deps -- -D warnings`, `cargo fmt -p figmog --check`. +- Commit trailer: `Co-Authored-By: Claude Fable 5 `. + +--- + +### Task 1: FileSession manager + routing + new tools + multiplexed watch + +**Files:** +- Create: `examples/figmog/src/sessions.rs`; Modify: `src/serve.rs`, `src/cli.rs` (Serve takes `files: Vec` positional + hidden `--figmog-root ` flag defaulting ".figmog" used for session store paths — testability per spec §14), `src/mcp.rs` (only the INSTRUCTIONS const gains the spec's exact appended sentence + its pinning test updated), `src/dispatch.rs` if needed. +- Test: unit tests in sessions.rs; serve.rs registry test updated 17→19. + +**Interfaces:** +- `sessions::FileSession { key: String, name: String, dispatch: Box Result>, pull: Box Result>, watermark: Box Option>, watcher: Watcher, backoff: Duration }` — built by `sessions::open_session(root: &Path, key: &str, api_token: Option<&str>, pull_now: bool) -> Result`; the closure factory owns the `open_store!` concrete site (move the existing per-tool rtx dispatch + inline pull + eviction blocks INTO the factory, generalized from serve.rs's current single-store code — this is a refactor-move, behavior preserved). +- `sessions::SessionManager { sessions: Vec, root, token }` with `resolve(&mut self, file_arg: Option<&str>) -> Result<&mut FileSession, String>` implementing spec §14's resolution rules (explicit → find-or-auto-open; omitted → default rules incl. the isError text naming figmog_open/figmog_files); `open(&mut self, file_ref) -> Result<&mut FileSession, String>` (parse_file_ref, dedupe by key); `list(&self) -> Value` for figmog_files. +- serve.rs: registry gains `figmog_open {file required}` + `figmog_files {}` (19 local tools; input schemas per pattern); every existing local ToolDef's schema gains optional `file` string property (description: "Figma file URL or key; omit for the default mirrored file"); the FnHandler closure extracts `file` from args (removing it before tool-specific arg parsing) and routes through SessionManager::resolve. figmog_sync syncs the RESOLVED session. Watch tick: round-robin — keep one `next_session_idx`; per tick poll ONE session's watcher (deadline = max(interval / session_count, 2s) per spec); Changed → that session's pull(); backoff discipline per session. Zero sessions + watch → just idle ticks (no polling). +- CLI Serve: `files: Vec` positional (zero or more); startup opens each (pull if store empty); first = default. `--figmog-root` threaded to SessionManager (and to resolve_db? NO — CLI single-file semantics unchanged; the flag only affects serve's sessions). + +- [ ] **Step 1 (TDD):** sessions unit tests with a scripted closure factory (no real stores): resolve explicit/default/none-mirrored error text; dedupe; auto-open counts. RED → implement sessions.rs → GREEN. +- [ ] **Step 2:** refactor serve.rs onto SessionManager (single-file path first — all existing tests must pass unchanged here, except none should need edits at this step), then add file-arg routing + the two new tools + schema additions + instructions sentence (now the 17→19 and instructions test edits land, listed in the report). +- [ ] **Step 3:** multiplexed watch tick + gates + commit `feat(figmog): multi-file serve — URL-addressed tools, figmog_open/figmog_files`. + +--- + +### Task 2: multi-file e2e + docs + +**Files:** +- Modify: `examples/figmog/tests/serve.rs` (new e2e), `examples/figmog/tests/common/mod.rs` (a second small fixture `fixture_other()` — distinct name "OtherFixture", a few nodes, distinct text), `examples/figmog/README.md`, workspace README bullet. + +- [ ] **Step 1 (TDD):** e2e per spec §14 testing: pre-build two stores under a temp `--figmog-root` (via `pull --from-file --db //db` with two different fixtures — note the CLI's --db is explicit so root layout is constructed by the test), start `serve --no-upstream --no-watch --figmog-root ` with BOTH keys as positional args; assert: tools/list has 19 tools and every local tool schema contains the optional `file` property; `figmog_files` lists both (first = default); `figmog_search {query:, file:}` hits; same query without `file` misses (proves default routing); `figmog_status {file:}` returns the other file's name; `figmog_open {file:"garbagekey1234567890"}` → isError (no token); omitted-file error case NOT triggerable here (a default exists) — cover via a second serve spawn with NO positional files: a tool without `file` → isError naming figmog_open. +- [ ] **Step 2:** README: "Multiple files" section (URL-per-call usage, figmog_open/figmog_files, zero-file startup for `claude mcp add`, proxied-tools caveat verbatim from spec §14); workspace bullet unchanged or +"multi-file". Full gates; commit `feat(figmog): multi-file serve e2e and docs`. + +## Self-review checklist +- Spec §14 coverage: surface (file arg/open/files/zero-file startup) → T1+T2 e2e; mechanics (sessions/watch/cache) → T1; caveat + docs → T2; non-goals respected (no cross-file queries, no idle eviction, CLI unchanged). +- Single-file regression safety: T1 Step 2's mid-step gate (existing tests green before feature lands). From f7fd025d6bca74a520dcc86183ed6b9eed8b6832 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 22:24:41 -0700 Subject: [PATCH 47/56] spec(figmog): v5 remote upstream (mcp.figma.com) with std-only MCP OAuth Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-15-figmog-build-design.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index f2f0fd8..b8447d3 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -883,3 +883,62 @@ opened stays open for the process lifetime); CLI multi-file addressing. errors when two mirrors exist and no default was given, `figmog_open` with `--from-file`-shaped… (network-free e2e: `figmog_open` is network-only; e2e covers its isError on missing token instead). + +## 15. v5: the remote upstream (mcp.figma.com) + +A second upstream flavor alongside the desktop server. The remote server +is a better proxy citizen than desktop — its tools take explicit +URLs/nodeIds per call (no selection), so proxied tools route per-file +like the local ones, erasing §12's open-file caveat — and it adds +remote-only tools (search_design_system, use_figma, whoami, +download_assets, generate_diagram, …). Its calls cost Tier-1-equivalent +per-minute budget on paid seats (6/month on Starter → effectively +paid-seat-only, consistent with the design center), which makes the +version-keyed cache genuinely valuable. + +### Auth (the whole cost) + +MCP OAuth, std-only: +- Discovery: on 401, read `WWW-Authenticate` / + `/.well-known/oauth-protected-resource`, then the authorization + server's metadata (`/.well-known/oauth-authorization-server`). +- Dynamic client registration at the advertised registration endpoint + (public client, PKCE). +- Browser flow: local `TcpListener` on an ephemeral port serves the + redirect; `open`/`xdg-open` launches the authorization URL; PKCE + verifier from `/dev/urandom`, S256 challenge via a vendored ~100-line + SHA-256 (well-known constants; unit-tested against published test + vectors). State parameter checked. +- Tokens persisted at `/auth.json` (0600), refresh-token + flow on 401/expiry; failures degrade to "remote upstream + unauthenticated" status (local tools unaffected). + +### Surface + +- `--upstream` accepts the remote URL; `--remote` sugar for + `--upstream https://mcp.figma.com/mcp`. Desktop and remote are the + same `UpstreamMcp` path — the OAuth layer is an `HttpUpstream` + concern activated when a request meets a 401 challenge (desktop never + does). `figmog login` CLI command runs the flow standalone; + `figmog serve` triggers it lazily on first challenged request + (browser opens once; stderr explains). +- Registry/routing/caching per §12 unchanged; remote tool descriptions + prefixed "[via Figma remote] ". Cacheable rule unchanged (get_/list_ + + explicit node id) — remote's URL-addressed args satisfy it + naturally; `use_figma`/creates are writes (uncached, meta-poll + trigger). +- `figmog_status.upstream` distinguishes `connected (desktop)` / + `connected (remote)` / `unauthenticated (remote)` / `unreachable` / + `disabled`. + +### Non-goals (v5) + +Multiple simultaneous upstreams (one at a time via --upstream); token +encryption beyond file permissions; headless/device-code auth flows. + +### Testing + +SHA-256 against FIPS test vectors; PKCE challenge known-answer test; +OAuth state machine against a scripted in-process HTTP fake (challenge → +discovery → registration → token exchange → authenticated retry → +refresh-on-401); no live-network tests (manual live check documented). From 089f51934ef98e9932adc84538f7673a8ec48dba Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 22:27:37 -0700 Subject: [PATCH 48/56] =?UTF-8?q?spec(figmog):=20v5=20remote=20upstream=20?= =?UTF-8?q?blocked=20=E2=80=94=20mcp.figma.com=20is=20catalog-allowlisted,?= =?UTF-8?q?=20PATs=20rejected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-15-figmog-build-design.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-15-figmog-build-design.md b/docs/superpowers/specs/2026-08-15-figmog-build-design.md index b8447d3..281d233 100644 --- a/docs/superpowers/specs/2026-08-15-figmog-build-design.md +++ b/docs/superpowers/specs/2026-08-15-figmog-build-design.md @@ -884,7 +884,19 @@ opened stays open for the process lifetime); CLI multi-file addressing. with `--from-file`-shaped… (network-free e2e: `figmog_open` is network-only; e2e covers its isError on missing token instead). -## 15. v5: the remote upstream (mcp.figma.com) +## 15. v5: the remote upstream (mcp.figma.com) — BLOCKED (2026-08-16) + +> **Status: blocked by Figma policy, not engineering.** Verified via +> Figma community threads: (a) personal/plan access tokens are rejected +> at mcp.figma.com — OAuth is the only auth; (b) remote-MCP access is +> allowlisted to clients in Figma's MCP Catalog (VS Code, Cursor, Claude +> Code, Codex) — custom clients cannot request the `mcp:connect` scope +> and dynamic client registration returns 403. figmog therefore cannot +> authenticate as itself, and impersonating a catalog client's identity +> would circumvent Figma's access control — out of the question. The +> design below stands ready if Figma ever opens DCR/PAT auth; until +> then the desktop server is the only proxyable upstream, and remote-only +> capabilities are candidates for native REST-backed equivalents instead. A second upstream flavor alongside the desktop server. The remote server is a better proxy citizen than desktop — its tools take explicit From f9a1225f9d142845e55218b0ab184aff30d197fe Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 22:46:41 -0700 Subject: [PATCH 49/56] =?UTF-8?q?feat(figmog):=20multi-file=20serve=20?= =?UTF-8?q?=E2=80=94=20URL-addressed=20tools,=20figmog=5Fopen/figmog=5Ffil?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- examples/figmog/src/cli.rs | 60 ++- examples/figmog/src/dispatch.rs | 55 ++- examples/figmog/src/lib.rs | 1 + examples/figmog/src/mcp.rs | 8 +- examples/figmog/src/serve.rs | 622 +++++++++++++++++++------------- examples/figmog/src/sessions.rs | 494 +++++++++++++++++++++++++ examples/figmog/tests/serve.rs | 9 +- 7 files changed, 962 insertions(+), 287 deletions(-) create mode 100644 examples/figmog/src/sessions.rs diff --git a/examples/figmog/src/cli.rs b/examples/figmog/src/cli.rs index bb01932..58d91d8 100644 --- a/examples/figmog/src/cli.rs +++ b/examples/figmog/src/cli.rs @@ -63,9 +63,11 @@ enum Cmd { /// `--no-upstream`) a cached proxy to Figma's native desktop MCP /// server — figmog is the only Figma MCP an agent needs to connect. Serve { - /// File key or figma.com URL. Optional after the first pull, or - /// with `--no-watch` and `--db` for a read-only, offline server. - file: Option, + /// File keys or figma.com URLs to mirror at startup — zero or + /// more (spec §14: the server starts empty and mirrors files as + /// agents reference them). The first one is the default file for + /// any tool call that omits `file`. + files: Vec, /// Poll interval in seconds. #[arg(long, default_value = "10")] interval: u64, @@ -78,6 +80,12 @@ enum Cmd { /// Serve local `figmog_*` tools only — no upstream proxy. #[arg(long)] no_upstream: bool, + /// Root directory for multi-file session stores (`//db` + /// — spec §14). Hidden: testability knob so e2e tests can point + /// startup files at pre-built fixture stores under a temp dir + /// instead of the real `.figmog`. + #[arg(long, default_value = ".figmog", hide = true)] + figmog_root: PathBuf, }, /// List every tool figmog would serve: the local registry, plus /// upstream tools when reachable. @@ -252,6 +260,36 @@ fn dispatch(cli: Cli) -> Result<(), String> { ); } + // `serve` manages its own (possibly many) session stores via + // `SessionManager` (`sessions.rs`) rather than the single `Db` every + // other command resolves below — handled here, before `resolve_db`, + // for the same reason `bench` is (see above): matched by reference so + // a non-match leaves `cli` untouched for the rest of this function. + // The global `--db` flag is still honored as a single-session escape + // hatch (spec §14 non-goal: CLI multi-file addressing is out of + // scope, and this keeps every pre-v4 `figmog serve --db ` + // invocation — including this crate's own e2e tests — working + // unchanged, single mirror, no `--figmog-root` layout involved). + if let Cmd::Serve { + files, + interval, + no_watch, + upstream, + no_upstream, + figmog_root, + } = &cli.cmd + { + return crate::serve::run_serve( + cli.db.clone(), + files.clone(), + *interval, + *no_watch, + upstream.clone(), + *no_upstream, + figmog_root.clone(), + ); + } + let db = resolve_db(&cli)?; match cli.cmd { Cmd::Pull { @@ -261,13 +299,6 @@ fn dispatch(cli: Cli) -> Result<(), String> { } => cmd_pull(&db, file, from_file, fresh, cli.json), Cmd::Watch { file, interval } => cmd_watch(&db, file, interval, cli.json), Cmd::ImportVariables { path } => cmd_import_variables(&db, path, cli.json), - Cmd::Serve { - file, - interval, - no_watch, - upstream, - no_upstream, - } => crate::serve::run_serve(&db, file, interval, no_watch, upstream, no_upstream), Cmd::Tools { upstream, no_upstream, @@ -402,11 +433,10 @@ fn resolve_db(cli: &Cli) -> Result { // pull/watch with an explicit file ref establish the key for this run. // `.figmog/current` is only written after a successful sync (see - // `do_pull`), so a failed pull never repoints later commands. - if let Cmd::Pull { file: Some(f), .. } - | Cmd::Watch { file: Some(f), .. } - | Cmd::Serve { file: Some(f), .. } = &cli.cmd - { + // `do_pull`), so a failed pull never repoints later commands. `serve` + // never reaches here — it's handled, `Db`-free, before this function + // is even called (see `dispatch`). + if let Cmd::Pull { file: Some(f), .. } | Cmd::Watch { file: Some(f), .. } = &cli.cmd { let key = parse_file_ref(f).ok_or_else(|| format!("not a Figma file key or URL: {f}"))?; return Ok(Db { path: db_path_for(&key), diff --git a/examples/figmog/src/dispatch.rs b/examples/figmog/src/dispatch.rs index 781547b..9424433 100644 --- a/examples/figmog/src/dispatch.rs +++ b/examples/figmog/src/dispatch.rs @@ -179,11 +179,27 @@ pub(crate) fn dispatch_read_tool( } } -/// The 17 `figmog_*` MCP tools: 12 core reads + 5 whole-file structural -/// queries (build design §11's two tables). Every tool but `figmog_sync` -/// reads the local mirror at zero Figma API cost. +/// The optional `file` property every local tool's schema carries as of +/// v4 (spec §14): a Figma file URL or key, routed by `SessionManager` +/// (`sessions.rs`) to the mirror it names, auto-opening it (one Tier-1 +/// pull) if it's new. Omitted, a tool targets the default mirrored file. +fn file_arg_property() -> Value { + json!({ + "type": "string", + "description": "Figma file URL or key; omit for the default mirrored file." + }) +} + +/// The 19 `figmog_*` MCP tools (spec §14, v4): the 12 core reads + 5 +/// whole-file structural queries + `figmog_sync` (build design §11's two +/// tables) — every one of those 17 gains the optional `file` routing +/// property below — plus the two v4 additions, `figmog_open` and +/// `figmog_files`, which don't (routing *to* a file, and listing every +/// file, aren't themselves per-file operations). Every tool but +/// `figmog_sync`/`figmog_open` reads the local mirror at zero Figma API +/// cost. pub(crate) fn tool_registry() -> Vec { - vec![ + let mut tools = vec![ ToolDef { name: "figmog_status", description: "File name, version, last modified time, and node count — reads the local mirror (no Figma API cost).", @@ -337,5 +353,34 @@ pub(crate) fn tool_registry() -> Vec { "required": ["x", "y"] }), }, - ] + ]; + + for t in tools.iter_mut() { + if let Some(props) = t + .input_schema + .get_mut("properties") + .and_then(Value::as_object_mut) + { + props.insert("file".to_string(), file_arg_property()); + } + } + + tools.push(ToolDef { + name: "figmog_open", + description: "Mirror a Figma file now (spends one Tier-1 pull) — creates the mirror if it's new, or re-syncs it if already mirrored. Returns the sync churn and node count.", + input_schema: json!({ + "type": "object", + "properties": { + "file": {"type": "string", "description": "Figma file URL or key to mirror."} + }, + "required": ["file"] + }), + }); + tools.push(ToolDef { + name: "figmog_files", + description: "List every mirrored file: key, name, version, node count, last synced time, and which one is the default — reads local state only (no Figma API cost).", + input_schema: json!({"type": "object", "properties": {}}), + }); + + tools } diff --git a/examples/figmog/src/lib.rs b/examples/figmog/src/lib.rs index d0c2982..c4c66b5 100644 --- a/examples/figmog/src/lib.rs +++ b/examples/figmog/src/lib.rs @@ -22,6 +22,7 @@ mod proxy; pub mod query; mod repl; pub mod serve; +mod sessions; pub mod store; pub mod upstream; pub mod vars; diff --git a/examples/figmog/src/mcp.rs b/examples/figmog/src/mcp.rs index 016c434..2728a5b 100644 --- a/examples/figmog/src/mcp.rs +++ b/examples/figmog/src/mcp.rs @@ -16,8 +16,10 @@ use serde_json::{Value, json}; /// This is the exact steering text carried verbatim in the `initialize` /// result's `instructions` field — see build design §12 / §11 point 3 /// (v3, cached-proxy positioning: figmog is the ONLY Figma MCP an agent -/// connects to, superseding the v2 "second, separate server" text). -const INSTRUCTIONS: &str = "figmog is your Figma server: a local, instant mirror of one Figma file plus a cached proxy to Figma's native capabilities. Call figmog for everything Figma-related. figmog_* tools answer from the local mirror at zero API cost; native-named tools (get_*, …) go to Figma, cached by file version where possible."; +/// connects to, superseding the v2 "second, separate server" text) plus, +/// as of v4 (spec §14), one appended sentence steering agents toward the +/// `file` argument and figmog's auto-mirror-on-first-reference behavior. +const INSTRUCTIONS: &str = "figmog is your Figma server: a local, instant mirror of one Figma file plus a cached proxy to Figma's native capabilities. Call figmog for everything Figma-related. figmog_* tools answer from the local mirror at zero API cost; native-named tools (get_*, …) go to Figma, cached by file version where possible. Pass the Figma file URL as the `file` argument when you have one; figmog mirrors files on first reference."; /// The default MCP protocol version echoed when a client's `initialize` /// request omits `protocolVersion`. @@ -258,7 +260,7 @@ mod tests { "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "figmog", "version": env!("CARGO_PKG_VERSION")}, - "instructions": "figmog is your Figma server: a local, instant mirror of one Figma file plus a cached proxy to Figma's native capabilities. Call figmog for everything Figma-related. figmog_* tools answer from the local mirror at zero API cost; native-named tools (get_*, …) go to Figma, cached by file version where possible.", + "instructions": "figmog is your Figma server: a local, instant mirror of one Figma file plus a cached proxy to Figma's native capabilities. Call figmog for everything Figma-related. figmog_* tools answer from the local mirror at zero API cost; native-named tools (get_*, …) go to Figma, cached by file version where possible. Pass the Figma file URL as the `file` argument when you have one; figmog mirrors files on first reference.", }, }) ); diff --git a/examples/figmog/src/serve.rs b/examples/figmog/src/serve.rs index 7e19db9..b66e4c3 100644 --- a/examples/figmog/src/serve.rs +++ b/examples/figmog/src/serve.rs @@ -1,105 +1,111 @@ //! `figmog serve` — an MCP stdio server with the sync loop built in. //! -//! One process owns the store (build design §11): a reader thread turns -//! stdin lines into an `mpsc` channel; the main loop owns the [`fold`] -//! store and answers JSON-RPC requests between poll ticks. Every -//! `figmog_*` tool is a thin wrapper over the same `query::*` functions -//! the CLI prints — one source of truth for every answer. `figmog_sync` -//! and the background poll loop share the same pull mechanics -//! (`flatten_file` → `collect_sweepable` → `store::sync`) and the same -//! failure-backoff discipline as `figmog watch` (see `cli::pull_failure_wait`). +//! One process owns every mirrored file's store (build design §11, +//! extended by spec §14's multi-file serve): a reader thread turns stdin +//! lines into an `mpsc` channel; the main loop owns a [`sessions::SessionManager`] +//! — one [`sessions::FileSession`] per mirrored file — and answers JSON-RPC +//! requests between poll ticks. Every local `figmog_*` tool is a thin +//! wrapper over the same `query::*` functions the CLI prints — one source +//! of truth for every answer. +//! +//! **v4 (spec §14): multi-file.** Every local tool's schema gains an +//! optional `file` argument (URL or key), routed by [`SessionManager::resolve`] +//! to the mirror it names — auto-opening it (spending one Tier-1 pull) if +//! it's new. Omitted, a tool targets the *default* file: the first one +//! given at startup, or whichever got mirrored first if none were. Two +//! new tools, `figmog_open` (mirror a file now) and `figmog_files` (list +//! every mirror), aren't per-file operations and so don't take `file`. +//! Each session owns its own store — and its own `proxy_cache` table +//! (spec §12) — opened at its own concrete `open_store!` call site inside +//! `sessions::open_session`; see that module's doc comment for why the +//! store can only ever be touched from behind one of a session's boxed +//! closures, and why that module carries a fourth (`proxy_cache`) closure +//! beyond spec §14's literal three. +//! +//! **Cache-routing choice for proxied calls (documented per spec §14's +//! caveat):** the desktop upstream has no notion of "which file" — a +//! proxied `get_*`/`list_*` call's `nodeId` could belong to any mirrored +//! file, or none of them. Rather than guess or refuse to cache, proxied +//! calls' version-keyed cache always routes through the **default** +//! session (the same one a `file`-less local tool call would answer +//! from) when at least one file is mirrored; with zero files mirrored, +//! the call is simply forwarded uncached (see [`handle_tool_call`]). //! //! **v3 (build design §12):** unless `--no-upstream`, figmog also probes //! Figma's native desktop MCP server at startup and becomes the *only* -//! Figma MCP an agent needs — `tools/list` merges the 17 local `figmog_*` +//! Figma MCP an agent needs — `tools/list` merges the 19 local `figmog_*` //! tools with every upstream tool verbatim (`proxy::merge_registry`), and -//! `tools/call` routes by the namespace rule (`proxy::is_local_tool`): -//! local names answer from the store exactly as in v2; everything else is -//! proxied, with `get_*`/`list_*` calls against an explicit node id served -//! from (and written to) the version-keyed `proxy_cache` table -//! (`proxy::proxy_call`). No mid-session re-probe: an unreachable upstream -//! at startup means local-only tools for the life of the process. -//! -//! Every `rtx`/`wtx` call against the store has to live at this concrete, -//! non-generic call site: `open_store!`'s pipeline type contains fn items -//! and can't be named, so it can't be threaded through a helper `fn` -//! generic over `P: Push<..>` (see the identical note in `cli::dispatch`). -//! The [`mcp::ToolHandler`] the loop hands to [`mcp::handle_message`] is -//! therefore a closure — wrapped in [`mcp::FnHandler`] — defined right -//! here, capturing the store by unique reference. What *can* be shared -//! across call sites — because it only needs individual reader values, or -//! only `wtx`, never a raw `rtx` tuple pattern spelled out generically — -//! lives in `crate::dispatch` (local tool reads) and `crate::proxy` -//! (routing rules and the proxied-call execution), and both `run_serve` -//! and the CLI's `figmog call`/`figmog tools` (`cli.rs`) call into them. - -use std::collections::BTreeSet; +//! `tools/call` routes by the namespace rule (`proxy::is_local_tool`). +//! Upstream routing is global, not per-session: the desktop server serves +//! whatever file is open in the Figma app, independent of any mirror this +//! process manages — spec §14's documented caveat. No mid-session +//! re-probe: an unreachable upstream at startup means local-only tools +//! for the life of the process. + use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::time::{Duration, Instant}; -use serde_json::Value; +use serde_json::{Value, json}; -use crate::api::{FigmaApi, UreqApi}; -use crate::cli::{ - Db, PullError, do_pull, now_ms, open_store_checked, pull_failure_wait, read_watermark, - write_current, -}; +use crate::api::UreqApi; use crate::dispatch; -use crate::flatten::flatten_file; use crate::ident::parse_file_ref; use crate::mcp::{self, FnHandler, ToolOutput}; -use crate::model::Id; use crate::proxy; -use crate::store::{self, collect_sweepable}; +use crate::sessions::{self, SessionManager}; use crate::upstream::{HttpUpstream, UpstreamMcp}; -use crate::watch::{BACKOFF_START, Tick, Watcher}; +use crate::watch::Tick; /// Default streamable-HTTP URL of Figma desktop app's Dev Mode MCP server /// (build design §12). pub const DEFAULT_UPSTREAM_URL: &str = "http://127.0.0.1:3845/mcp"; -/// Run the MCP stdio server against `db`, serving `figmog_*` tools and — -/// unless `no_watch` — pulling inline whenever the file changes. Unless -/// `no_upstream`, also attaches Figma's native desktop MCP server at -/// `upstream_url` as a cached proxy (build design §12); a failed probe -/// degrades to local-only tools with one stderr line, never a hard error. -/// -/// `file` resolves the mirrored key the same way `pull`/`watch` do (a -/// `--db` override alone is enough for a read-only, offline server; a key -/// is only required once network access is actually needed: `!no_watch`, -/// or a `figmog_sync` tool call). +/// Floor for the round-robin watch tick's per-session deadline (spec §14: +/// `max(interval / session_count, 2s)`). +const MIN_TICK_DEADLINE: Duration = Duration::from_secs(2); + +/// How long to wait before the next watch tick, given how many sessions +/// are being round-robin polled: the full `interval` split evenly across +/// them (so each file gets, on average, one Tier-3 poll per `interval`), +/// floored at [`MIN_TICK_DEADLINE`] so total poll spend stays bounded even +/// with many mirrored files. Zero sessions: idle at the full interval. +fn tick_deadline(interval: Duration, session_count: usize) -> Duration { + if session_count == 0 { + return interval; + } + (interval / session_count as u32).max(MIN_TICK_DEADLINE) +} + +/// Run the MCP stdio server. `db_override` is the CLI's legacy `--db +/// ` escape hatch (pre-v4, single-session semantics preserved +/// exactly — see `cli::dispatch`'s note on this branch); when absent, +/// `files` (zero or more, `--figmog-root`-rooted) are each mirrored at +/// startup (pulled if their store is empty and `!no_watch`), the first +/// one becoming the default. Unless `no_upstream`, also attaches Figma's +/// native desktop MCP server at `upstream_url` as a cached proxy (build +/// design §12); a failed probe degrades to local-only tools with one +/// stderr line, never a hard error. pub(crate) fn run_serve( - db: &Db, - file: Option, + db_override: Option, + files: Vec, interval: u64, no_watch: bool, upstream_url: String, no_upstream: bool, + figmog_root: PathBuf, ) -> Result<(), String> { - let key: Option = db - .key - .clone() - .or_else(|| file.and_then(|f| parse_file_ref(&f))); - let interval_dur = Duration::from_secs(interval); - let api: Option = if no_watch { - None - } else { - let resolved = key - .clone() - .ok_or_else(|| "no file key: pass a file key or figma.com URL".to_string())?; - let token = std::env::var("FIGMA_TOKEN") - .map_err(|_| "FIGMA_TOKEN not set — required for watch".to_string())?; - if read_watermark(db)?.is_none() { - do_pull(db, Some(resolved), None, false).map_err(|e| e.to_string())?; - } - Some(UreqApi::new(token)) - }; + let token = std::env::var("FIGMA_TOKEN").ok(); + + let (mut manager, track_current) = + build_sessions(db_override, &files, no_watch, &token, &figmog_root)?; // Upstream probe: no mid-session re-probe in v3 — an unreachable // desktop server at startup means local-only tools for the process's - // whole life (build design §12). + // whole life (build design §12). Global, not per-session (see this + // module's doc comment). let mut upstream: Option = if no_upstream { None } else { @@ -129,9 +135,9 @@ pub(crate) fn run_serve( } eprintln!( - "{} serving {} (watch {}, upstream {upstream_status})", + "{} serving {} file(s) (watch {}, upstream {upstream_status})", mcp::SERVER_NAME, - key.as_deref().unwrap_or(""), + manager.sessions.len(), if no_watch { "off" } else { "on" } ); @@ -152,15 +158,8 @@ pub(crate) fn run_serve( } }); - // I-1: a second `figmog serve`/`figmog watch` against the same store - // hits the same fold panic-on-open a CLI read does — translate it the - // same way rather than letting the raw panic surface here. - let mut st = open_store_checked(|| crate::open_store!(&db.path))?; - let mut stored: Option = - st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)); - let mut watcher = Watcher::new(stored.clone()); - let mut pull_backoff = BACKOFF_START; - let mut next_deadline = Instant::now() + interval_dur; + let mut next_session_idx: usize = 0; + let mut next_deadline = Instant::now() + tick_deadline(interval_dur, manager.sessions.len()); loop { let incoming = if no_watch { @@ -181,191 +180,274 @@ pub(crate) fn run_serve( }; let Some(line) = incoming else { - // Timeout with watch enabled: poll, and pull inline on change. - let api_ref = api - .as_ref() - .expect("api is Some whenever watch is enabled, the only way to reach a timeout"); - let watch_key = key - .as_deref() - .expect("key is resolved above whenever watch is enabled"); - match watcher.tick(api_ref, watch_key) { - Tick::Unchanged => next_deadline = Instant::now() + interval_dur, - Tick::Wait { after } => next_deadline = Instant::now() + after, - Tick::Changed { .. } => { - let pull_result: Result = (|| { - let resp = api_ref.file(watch_key)?; - // Opportunistic Enterprise variables sync (spec - // §12): `Ok(None)` on non-Enterprise plans is not an - // error — v1 behavior (import/inference, - // sweep-exempt) holds unchanged below. - let vars_resp = api_ref.variables_local(watch_key)?; - let mut flattened = flatten_file(&resp).map_err(|e| e.to_string())?; - let mut prior: BTreeSet = - st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { - collect_sweepable(&nodes, &components, &component_sets, &styles) - }); - if let Some(v) = &vars_resp { - let var_recs = crate::vars::parse_variables_export(v) - .map_err(|e| e.to_string())?; - flattened.recs.extend(var_recs); - let stored_var_ids = - st.rtx(|(_, _, _, _, variables, variable_collections, _, _)| { - store::collect_variable_ids(&variables, &variable_collections) - }); - prior.extend(stored_var_ids); - } - Ok(store::sync(&mut st, &prior, &flattened, now_ms())) - })(); - match pull_result { - Ok(_churn) => { - stored = st.rtx(|(_, _, _, _, _, _, meta, _)| { - meta.get(&0).map(|m| m.last_modified) - }); - // Sweep any proxy_cache rows the new version made - // stale (spec §12; a no-op if the version didn't - // actually move — see `store.rs`'s eviction note). - let version = st.rtx(|(_, _, _, _, _, _, meta, _)| { - meta.get(&0).map(|m| m.version.clone()) - }); - if let Some(version) = version { - let stale = st.rtx(|(_, _, _, _, _, _, _, cache)| { - store::stale_cache_ids(&cache, &version) - }); - if !stale.is_empty() { - store::evict_stale_cache(&mut st, &stale); - } - } - pull_backoff = BACKOFF_START; - if let Some(k) = &db.key { - let _ = write_current(k); - } - eprintln!("figmog: synced"); - next_deadline = Instant::now() + interval_dur; - } - Err(e) => { - eprintln!("figmog: pull failed: {e}"); - // Reset to the last successfully-synced watermark - // so the same change is re-detected next tick — - // same discipline as `cmd_watch`. - watcher = Watcher::new(stored.clone()); - let wait = pull_failure_wait(&e, &mut pull_backoff, interval_dur); - next_deadline = Instant::now() + wait; - } - } - } - } + // Timeout with watch enabled: round-robin one session's meta + // poll, pulling inline on change (spec §14). + next_deadline = watch_tick( + &mut manager, + &mut next_session_idx, + interval_dur, + track_current, + ); continue; }; let mut handler = FnHandler(|name: &str, args: &Value| -> Result { - if name == "figmog_sync" { - let sync_key = key - .clone() - .ok_or_else(|| "no file key: pass a file key or figma.com URL".to_string())?; - let token = std::env::var("FIGMA_TOKEN") - .map_err(|_| "FIGMA_TOKEN not set — required for figmog_sync".to_string())?; - let sync_api = UreqApi::new(token); - let pull_result: Result = (|| { - let resp = sync_api.file(&sync_key)?; - // Opportunistic Enterprise variables sync (spec §12): - // `Ok(None)` on non-Enterprise plans is not an error — - // v1 behavior (import/inference, sweep-exempt) holds - // unchanged below. - let vars_resp = sync_api.variables_local(&sync_key)?; - let mut flattened = flatten_file(&resp).map_err(|e| e.to_string())?; - let mut prior: BTreeSet = - st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { - collect_sweepable(&nodes, &components, &component_sets, &styles) - }); - if let Some(v) = &vars_resp { - let var_recs = - crate::vars::parse_variables_export(v).map_err(|e| e.to_string())?; - flattened.recs.extend(var_recs); - let stored_var_ids = - st.rtx(|(_, _, _, _, variables, variable_collections, _, _)| { - store::collect_variable_ids(&variables, &variable_collections) - }); - prior.extend(stored_var_ids); - } - Ok(store::sync(&mut st, &prior, &flattened, now_ms())) - })(); - // A failed manual sync still spends the same backoff - // budget as a failed background tick, and — when watch - // is enabled — the next tick must not fire back into a - // rate-limit window this call just learned about. - let churn = match pull_result { - Ok(c) => c, - Err(e) => { - let wait = pull_failure_wait(&e, &mut pull_backoff, interval_dur); - next_deadline = Instant::now() + wait; - return Err(e.to_string()); - } - }; - stored = - st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified)); - // Sweep any proxy_cache rows the new version made stale - // (spec §12; a no-op if the version didn't actually move). - let version = - st.rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.version.clone())); - if let Some(version) = version { - let stale = st.rtx(|(_, _, _, _, _, _, _, cache)| { - store::stale_cache_ids(&cache, &version) - }); - if !stale.is_empty() { - store::evict_stale_cache(&mut st, &stale); - } - } - pull_backoff = BACKOFF_START; - watcher = Watcher::new(stored.clone()); - if let Some(k) = &db.key { - let _ = write_current(k); - } - let churn_value = serde_json::to_value(&churn).map_err(|e| e.to_string())?; - return Ok(ToolOutput::Json(churn_value)); - } + handle_tool_call( + &mut manager, + &mut upstream, + upstream_status, + no_watch, + track_current, + &mut next_deadline, + name, + args, + ) + }); + + if let Some(resp) = mcp::handle_message(&line, &tools, &mut handler) { + println!("{resp}"); + std::io::stdout().flush().map_err(|e| e.to_string())?; + } + } +} - if let Some(result) = - st.rtx(|r| dispatch::dispatch_read_tool(name, args, upstream_status, r)) - { - return result.map(ToolOutput::Json); +/// Startup: build the [`SessionManager`] for either the legacy single-`--db` +/// path or the (possibly zero-file) multi-file path. Returns whether +/// successful pulls on the *default* session should refresh +/// `.figmog/current` (`track_current`) — only true in the no-override +/// path, matching pre-v4 behavior exactly: `--db` always resolved with no +/// established key (see `cli::resolve_db`'s old short-circuit), so it +/// never wrote `.figmog/current` either. +fn build_sessions( + db_override: Option, + files: &[String], + no_watch: bool, + token: &Option, + figmog_root: &Path, +) -> Result<(SessionManager, bool), String> { + if let Some(path) = db_override { + // A `file` positional may still be given alongside `--db` (pre-v4 + // behavior: `--db` fixes the store path, an optional file arg + // still resolves the key network operations need). + let key_opt = files.first().and_then(|f| parse_file_ref(f)); + if !no_watch { + key_opt + .clone() + .ok_or_else(|| "no file key: pass a file key or figma.com URL".to_string())?; + token + .clone() + .ok_or_else(|| "FIGMA_TOKEN not set — required for watch".to_string())?; + } + let session_key = key_opt.unwrap_or_else(|| path.display().to_string()); + let session = sessions::open_session_at(path, session_key, token.as_deref(), false)?; + let mut manager = SessionManager { + sessions: vec![session], + root: figmog_root.to_path_buf(), + token: token.clone(), + }; + if !no_watch { + let session = &mut manager.sessions[0]; + if (session.watermark)().is_none() { + let outcome = (session.pull)()?; + session.note_pull_success(&outcome); } + } + return Ok((manager, false)); + } + + // Watch needs a token to keep polling for the life of the process, + // independent of whether any startup file actually needs an initial + // pull — same eager requirement the old single-file path had, scaled + // to "there's at least one file to watch" (spec §14: zero files is a + // valid, token-free, idle startup). + if !no_watch && !files.is_empty() { + token + .clone() + .ok_or_else(|| "FIGMA_TOKEN not set — required for watch".to_string())?; + } - if proxy::is_local_tool(name) { - return Err(format!("unknown tool: {name}")); + let mut manager = SessionManager { + sessions: Vec::new(), + root: figmog_root.to_path_buf(), + token: token.clone(), + }; + for f in files { + let session = manager.open(f)?; + if !no_watch && (session.watermark)().is_none() { + let outcome = (session.pull)()?; + session.note_pull_success(&outcome); + } + } + Ok((manager, true)) +} + +/// One round-robin watch tick: poll exactly one session's [`Watcher`](crate::watch::Watcher) +/// and, on `Changed`, pull it. Returns the next deadline. +fn watch_tick( + manager: &mut SessionManager, + next_idx: &mut usize, + interval: Duration, + track_current: bool, +) -> Instant { + if manager.sessions.is_empty() { + return Instant::now() + interval; + } + // A session can only exist if opening it (an auto-open, or a startup + // pull) already required a token — *except* a session whose own + // startup/auto-open pull failed (sessions.rs leaves it in place empty + // rather than evicting it — no idle eviction, spec §14 non-goal). + // Either way, without a token there's nothing safe to poll this tick. + let Some(token) = manager.token.clone() else { + return Instant::now() + interval; + }; + + let n = manager.sessions.len(); + let idx = *next_idx % n; + *next_idx = (*next_idx + 1) % n; + let deadline = tick_deadline(interval, n); + + let api = UreqApi::new(token); + let session = &mut manager.sessions[idx]; + let key = session.key.clone(); + + match session.watcher.tick(&api, &key) { + Tick::Unchanged => Instant::now() + deadline, + Tick::Wait { after } => Instant::now() + after, + Tick::Changed { .. } => match (session.pull)() { + Ok(outcome) => { + session.note_pull_success(&outcome); + refresh_current(manager, track_current, &key); + Instant::now() + deadline } + Err(e) => { + eprintln!("figmog: pull failed for {key}: {e}"); + // No typed `ApiError` survives a session's `pull` closure + // (spec §14's interface returns a plain `String` — see + // sessions.rs), so watch-triggered pull failures get plain + // per-session exponential backoff rather than the + // Retry-After-aware `cli::pull_failure_wait` the CLI's own + // `pull`/`watch` commands still use unchanged. + let session = &mut manager.sessions[idx]; + session.watcher = crate::watch::Watcher::new((session.watermark)()); + let wait = session.backoff; + session.backoff = (session.backoff * 2).min(crate::watch::BACKOFF_CAP); + Instant::now() + wait + } + }, + } +} + +/// Refresh `.figmog/current` to `key` — only when `track` (the no-`--db`- +/// override startup path) and `key` is the *default* session's, matching +/// old single-file behavior for the one invocation shape that used to do +/// this (`figmog serve `, no `--db`). +fn refresh_current(manager: &SessionManager, track: bool, key: &str) { + if track && manager.sessions.first().map(|s| s.key.as_str()) == Some(key) { + let _ = crate::cli::write_current(key); + } +} + +/// Route one `tools/call`. `figmog_files`/`figmog_open` aren't per-file +/// and are handled first; every other local tool's optional `file` +/// argument is extracted (and stripped before tool-specific arg parsing) +/// and routed through [`SessionManager::resolve`]; non-local names are +/// proxied — see this module's doc comment for the cache-routing choice. +#[allow(clippy::too_many_arguments)] +fn handle_tool_call( + manager: &mut SessionManager, + upstream: &mut Option, + upstream_status: &'static str, + no_watch: bool, + track_current: bool, + next_deadline: &mut Instant, + name: &str, + args: &Value, +) -> Result { + if name == "figmog_files" { + return Ok(ToolOutput::Json(manager.list())); + } - let up = upstream - .as_mut() - .ok_or_else(|| format!("upstream not attached: {name}"))?; - let args_canonical = proxy::canonical_args(args); - let version_and_hit = if proxy::is_cacheable(name, args) { - st.rtx(|(_, _, _, _, _, _, meta, cache)| { - let version = meta.get(&0).map(|m| m.version.clone()); - let hit = version - .as_ref() - .and_then(|v| crate::cache::lookup(&cache, name, &args_canonical, v)); - (version, hit) - }) - } else { - (None, None) - }; - let (value, trigger_poll) = - proxy::proxy_call(&mut st, up, name, args, version_and_hit)?; - if trigger_poll && !no_watch { - next_deadline = Instant::now(); + if name == "figmog_open" { + let file = dispatch::require_str(args, "file")?; + let session = manager.open(&file)?; + let outcome = (session.pull)()?; + session.note_pull_success(&outcome); + let key = session.key.clone(); + // The node count alone — everything else in the result comes + // straight from `outcome`, which already has this pull's + // authoritative name/version/churn. + let nodes = match (session.dispatch)("figmog_status", &json!({}))? { + ToolOutput::Json(v) => v["nodes"].clone(), + ToolOutput::Raw(_) => Value::Null, + }; + refresh_current(manager, track_current, &key); + + return Ok(ToolOutput::Json(json!({ + "key": key, + "name": outcome.name, + "version": outcome.version, + "nodes": nodes, + "added": outcome.churn.added, + "changed": outcome.churn.changed, + "removed": outcome.churn.removed, + "unchanged": outcome.churn.unchanged, + }))); + } + + let file_arg = args.get("file").and_then(Value::as_str).map(str::to_string); + let mut call_args = args.clone(); + if let Some(obj) = call_args.as_object_mut() { + obj.remove("file"); + } + + if proxy::is_local_tool(name) { + let session = manager.resolve(file_arg.as_deref())?; + + if name == "figmog_sync" { + let outcome = (session.pull)()?; + session.note_pull_success(&outcome); + let key = session.key.clone(); + let churn_value = serde_json::to_value(&outcome.churn).map_err(|e| e.to_string())?; + refresh_current(manager, track_current, &key); + return Ok(ToolOutput::Json(churn_value)); + } + + let result = (session.dispatch)(name, &call_args)?; + if name == "figmog_status" + && let ToolOutput::Json(mut v) = result + { + if let Some(obj) = v.as_object_mut() { + obj.insert("upstream".to_string(), json!(upstream_status)); } - // A proxied result is already a complete MCP `CallToolResult` - // from the upstream — emit it verbatim (spec §11/§12; see - // `mcp::ToolOutput::Raw`'s doc comment) rather than re-wrapping - // it as figmog's own text-block shape. - Ok(ToolOutput::Raw(value)) - }); + return Ok(ToolOutput::Json(v)); + } + return Ok(result); + } - if let Some(resp) = mcp::handle_message(&line, &tools, &mut handler) { - println!("{resp}"); - std::io::stdout().flush().map_err(|e| e.to_string())?; + // Proxied (upstream) call: `file` carries no meaning here (spec §14's + // documented caveat — the desktop server has no concept of "which + // file"; a client sending it anyway is simply ignored, same as any + // other argument the upstream tool doesn't itself define). Route the + // version-keyed cache through the default session when one exists; + // zero files mirrored yet means forward uncached rather than fail. + let up = upstream + .as_mut() + .ok_or_else(|| format!("upstream not attached: {name}"))?; + let (value, trigger_poll) = match manager.sessions.first_mut() { + Some(session) => (session.proxy_cache)(up, name, args)?, + None => { + let result = up.call(name, args).map_err(|e| e.to_string())?; + (result, false) } + }; + if trigger_poll && !no_watch { + *next_deadline = Instant::now(); } + // A proxied result is already a complete MCP `CallToolResult` from the + // upstream — emit it verbatim (spec §11/§12; see `mcp::ToolOutput::Raw`'s + // doc comment) rather than re-wrapping it as figmog's own text-block + // shape. + Ok(ToolOutput::Raw(value)) } #[cfg(test)] @@ -373,7 +455,6 @@ mod tests { use super::*; use crate::mcp::ToolDef; use crate::upstream::FakeUpstream; - use serde_json::json; fn local_registry() -> Vec { dispatch::tool_registry() @@ -388,10 +469,10 @@ mod tests { })]); let (tools, dropped) = proxy::merge_registry(local_registry(), upstream.tools()); assert!(dropped.is_empty()); - assert_eq!(tools.len(), 18); - assert!(tools[..17].iter().all(|t| t.name.starts_with("figmog_"))); - assert_eq!(tools[17].name, "get_design_context"); - assert!(tools[17].description.starts_with("[via Figma desktop] ")); + assert_eq!(tools.len(), 20); + assert!(tools[..19].iter().all(|t| t.name.starts_with("figmog_"))); + assert_eq!(tools[19].name, "get_design_context"); + assert!(tools[19].description.starts_with("[via Figma desktop] ")); } #[test] @@ -405,7 +486,7 @@ mod tests { "components": {}, "componentSets": {}, "styles": {}, })) .unwrap(); - store::sync(&mut st, &BTreeSet::new(), &flattened, 0); + crate::store::sync(&mut st, &std::collections::BTreeSet::new(), &flattened, 0); let result = st.rtx(|r| dispatch::dispatch_read_tool("figmog_status", &json!({}), "connected", r)); @@ -421,4 +502,25 @@ mod tests { st.rtx(|r| dispatch::dispatch_read_tool("get_code", &json!({}), "connected", r)); assert!(result.is_none()); } + + #[test] + fn tick_deadline_splits_interval_across_sessions_floored_at_2s() { + assert_eq!( + tick_deadline(Duration::from_secs(10), 0), + Duration::from_secs(10) + ); + assert_eq!( + tick_deadline(Duration::from_secs(10), 1), + Duration::from_secs(10) + ); + assert_eq!( + tick_deadline(Duration::from_secs(10), 5), + Duration::from_secs(2) + ); + // Floored: 10s / 100 sessions would be 0.1s, floored to 2s. + assert_eq!( + tick_deadline(Duration::from_secs(10), 100), + Duration::from_secs(2) + ); + } } diff --git a/examples/figmog/src/sessions.rs b/examples/figmog/src/sessions.rs new file mode 100644 index 0000000..ccbd776 --- /dev/null +++ b/examples/figmog/src/sessions.rs @@ -0,0 +1,494 @@ +//! Multi-file serve (spec §14): each mirrored Figma file is a [`FileSession`] +//! — its own store, opened at its own `open_store!` call site, with +//! everything that touches it captured in boxed closures (`dispatch`, +//! `pull`, `watermark`). This generalizes the single-store logic +//! `serve.rs` used to inline directly: the store's pipeline type contains +//! fn items and can't be named (see the doc comment on `store.rs`'s +//! `open_store!` macro and the identical note in `cli::dispatch`), so a +//! session's three closures share the one open handle via `Rc>` +//! — the only way three independently-boxed `FnMut`s can all reach the +//! same unnameable, non-`Copy` value. +//! +//! [`SessionManager`] owns every open session, in open order (first = +//! default — spec §14's resolution rule for an omitted `file` argument), +//! and implements the `file`-argument routing rule: explicit `file` → +//! that mirror, auto-opening (and spending exactly one Tier-1 pull) if +//! it's new to this manager; omitted → the first-opened session, or an +//! error naming `figmog_open`/`figmog_files` if none exists yet. +//! +//! **Proxied (upstream) tools stay outside this module entirely** — spec +//! §14's documented caveat is that the desktop server has no concept of +//! "which file", so the `file` argument only ever routes figmog's own +//! local tools. `serve.rs` still owns the single, global upstream +//! connection and decides for itself which session's `proxy_cache` a +//! proxied call reads/writes through (see its own doc comment for that +//! choice). + +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::api::{FigmaApi, UreqApi}; +use crate::cli::{now_ms, open_store_checked}; +use crate::dispatch; +use crate::flatten::flatten_file; +use crate::ident::parse_file_ref; +use crate::mcp::ToolOutput; +use crate::model::Id; +use crate::store::{self, Churn, collect_sweepable, collect_variable_ids}; +use crate::watch::{BACKOFF_START, Watcher}; + +/// What one [`FileSession::pull`] call did: the sync churn plus the file's +/// name/version as of that pull — `figmog_open`'s own result and +/// `figmog_sync`'s churn value are both built from this. +pub(crate) struct PullOutcome { + pub(crate) churn: Churn, + pub(crate) name: String, + pub(crate) version: String, +} + +// Named aliases for `FileSession`'s boxed-closure fields (clippy's +// `type_complexity` lint, and plain readability): every one of these +// exists only because the store's pipeline type contains fn items and +// can't be named (this module's doc comment), so it can never appear in a +// named struct field directly — only behind one of these closures. +type DispatchFn = Box Result>; +type PullFn = Box Result>; +type WatermarkFn = Box Option>; +type ProxyCacheFn = Box< + dyn FnMut(&mut crate::upstream::HttpUpstream, &str, &Value) -> Result<(Value, bool), String>, +>; + +/// One mirrored Figma file. `dispatch` answers the 16 read-only +/// `figmog_*` tools (everything [`dispatch::dispatch_read_tool`] knows); +/// `pull` runs one Tier-1 fetch-flatten-sync-evict cycle (used by +/// startup, `figmog_sync`, `figmog_open`, and a watch tick's `Changed` +/// branch); `watermark` reads the stored `FileMeta.last_modified`, used to +/// (re)seed `watcher` after every successful pull. `watcher`/`backoff` are +/// plain per-session state (not closures) so `serve.rs`'s round-robin tick +/// loop can drive them directly, exactly as the old single-session loop +/// drove its own local variables. +/// +/// `proxy_cache` is a fourth closure beyond spec §14's literal list, +/// needed for the same structural reason as the other three: a proxied +/// (upstream, non-`figmog_*`) call's version-keyed cache lives in *this* +/// session's own `proxy_cache` table (spec §12/§14: "each session's store +/// carries its own `proxy_cache`"), and the store can only ever be touched +/// from behind one of these closures. `serve.rs` calls it only for the +/// *default* session — see its own doc comment for why proxied calls +/// route through the default rather than any per-call `file` argument. +pub(crate) struct FileSession { + pub(crate) key: String, + pub(crate) name: String, + pub(crate) dispatch: DispatchFn, + pub(crate) pull: PullFn, + pub(crate) watermark: WatermarkFn, + pub(crate) proxy_cache: ProxyCacheFn, + pub(crate) watcher: Watcher, + pub(crate) backoff: Duration, +} + +impl FileSession { + /// Reseed `watcher`/`backoff` after a successful pull from any call + /// site (startup, `figmog_sync`, `figmog_open`, a watch tick) — the + /// same bookkeeping every one of those paths used to repeat inline. + pub(crate) fn note_pull_success(&mut self, outcome: &PullOutcome) { + self.name = outcome.name.clone(); + let seen = (self.watermark)(); + self.watcher = Watcher::new(seen); + self.backoff = BACKOFF_START; + } +} + +/// Build a [`FileSession`] mirroring `key` under `root` (`root//db` — +/// the per-key store layout every mirror has used since v1, see +/// `cli::db_path_for`). This owns the concrete `open_store!` call site; +/// `pull_now`: perform one Tier-1 pull-and-sync cycle before returning, +/// unconditionally (callers that only want a pull when the store happens +/// to be empty check that themselves — via the returned session's own +/// `watermark()` — since deciding requires opening the store anyway, and +/// this function is the only thing that does). +pub(crate) fn open_session( + root: &Path, + key: &str, + api_token: Option<&str>, + pull_now: bool, +) -> Result { + let path = root.join(key).join("db"); + open_session_at(path, key.to_string(), api_token, pull_now) +} + +/// Like [`open_session`], but at an explicit store path rather than one +/// derived from `root`/`key` — the CLI's legacy `--db ` escape hatch +/// (`serve.rs`'s `run_serve`) needs this: it predates multi-file serve and +/// its existing tests pin an explicit, arbitrary store directory (no +/// `--figmog-root` layout involved), so `figmog serve --db ` keeps +/// opening exactly that path as a single session, unchanged. +pub(crate) fn open_session_at( + path: PathBuf, + key: String, + api_token: Option<&str>, + pull_now: bool, +) -> Result { + let token = api_token.map(str::to_string); + let st = Rc::new(RefCell::new(open_store_checked(|| { + crate::open_store!(&path) + })?)); + + // The pull-and-sync cycle (build design §12's do_pull-equivalent + // sequence): fetch, flatten, sync, evict stale cache rows on a version + // change. Defined once and reused for the immediate `pull_now` call + // below and — moved as-is — for the `pull` closure every other call + // site (`figmog_sync`, `figmog_open`, watch) drives. + let pull_closure = { + let st = st.clone(); + let key = key.clone(); + let token = token.clone(); + move || -> Result { + let token = token + .clone() + .ok_or_else(|| "FIGMA_TOKEN not set — required for network pulls".to_string())?; + let api = UreqApi::new(token); + let resp = api.file(&key).map_err(|e| e.to_string())?; + // Opportunistic Enterprise variables sync (spec §12): `Ok(None)` + // on non-Enterprise plans is not an error — v1 behavior + // (import/inference, sweep-exempt) holds unchanged below. + let vars_resp = api.variables_local(&key).map_err(|e| e.to_string())?; + let mut flattened = flatten_file(&resp).map_err(|e| e.to_string())?; + + let mut st = st.borrow_mut(); + let mut prior: BTreeSet = + st.rtx(|((nodes, ..), components, component_sets, styles, ..)| { + collect_sweepable(&nodes, &components, &component_sets, &styles) + }); + if let Some(v) = &vars_resp { + let var_recs = crate::vars::parse_variables_export(v).map_err(|e| e.to_string())?; + flattened.recs.extend(var_recs); + let stored_var_ids = + st.rtx(|(_, _, _, _, variables, variable_collections, _, _)| { + collect_variable_ids(&variables, &variable_collections) + }); + prior.extend(stored_var_ids); + } + let churn = store::sync(&mut st, &prior, &flattened, now_ms()); + + // Cache eviction lives here rather than folded into `sync` + // (store.rs's own note): a version-changing pull sweeps stale + // `proxy_cache` rows; computing `stale` against the version + // just synced makes this a no-op whenever the version didn't + // actually move, with no separate "did it change" check needed. + let version = flattened.file.version.clone(); + let stale = + st.rtx(|(_, _, _, _, _, _, _, cache)| store::stale_cache_ids(&cache, &version)); + if !stale.is_empty() { + store::evict_stale_cache(&mut st, &stale); + } + + Ok(PullOutcome { + churn, + name: flattened.file.name.clone(), + version, + }) + } + }; + + if pull_now { + pull_closure()?; + } + + let name = st + .borrow() + .rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.name.clone())) + .unwrap_or_else(|| key.clone()); + let seen = st + .borrow() + .rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified.clone())); + + let dispatch: DispatchFn = { + let st = st.clone(); + Box::new( + move |name: &str, args: &Value| -> Result { + let st = st.borrow(); + // `upstream_status` is a purely global concern (proxy routing + // never varies per file — see this module's doc comment), so + // it's spliced into a client-requested `figmog_status` result + // at the call site that actually knows it (`serve.rs`), not + // here. `""` is never observed by a real caller. + match st.rtx(|r| dispatch::dispatch_read_tool(name, args, "", r)) { + Some(result) => result.map(ToolOutput::Json), + None => Err(format!("unknown tool: {name}")), + } + }, + ) + }; + + let pull: PullFn = Box::new(pull_closure); + + let watermark: WatermarkFn = { + let st = st.clone(); + Box::new(move || { + st.borrow() + .rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.last_modified.clone())) + }) + }; + + let proxy_cache: ProxyCacheFn = { + let st = st.clone(); + Box::new(move |upstream, name, args| { + let args_canonical = crate::proxy::canonical_args(args); + let version_and_hit = if crate::proxy::is_cacheable(name, args) { + st.borrow().rtx(|(_, _, _, _, _, _, meta, cache)| { + let version = meta.get(&0).map(|m| m.version.clone()); + let hit = version + .as_ref() + .and_then(|v| crate::cache::lookup(&cache, name, &args_canonical, v)); + (version, hit) + }) + } else { + (None, None) + }; + let mut st = st.borrow_mut(); + crate::proxy::proxy_call(&mut st, upstream, name, args, version_and_hit) + }) + }; + + Ok(FileSession { + key, + name, + dispatch, + pull, + watermark, + proxy_cache, + watcher: Watcher::new(seen), + backoff: BACKOFF_START, + }) +} + +/// Every mirrored file for one `figmog serve` process, in open order +/// (index 0 = default — spec §14). `root`/`token` are what every +/// auto-opened session is built with ([`open_session`]). +pub(crate) struct SessionManager { + pub(crate) sessions: Vec, + pub(crate) root: PathBuf, + pub(crate) token: Option, +} + +/// The `file`-argument resolution error's shared text (spec §14: must name +/// `figmog_open`/`figmog_files`). +const NO_DEFAULT_FILE_MSG: &str = "no file specified and no default mirrored file — pass a `file` argument, mirror one with figmog_open, or see figmog_files for the current list"; + +impl SessionManager { + /// Get-or-create the session for `file_ref` (URL or bare key), + /// deduped by key — never pulls: a freshly-created session is left + /// exactly as [`open_session`] built it (empty unless the caller asked + /// for `pull_now`). [`Self::resolve`]/`figmog_open` are what decide + /// whether — and how many times — to actually pull. + pub(crate) fn open(&mut self, file_ref: &str) -> Result<&mut FileSession, String> { + let key = parse_file_ref(file_ref) + .ok_or_else(|| format!("not a Figma file key or URL: {file_ref}"))?; + if let Some(pos) = self.sessions.iter().position(|s| s.key == key) { + return Ok(&mut self.sessions[pos]); + } + let session = open_session(&self.root, &key, self.token.as_deref(), false)?; + self.sessions.push(session); + Ok(self.sessions.last_mut().expect("just pushed")) + } + + /// Spec §14's `file`-argument resolution rule. Explicit `file`: + /// that mirror, auto-opening it (and spending exactly one Tier-1 pull, + /// only for a session that's genuinely new to this manager) if + /// unknown. Omitted: the first-opened session (the first startup + /// FILE if any were given, else whichever file got mirrored first), + /// or [`NO_DEFAULT_FILE_MSG`] if none exists yet. + pub(crate) fn resolve(&mut self, file_arg: Option<&str>) -> Result<&mut FileSession, String> { + match file_arg { + Some(f) => { + let key = + parse_file_ref(f).ok_or_else(|| format!("not a Figma file key or URL: {f}"))?; + let existed = self.sessions.iter().any(|s| s.key == key); + let session = self.open(f)?; + if !existed { + let outcome = (session.pull)()?; + session.note_pull_success(&outcome); + } + Ok(session) + } + None => { + if self.sessions.is_empty() { + Err(NO_DEFAULT_FILE_MSG.to_string()) + } else { + Ok(&mut self.sessions[0]) + } + } + } + } + + /// `figmog_files`: every mirrored file, in open order (index 0 = + /// default), as `{key, name, version, nodes, last_synced, default}`. + /// Deterministic — plain `Vec` order, no `HashMap` involved. + pub(crate) fn list(&mut self) -> Value { + let rows: Vec = self + .sessions + .iter_mut() + .enumerate() + .map(|(i, s)| { + let status = (s.dispatch)("figmog_status", &json!({})).ok(); + let (name, version, nodes, last_synced) = match status { + Some(ToolOutput::Json(v)) => ( + v["name"].clone(), + v["version"].clone(), + v["nodes"].clone(), + v["synced_at_unix_ms"].clone(), + ), + _ => (Value::Null, Value::Null, Value::Null, Value::Null), + }; + json!({ + "key": s.key, + "name": name, + "version": version, + "nodes": nodes, + "last_synced": last_synced, + "default": i == 0, + }) + }) + .collect(); + json!(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A scripted stand-in for a [`FileSession`] built without ever + /// touching a real store — proves [`SessionManager`]'s routing/dedupe + /// logic in isolation, per the brief's Step 1. + fn scripted_session(key: &str, pull_calls: Rc>) -> FileSession { + let watermark: WatermarkFn = Box::new(|| Some("t".to_string())); + let pull: PullFn = { + let pull_calls = pull_calls.clone(); + Box::new(move || { + *pull_calls.borrow_mut() += 1; + Ok(PullOutcome { + churn: Churn::default(), + name: "Scripted".to_string(), + version: "1".to_string(), + }) + }) + }; + FileSession { + key: key.to_string(), + name: "Scripted".to_string(), + dispatch: Box::new(|_name, _args| Ok(ToolOutput::Json(json!({})))), + pull, + watermark, + proxy_cache: Box::new(|_upstream, name, _args| { + Err(format!( + "scripted session has no store to proxy through: {name}" + )) + }), + watcher: Watcher::new(None), + backoff: BACKOFF_START, + } + } + + fn empty_manager() -> SessionManager { + SessionManager { + sessions: Vec::new(), + root: PathBuf::from("/nonexistent"), + token: None, + } + } + + #[test] + fn resolve_omitted_with_no_sessions_names_figmog_open_and_figmog_files() { + let mut mgr = empty_manager(); + let err = mgr.resolve(None).map(|_| ()).unwrap_err(); + assert!(err.contains("figmog_open"), "{err}"); + assert!(err.contains("figmog_files"), "{err}"); + } + + #[test] + fn resolve_omitted_returns_first_opened_session() { + let mut mgr = empty_manager(); + let calls = Rc::new(RefCell::new(0)); + mgr.sessions + .push(scripted_session("keyA1234567890", calls.clone())); + mgr.sessions + .push(scripted_session("keyB1234567890", calls.clone())); + let session = mgr.resolve(None).unwrap(); + assert_eq!(session.key, "keyA1234567890"); + } + + #[test] + fn resolve_explicit_known_key_never_pulls() { + let mut mgr = empty_manager(); + let calls = Rc::new(RefCell::new(0)); + mgr.sessions + .push(scripted_session("flAtUnMfzvA5daBSTFQK35", calls.clone())); + let session = mgr.resolve(Some("flAtUnMfzvA5daBSTFQK35")).unwrap(); + assert_eq!(session.key, "flAtUnMfzvA5daBSTFQK35"); + assert_eq!( + *calls.borrow(), + 0, + "an already-known session must not be re-pulled" + ); + } + + #[test] + fn resolve_explicit_unknown_key_dedupes_by_key_from_a_url() { + let mut mgr = empty_manager(); + let calls = Rc::new(RefCell::new(0)); + mgr.sessions + .push(scripted_session("flAtUnMfzvA5daBSTFQK35", calls.clone())); + // A full figma.com URL for the same key resolves to the existing + // session rather than creating a second one (dedupe by parsed key). + let session = mgr + .resolve(Some( + "https://www.figma.com/design/flAtUnMfzvA5daBSTFQK35/whatever", + )) + .unwrap(); + assert_eq!(session.key, "flAtUnMfzvA5daBSTFQK35"); + assert_eq!(mgr.sessions.len(), 1); + assert_eq!(*calls.borrow(), 0); + } + + #[test] + fn resolve_rejects_garbage_file_ref() { + let mut mgr = empty_manager(); + let err = mgr.resolve(Some("not a key!")).map(|_| ()).unwrap_err(); + assert!(err.contains("not a Figma file key or URL"), "{err}"); + } + + #[test] + fn open_dedupes_repeated_calls_for_the_same_key() { + let mut mgr = empty_manager(); + let calls = Rc::new(RefCell::new(0)); + mgr.sessions + .push(scripted_session("keyA1234567890", calls.clone())); + mgr.open("keyA1234567890").unwrap(); + mgr.open("keyA1234567890").unwrap(); + assert_eq!(mgr.sessions.len(), 1); + } + + #[test] + fn list_marks_only_the_first_session_default_and_is_ordered() { + let mut mgr = empty_manager(); + let calls = Rc::new(RefCell::new(0)); + mgr.sessions + .push(scripted_session("keyA1234567890", calls.clone())); + mgr.sessions + .push(scripted_session("keyB1234567890", calls.clone())); + let list = mgr.list(); + let rows = list.as_array().unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["key"], json!("keyA1234567890")); + assert_eq!(rows[0]["default"], json!(true)); + assert_eq!(rows[1]["key"], json!("keyB1234567890")); + assert_eq!(rows[1]["default"], json!(false)); + } +} diff --git a/examples/figmog/tests/serve.rs b/examples/figmog/tests/serve.rs index 3290c25..0890f90 100644 --- a/examples/figmog/tests/serve.rs +++ b/examples/figmog/tests/serve.rs @@ -180,14 +180,15 @@ fn serve_e2e_initialize_tools_list_and_tool_calls() { &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), ); - // -- tools/list: exactly 17 figmog_* tools -- + // -- tools/list: exactly 19 figmog_* tools (spec §14: the 17 v3 tools + // plus figmog_open/figmog_files) -- send( &mut stdin, &json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), ); let resp = recv(&rx); let tools = resp["result"]["tools"].as_array().expect("tools array"); - assert_eq!(tools.len(), 17, "tools: {tools:#?}"); + assert_eq!(tools.len(), 19, "tools: {tools:#?}"); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); for name in &names { assert!( @@ -475,14 +476,14 @@ fn serve_e2e_proxied_tool_lists_round_trips_and_second_call_is_cache_served() { &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), ); - // -- tools/list: 17 local + 1 proxied, prefixed description -- + // -- tools/list: 19 local + 1 proxied, prefixed description -- send( &mut stdin, &json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), ); let resp = recv(&rx); let tools = resp["result"]["tools"].as_array().expect("tools array"); - assert_eq!(tools.len(), 18, "tools: {tools:#?}"); + assert_eq!(tools.len(), 20, "tools: {tools:#?}"); let proxied = tools .iter() .find(|t| t["name"] == json!("get_code")) From dc5b881b014c722818ddccf92d453ffa83a5149b Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 22:54:03 -0700 Subject: [PATCH 50/56] spec(figmog): standalone repo migration & overhaul design Co-Authored-By: Claude Fable 5 --- .../2026-08-16-figmog-standalone-repo.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md diff --git a/docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md b/docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md new file mode 100644 index 0000000..46523af --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md @@ -0,0 +1,132 @@ +# figmog standalone repo — migration & overhaul design + +**Date:** 2026-08-16 +**Status:** Approved in discussion; this document is the binding record. +**From:** `sanctuarycomputer/clog` branch `worktree-figmog`, `examples/figmog` +**To:** new repo `sanctuarycomputer/figmog`, crate at root + +## 1. Goal + +figmog graduates from a hackathon example crate to a standalone, +distributable tool: its own repo, `fold` pulled via git, dead weight +removed, and downloadable binaries on GitHub Releases. Homebrew is +explicitly deferred (no tap, no formula — revisit later). + +## 2. New repo shape + +``` +figmog/ + Cargo.toml — single crate (bin + lib), version 0.1.0 + src/ — moved wholesale from examples/figmog/src (minus cuts) + tests/ — moved wholesale (minus cuts) + docs/ + SPEC.md — consolidated current-state spec (no v1..v5 archaeology) + history/… — the old build-design spec + plans, verbatim, read-only + README.md — rewritten standalone (install via release binary, + quick start, MCP setup, tool tables, limitations) + CLAUDE.md — figmog's standing rules (see §6) + LICENSE — MIT, covering figmog's own code + .github/workflows/ + ci.yml — test + clippy(-D warnings, --no-deps) + fmt on push/PR + release.yml — see §5 +``` + +History: clean start — one import commit whose message records provenance +(source repo, branch, PR #1 URL). No filter-repo. + +## 3. Dependencies + +```toml +fold = { git = "https://github.com/flowercomputers/bogkit", rev = "" } +``` + +- `anny` rides along via fold's internal path-dep; everything else stays + crates.io as today (serde, serde_json, ureq 2, clap 4, thiserror 2; + dev: tempfile, assert_cmd, postcard). +- The rev is pinned and bumped deliberately. Documented fallback: retarget + to the `sanctuarycomputer/clog` fork if upstream moves or archives. +- **License gate:** `fold` currently has no license (bogkit repo has no + LICENSE file, fold's Cargo.toml no license field). figmog's own code is + MIT, and building locally is fine — but **publishing release binaries + that embed fold waits until upstream adds a license**. The release + workflow lands ready; the first public (non-draft) release is a manual + act taken after that clears. README states this plainly until resolved. + +## 4. Cuts and shakeout + +**Removed surfaces (approved):** +- `bench.rs`, `repl.rs`, the corpus generator, `--interactive` — the whole + bench/demo apparatus (~2.5k lines) and its spec §13 (moves to history). +- `figmog watch` CLI command (serve owns sync; `pull` remains for + one-shots). Its helpers survive only where serve/sessions use them. +- Human-mode CLI output: every read command emits JSON only; the `--json` + flag disappears (JSON is the only mode); errors are JSON on stderr. + `serve`'s MCP protocol output is unchanged (it was already pure frames). + +**Kept:** pull (`--from-file`, `--fresh`), all read commands incl. the +structural pack, import-variables + inference (variables fallbacks stay), +tools/call, serve (multi-file + desktop proxy + Enterprise variables). + +**Debt paid during migration** (the deferred-minors ledger, plus fresh +audit): +- `variable_edges` dedup made real or removed (comment currently + overclaims); `merge_registry` dedup by actual local names, not prefix; + hoist the namespace check above the per-proxied-call `rtx`; + `cache::store` surfaces serialize errors; wrong-type tool args say + "expected string, got number" instead of "missing"; `--interval` + overflow clamped; serve's stdout writes tolerate a vanished client + (no broken-pipe panic); `pub`/`pub(crate)` visibility mismatches fixed; + `obj_map` clone pass removed; README carries the Tier-3 poll budget. +- Post-cut dead-code sweep: removing printers/bench orphans helpers — + `cargo clippy` dead-code warnings + a manual pass over `pub(crate)` + items with no remaining callers. +- `cli.rs` (~1.4k lines) splits: `cli/mod.rs` (clap + dispatch), + `cli/pull.rs`, `cli/read.rs`, `cli/call.rs`; printing collapses to + `serde_json::to_string_pretty` at one seam. + +## 5. Release binaries (GitHub Releases; no Homebrew) + +`release.yml`: on tag `v*` — matrix build `aarch64-apple-darwin`, +`x86_64-apple-darwin`, `x86_64-unknown-linux-gnu` (`cargo build +--release`), strip, tar.gz as `figmog--.tar.gz`, +generate `SHA256SUMS`, create a **draft** GitHub Release with the +artifacts attached. Publishing the draft is manual (and gated per §3 +until fold is licensed). README's install section: download, untar, +`chmod +x`, optionally `xattr -d com.apple.quarantine` note for +unsigned macOS binaries (no codesigning/notarization in v1 — documented +limitation). + +## 6. New-repo CLAUDE.md (standing rules) + +- Determinism: sorted output at every boundary; no HashMap iteration at + output boundaries; serde_json never with `preserve_order`. +- On-disk schema: sink names frozen; `Id`/`Rec` enums append-only + (postcard variant indices). +- fold/bogkit is upstream: never vendored, never patched; consume the + pinned git dep's public API only. +- Gates for any change: `cargo test`, `cargo clippy --no-deps -- -D + warnings`, `cargo fmt --check`. +- No new dependencies without a written justification in the PR. +- Fixtures are synthetic only — nothing derived from real client files. + +## 7. Sequencing + +1. In-flight multi-file serve work completes in the current repo + (Task 1 done; review running; Task 2 e2e/docs next) — it migrates + wholesale. +2. Adversarial whole-branch review of the multi-file milestone; push to + PR #1 (final state of the example-crate era). +3. New repo created (**user confirms creation of + `sanctuarycomputer/figmog` before any `gh repo create` runs**). +4. Migration executes per this spec: import → git-dep switch → cuts → + debt payment → module split → SPEC.md consolidation → CI + release + workflow → tag `v0.1.0` draft release. +5. PR #1 gains a "graduated to sanctuarycomputer/figmog" note; it stays + open as the hackathon artifact (upstream submission remains a separate, + user-initiated act from the fork). + +## 8. Non-goals + +Homebrew tap/formula (deferred); codesigning/notarization; crates.io +publication (fold isn't on crates.io, so figmog can't be); +Windows builds; history-preserving migration. From a03345cc2bfce4aaccca67d74d2bb083aa9882f2 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 22:59:24 -0700 Subject: [PATCH 51/56] plan(figmog): standalone migration (5 tasks, orphan-branch mechanics) Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-16-figmog-migration.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-16-figmog-migration.md diff --git a/docs/superpowers/plans/2026-08-16-figmog-migration.md b/docs/superpowers/plans/2026-08-16-figmog-migration.md new file mode 100644 index 0000000..f8e0bdc --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-figmog-migration.md @@ -0,0 +1,56 @@ +# figmog standalone migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. + +**Goal:** Execute docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md — figmog at the root of the (already-created, empty) `sanctuarycomputer/figmog` repo, fold via pinned git dep, cuts + shakeout done, CI + draft-release workflow in place, tagged v0.1.0 draft. + +**Precondition:** the multi-file serve milestone is complete and pushed on `worktree-figmog` (its final state is what migrates). Do not start while any implementer is active on this worktree. + +**Mechanics note (sandbox):** this session's git operations are confined to this worktree. The migration therefore happens on an **orphan branch** here (`figmog-standalone`), whose tree IS the new repo's root layout, pushed to `https://github.com/sanctuarycomputer/figmog.git` as `main` (`git push figmog-standalone:main`). Each task = commits on that branch + push. After the final task, switch this worktree back to `worktree-figmog`. + +**Spec:** docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md (binding; read fully before any task) + +## Global Constraints +- The spec's §6 standing rules apply to all new-repo content (determinism, frozen sinks, append-only enums, no fold patches, gates, no new deps). +- Every task ends green IN THE NEW LAYOUT: `cargo test`, `cargo clippy --no-deps -- -D warnings`, `cargo fmt --check` run at the orphan branch root. +- Commit trailer everywhere: `Co-Authored-By: Claude Fable 5 `. +- The clog-fork branch `worktree-figmog` is never modified by this plan. + +--- + +### Task 1: import + git-dep switch (the repo exists after this) + +- [ ] **Step 1 — pin determination:** `git remote add bogkit-upstream https://github.com/flowercomputers/bogkit && git fetch bogkit-upstream` (in-worktree git op). Diff our `fold/` + `anny/` against `bogkit-upstream/main` (`git diff worktree-figmog:fold bogkit-upstream/main:fold` etc.). If IDENTICAL: pin = upstream main's current rev. If upstream moved: find the newest upstream rev whose fold tree matches ours (`git log bogkit-upstream/main -- fold` + diff per rev); if none matches (upstream diverged incompatibly), STOP and report — the spec's fallback (pin the sanctuarycomputer/clog fork rev instead) needs a controller ruling with the divergence summarized. +- [ ] **Step 2 — orphan branch + layout:** `git switch --orphan figmog-standalone`; populate from `worktree-figmog`'s tree (use `git restore --source worktree-figmog -- examples/figmog docs` then move): crate files from `examples/figmog/*` to root (src/, tests/, README.md → kept for now, Cargo.toml rewritten standalone: `[package] name figmog version 0.1.0 edition 2024 license MIT` + the git dep `fold = { git = "https://github.com/flowercomputers/bogkit", rev = "" }`, same crates.io deps/dev-deps, NO workspace section); `docs/history/` gets the old spec + all figmog plan docs verbatim; LICENSE = MIT (year 2026, copyright sanctuary computer); minimal `.gitignore` (target/, .figmog/). Nothing else yet (CI, SPEC.md, README rewrite are later tasks). +- [ ] **Step 3 — build against the git dep:** `cargo test` at root (network fetch of bogkit occurs here). All 15x tests must pass unchanged — this proves the pin is faithful. Then clippy/fmt gates. +- [ ] **Step 4 — commit + push:** single commit `import figmog from sanctuarycomputer/clog (branch worktree-figmog, PR #1) as standalone crate` (+ trailer); `git push https://github.com/sanctuarycomputer/figmog.git figmog-standalone:main`. + +### Task 2: the cuts + +- [ ] Remove `src/bench.rs`, `src/repl.rs` (+ their lib.rs mods, Cmd::Bench variant + dispatch, bench/repl tests in tests/cli.rs + tests/serve.rs if any, corpus references). Remove `Cmd::Watch` + `cmd_watch` (keep helpers serve/sessions still use — compiler tells). Remove human-mode output: every read command prints `serde_json::to_string_pretty` only; delete the printer/table helpers; remove the `--json` flag (JSON is the only mode) — errors become JSON on stderr unconditionally; update every test that passed `--json` (mechanical flag removal) and any asserting human output (delete those assertions, keep the JSON ones). Spec §4 lists this as approved — the test edits here are authorized wholesale but must be enumerated in the report. +- [ ] Post-cut dead-code sweep: build with `-D warnings` (dead_code surfaces), remove orphans; manual pass over remaining `pub(crate)` items for zero-caller leftovers. +- [ ] Gates; commit `remove bench/REPL/watch and human output mode (JSON-only CLI)`; push. + +### Task 3: debt payment (spec §4's list, verbatim scope) + +- [ ] Fix each: variable_edges dedup (BTreeSet or remove + honest comment); merge_registry dedup by actual local names; hoist namespace check above per-proxied-call rtx; cache::store surfaces serialize errors (Result); wrong-type args errors say expected/got; `--interval` overflow clamp; serve stdout writes tolerate closed pipe (write! + map_err → clean exit); visibility mismatches (pub mod serve etc.); obj_map borrow instead of clone; README gains Tier-3 budget line (interim — full README rewrite is T4). Add focused tests where a fix changes observable behavior (wrong-type message, interval clamp). +- [ ] Gates; commit `pay down deferred review debt`; push. + +### Task 4: structure + docs + +- [ ] Split cli.rs (~1.4k lines): `src/cli/mod.rs` (clap types + dispatch + run), `src/cli/pull.rs` (pull/do_pull/PullError/open_store_checked/current helpers), `src/cli/read.rs` (read command fns), `src/cli/call.rs` (tools/call/import-variables). Pure moves; suite green unchanged. +- [ ] `docs/SPEC.md`: consolidated CURRENT-state spec (architecture, data model, pipeline, sync, serve/MCP tools incl. multi-file, proxy + cache, variables story, bench REMOVED — no version archaeology; ~the §2-§14 content that still exists, rewritten present-tense). Old spec stays in docs/history/ untouched. +- [ ] README.md rewrite per spec §2 (standalone: what it is, install from GitHub Releases incl. macOS quarantine note + the fold-license gate sentence while unresolved, quick start, MCP setup incl. multi-file/URL-addressed usage, CLI reference, tool tables, limitations). New-repo CLAUDE.md per spec §6. +- [ ] Gates + `cargo doc --no-deps` warning-free; commit `standalone docs and module structure`; push. + +### Task 5: CI + release + tag + +- [ ] `.github/workflows/ci.yml`: on push/PR to main — ubuntu + macos runners: `cargo test`, `cargo clippy --no-deps -- -D warnings`, `cargo fmt --check`. +- [ ] `.github/workflows/release.yml` per spec §5: on tag `v*` — matrix {aarch64-apple-darwin on macos-14, x86_64-apple-darwin on macos-13, x86_64-unknown-linux-gnu on ubuntu-latest}; `cargo build --release`; strip; `tar czf figmog-${TAG}-${TARGET}.tar.gz -C target//release figmog`; sha256 into SHA256SUMS; `gh release create "$TAG" --draft --title "$TAG"` + upload artifacts (use `softprops/action-gh-release` OR plain `gh` CLI — prefer plain `gh`, zero third-party actions beyond actions/checkout + dtolnay/rust-toolchain or rustup manual; document the choice). +- [ ] Push; verify CI runs green on the actual repo (`gh run watch/list -R sanctuarycomputer/figmog`); fix-forward if runner reality differs (allowed: iterative commits, each pushed, until green — list them). +- [ ] Tag `v0.1.0`, push tag, confirm the DRAFT release appears with 3 artifacts + checksums (`gh release view v0.1.0 -R sanctuarycomputer/figmog`). DO NOT publish the draft (fold-license gate; publishing is the user's manual act). +- [ ] Final: switch this worktree back to `worktree-figmog`. Commit nothing further there. + +## Self-review checklist +- Spec §2 layout → T1/T4; §3 dep+pin+gate → T1 (+README sentence T4); §4 cuts+debt → T2/T3; §5 release → T5 (draft-only honored); §6 CLAUDE.md → T4; §7 sequencing → precondition + task order; §8 non-goals respected (no tap, no signing, no filter-repo). +- Risk center: T1's pin-parity check (fold drift would silently change engine behavior — the full-suite gate at T1 Step 3 is the proof). From a74fda53f3f485eab801cc9c4adb179bc2dbeb00 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 23:00:06 -0700 Subject: [PATCH 52/56] =?UTF-8?q?plan(figmog):=20pin=20verified=20?= =?UTF-8?q?=E2=80=94=20bogkit=20fold=20source-identical=20at=2020f2ca5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/superpowers/plans/2026-08-16-figmog-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-16-figmog-migration.md b/docs/superpowers/plans/2026-08-16-figmog-migration.md index f8e0bdc..cfc1da9 100644 --- a/docs/superpowers/plans/2026-08-16-figmog-migration.md +++ b/docs/superpowers/plans/2026-08-16-figmog-migration.md @@ -20,7 +20,7 @@ ### Task 1: import + git-dep switch (the repo exists after this) -- [ ] **Step 1 — pin determination:** `git remote add bogkit-upstream https://github.com/flowercomputers/bogkit && git fetch bogkit-upstream` (in-worktree git op). Diff our `fold/` + `anny/` against `bogkit-upstream/main` (`git diff worktree-figmog:fold bogkit-upstream/main:fold` etc.). If IDENTICAL: pin = upstream main's current rev. If upstream moved: find the newest upstream rev whose fold tree matches ours (`git log bogkit-upstream/main -- fold` + diff per rev); if none matches (upstream diverged incompatibly), STOP and report — the spec's fallback (pin the sanctuarycomputer/clog fork rev instead) needs a controller ruling with the divergence summarized. +- [ ] **Step 1 — pin determination: DONE by the controller (2026-08-16).** Upstream fetched (`bogkit-upstream` remote exists in this worktree); `fold` at `bogkit-upstream/main` is source-identical to our tree (only a `readme = "readme.md"` metadata line + readme files differ; `anny` likewise readme-only). **The pin is `rev = "20f2ca50d5d06f51edfe8b8570c0fb48caf9eb81"`.** Task 1 Step 3's full-suite gate remains the executable proof. (Upstream still has no LICENSE anywhere — the §3 release gate stands.) - [ ] **Step 2 — orphan branch + layout:** `git switch --orphan figmog-standalone`; populate from `worktree-figmog`'s tree (use `git restore --source worktree-figmog -- examples/figmog docs` then move): crate files from `examples/figmog/*` to root (src/, tests/, README.md → kept for now, Cargo.toml rewritten standalone: `[package] name figmog version 0.1.0 edition 2024 license MIT` + the git dep `fold = { git = "https://github.com/flowercomputers/bogkit", rev = "" }`, same crates.io deps/dev-deps, NO workspace section); `docs/history/` gets the old spec + all figmog plan docs verbatim; LICENSE = MIT (year 2026, copyright sanctuary computer); minimal `.gitignore` (target/, .figmog/). Nothing else yet (CI, SPEC.md, README rewrite are later tasks). - [ ] **Step 3 — build against the git dep:** `cargo test` at root (network fetch of bogkit occurs here). All 15x tests must pass unchanged — this proves the pin is faithful. Then clippy/fmt gates. - [ ] **Step 4 — commit + push:** single commit `import figmog from sanctuarycomputer/clog (branch worktree-figmog, PR #1) as standalone crate` (+ trailer); `git push https://github.com/sanctuarycomputer/figmog.git figmog-standalone:main`. From bd74b677a3cbdb5063c07d52e9206c70d7f8ab07 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 23:05:10 -0700 Subject: [PATCH 53/56] =?UTF-8?q?plan(figmog):=20migration=20amendments=20?= =?UTF-8?q?=E2=80=94=20v0.0.1,=20PR=20flow,=20published=20pre-release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/superpowers/plans/2026-08-16-figmog-migration.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-08-16-figmog-migration.md b/docs/superpowers/plans/2026-08-16-figmog-migration.md index cfc1da9..49ee2c1 100644 --- a/docs/superpowers/plans/2026-08-16-figmog-migration.md +++ b/docs/superpowers/plans/2026-08-16-figmog-migration.md @@ -6,7 +6,9 @@ **Precondition:** the multi-file serve milestone is complete and pushed on `worktree-figmog` (its final state is what migrates). Do not start while any implementer is active on this worktree. -**Mechanics note (sandbox):** this session's git operations are confined to this worktree. The migration therefore happens on an **orphan branch** here (`figmog-standalone`), whose tree IS the new repo's root layout, pushed to `https://github.com/sanctuarycomputer/figmog.git` as `main` (`git push figmog-standalone:main`). Each task = commits on that branch + push. After the final task, switch this worktree back to `worktree-figmog`. +**Mechanics note (sandbox):** this session's git operations are confined to this worktree. The migration therefore happens on an **orphan branch** here (`figmog-standalone`), whose tree IS the new repo's root layout. **PR flow (user-directed):** first seed the empty repo's `main` with a minimal init commit (LICENSE + one-line README), then push the orphan branch as `first-pass` and open a PR against `main`. All five tasks commit onto that branch (pushed after each task); per-pillar adversarial reviews run against the PR; when clean, the PR merges into `main` and the release tag is cut from `main`. After the final task, switch this worktree back to `worktree-figmog`. + +**Version + release (user-directed):** crate version **0.0.1**, tag **v0.0.1**, release **published as a pre-release** (not draft) so the user can download and test the binary. The fold-license caveat remains a README sentence about broader distribution; the user has accepted publishing on their own repo for testing. **Spec:** docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md (binding; read fully before any task) @@ -21,7 +23,7 @@ ### Task 1: import + git-dep switch (the repo exists after this) - [ ] **Step 1 — pin determination: DONE by the controller (2026-08-16).** Upstream fetched (`bogkit-upstream` remote exists in this worktree); `fold` at `bogkit-upstream/main` is source-identical to our tree (only a `readme = "readme.md"` metadata line + readme files differ; `anny` likewise readme-only). **The pin is `rev = "20f2ca50d5d06f51edfe8b8570c0fb48caf9eb81"`.** Task 1 Step 3's full-suite gate remains the executable proof. (Upstream still has no LICENSE anywhere — the §3 release gate stands.) -- [ ] **Step 2 — orphan branch + layout:** `git switch --orphan figmog-standalone`; populate from `worktree-figmog`'s tree (use `git restore --source worktree-figmog -- examples/figmog docs` then move): crate files from `examples/figmog/*` to root (src/, tests/, README.md → kept for now, Cargo.toml rewritten standalone: `[package] name figmog version 0.1.0 edition 2024 license MIT` + the git dep `fold = { git = "https://github.com/flowercomputers/bogkit", rev = "" }`, same crates.io deps/dev-deps, NO workspace section); `docs/history/` gets the old spec + all figmog plan docs verbatim; LICENSE = MIT (year 2026, copyright sanctuary computer); minimal `.gitignore` (target/, .figmog/). Nothing else yet (CI, SPEC.md, README rewrite are later tasks). +- [ ] **Step 2 — orphan branch + layout:** `git switch --orphan figmog-standalone`; populate from `worktree-figmog`'s tree (use `git restore --source worktree-figmog -- examples/figmog docs` then move): crate files from `examples/figmog/*` to root (src/, tests/, README.md → kept for now, Cargo.toml rewritten standalone: `[package] name figmog version 0.0.1 edition 2024 license MIT` + the git dep `fold = { git = "https://github.com/flowercomputers/bogkit", rev = "" }`, same crates.io deps/dev-deps, NO workspace section); `docs/history/` gets the old spec + all figmog plan docs verbatim; LICENSE = MIT (year 2026, copyright sanctuary computer); minimal `.gitignore` (target/, .figmog/). Nothing else yet (CI, SPEC.md, README rewrite are later tasks). - [ ] **Step 3 — build against the git dep:** `cargo test` at root (network fetch of bogkit occurs here). All 15x tests must pass unchanged — this proves the pin is faithful. Then clippy/fmt gates. - [ ] **Step 4 — commit + push:** single commit `import figmog from sanctuarycomputer/clog (branch worktree-figmog, PR #1) as standalone crate` (+ trailer); `git push https://github.com/sanctuarycomputer/figmog.git figmog-standalone:main`. @@ -48,7 +50,7 @@ - [ ] `.github/workflows/ci.yml`: on push/PR to main — ubuntu + macos runners: `cargo test`, `cargo clippy --no-deps -- -D warnings`, `cargo fmt --check`. - [ ] `.github/workflows/release.yml` per spec §5: on tag `v*` — matrix {aarch64-apple-darwin on macos-14, x86_64-apple-darwin on macos-13, x86_64-unknown-linux-gnu on ubuntu-latest}; `cargo build --release`; strip; `tar czf figmog-${TAG}-${TARGET}.tar.gz -C target//release figmog`; sha256 into SHA256SUMS; `gh release create "$TAG" --draft --title "$TAG"` + upload artifacts (use `softprops/action-gh-release` OR plain `gh` CLI — prefer plain `gh`, zero third-party actions beyond actions/checkout + dtolnay/rust-toolchain or rustup manual; document the choice). - [ ] Push; verify CI runs green on the actual repo (`gh run watch/list -R sanctuarycomputer/figmog`); fix-forward if runner reality differs (allowed: iterative commits, each pushed, until green — list them). -- [ ] Tag `v0.1.0`, push tag, confirm the DRAFT release appears with 3 artifacts + checksums (`gh release view v0.1.0 -R sanctuarycomputer/figmog`). DO NOT publish the draft (fold-license gate; publishing is the user's manual act). +- [ ] Tag `v0.0.1`, push tag, confirm the release appears with 3 artifacts + checksums (`gh release view v0.1.0 -R sanctuarycomputer/figmog`). Publish as PRE-RELEASE (user-directed for testing); README keeps the fold-license sentence for broader distribution. - [ ] Final: switch this worktree back to `worktree-figmog`. Commit nothing further there. ## Self-review checklist From 9eb061ac6ac8a60e06de69f7ca53e8bdf5bbccff Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 23:05:37 -0700 Subject: [PATCH 54/56] plan(figmog): PR-flow mechanics, v0.0.1 pre-release consistency Co-Authored-By: Claude Fable 5 --- docs/superpowers/plans/2026-08-16-figmog-migration.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-08-16-figmog-migration.md b/docs/superpowers/plans/2026-08-16-figmog-migration.md index 49ee2c1..61cb6ed 100644 --- a/docs/superpowers/plans/2026-08-16-figmog-migration.md +++ b/docs/superpowers/plans/2026-08-16-figmog-migration.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. -**Goal:** Execute docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md — figmog at the root of the (already-created, empty) `sanctuarycomputer/figmog` repo, fold via pinned git dep, cuts + shakeout done, CI + draft-release workflow in place, tagged v0.1.0 draft. +**Goal:** Execute docs/superpowers/specs/2026-08-16-figmog-standalone-repo.md — figmog at the root of the (already-created, empty) `sanctuarycomputer/figmog` repo, fold via pinned git dep, cuts + shakeout done, CI + release workflow in place, tagged v0.0.1 published pre-release. **Precondition:** the multi-file serve milestone is complete and pushed on `worktree-figmog` (its final state is what migrates). Do not start while any implementer is active on this worktree. @@ -25,7 +25,7 @@ - [ ] **Step 1 — pin determination: DONE by the controller (2026-08-16).** Upstream fetched (`bogkit-upstream` remote exists in this worktree); `fold` at `bogkit-upstream/main` is source-identical to our tree (only a `readme = "readme.md"` metadata line + readme files differ; `anny` likewise readme-only). **The pin is `rev = "20f2ca50d5d06f51edfe8b8570c0fb48caf9eb81"`.** Task 1 Step 3's full-suite gate remains the executable proof. (Upstream still has no LICENSE anywhere — the §3 release gate stands.) - [ ] **Step 2 — orphan branch + layout:** `git switch --orphan figmog-standalone`; populate from `worktree-figmog`'s tree (use `git restore --source worktree-figmog -- examples/figmog docs` then move): crate files from `examples/figmog/*` to root (src/, tests/, README.md → kept for now, Cargo.toml rewritten standalone: `[package] name figmog version 0.0.1 edition 2024 license MIT` + the git dep `fold = { git = "https://github.com/flowercomputers/bogkit", rev = "" }`, same crates.io deps/dev-deps, NO workspace section); `docs/history/` gets the old spec + all figmog plan docs verbatim; LICENSE = MIT (year 2026, copyright sanctuary computer); minimal `.gitignore` (target/, .figmog/). Nothing else yet (CI, SPEC.md, README rewrite are later tasks). - [ ] **Step 3 — build against the git dep:** `cargo test` at root (network fetch of bogkit occurs here). All 15x tests must pass unchanged — this proves the pin is faithful. Then clippy/fmt gates. -- [ ] **Step 4 — commit + push:** single commit `import figmog from sanctuarycomputer/clog (branch worktree-figmog, PR #1) as standalone crate` (+ trailer); `git push https://github.com/sanctuarycomputer/figmog.git figmog-standalone:main`. +- [ ] **Step 4 — seed main, push branch, open PR:** first seed the empty repo: create a tiny init commit (LICENSE + one-line README stub) on a temp orphan branch and `git push https://github.com/sanctuarycomputer/figmog.git :main`. Then commit the import on `figmog-standalone` (`import figmog from sanctuarycomputer/clog (branch worktree-figmog, PR #1) as standalone crate` + trailer), `git push https://github.com/sanctuarycomputer/figmog.git figmog-standalone:first-pass`, and `gh pr create -R sanctuarycomputer/figmog --base main --head first-pass` (title "figmog first pass", body summarizing the import + planned task commits; PR body ends with the standard generated-with footer). ### Task 2: the cuts @@ -48,11 +48,12 @@ ### Task 5: CI + release + tag - [ ] `.github/workflows/ci.yml`: on push/PR to main — ubuntu + macos runners: `cargo test`, `cargo clippy --no-deps -- -D warnings`, `cargo fmt --check`. -- [ ] `.github/workflows/release.yml` per spec §5: on tag `v*` — matrix {aarch64-apple-darwin on macos-14, x86_64-apple-darwin on macos-13, x86_64-unknown-linux-gnu on ubuntu-latest}; `cargo build --release`; strip; `tar czf figmog-${TAG}-${TARGET}.tar.gz -C target//release figmog`; sha256 into SHA256SUMS; `gh release create "$TAG" --draft --title "$TAG"` + upload artifacts (use `softprops/action-gh-release` OR plain `gh` CLI — prefer plain `gh`, zero third-party actions beyond actions/checkout + dtolnay/rust-toolchain or rustup manual; document the choice). +- [ ] `.github/workflows/release.yml` per spec §5: on tag `v*` — matrix {aarch64-apple-darwin on macos-14, x86_64-apple-darwin on macos-13, x86_64-unknown-linux-gnu on ubuntu-latest}; `cargo build --release`; strip; `tar czf figmog-${TAG}-${TARGET}.tar.gz -C target//release figmog`; sha256 into SHA256SUMS; `gh release create "$TAG" --prerelease --title "$TAG"` + upload artifacts (use `softprops/action-gh-release` OR plain `gh` CLI — prefer plain `gh`, zero third-party actions beyond actions/checkout + dtolnay/rust-toolchain or rustup manual; document the choice). - [ ] Push; verify CI runs green on the actual repo (`gh run watch/list -R sanctuarycomputer/figmog`); fix-forward if runner reality differs (allowed: iterative commits, each pushed, until green — list them). -- [ ] Tag `v0.0.1`, push tag, confirm the release appears with 3 artifacts + checksums (`gh release view v0.1.0 -R sanctuarycomputer/figmog`). Publish as PRE-RELEASE (user-directed for testing); README keeps the fold-license sentence for broader distribution. +- [ ] Tag `v0.0.1`, push tag, confirm the release appears with 3 artifacts + checksums (`gh release view v0.0.1 -R sanctuarycomputer/figmog`). Publish as PRE-RELEASE (user-directed for testing); README keeps the fold-license sentence for broader distribution. - [ ] Final: switch this worktree back to `worktree-figmog`. Commit nothing further there. ## Self-review checklist -- Spec §2 layout → T1/T4; §3 dep+pin+gate → T1 (+README sentence T4); §4 cuts+debt → T2/T3; §5 release → T5 (draft-only honored); §6 CLAUDE.md → T4; §7 sequencing → precondition + task order; §8 non-goals respected (no tap, no signing, no filter-repo). +- Spec §2 layout → T1/T4; §3 dep+pin+gate → T1 (+README sentence T4); §4 cuts+debt → T2/T3; §5 release → T5 (pre-release publish per user direction supersedes the spec's draft-only rule — ledger this as a ruling); §6 CLAUDE.md → T4; §7 sequencing → precondition + task order; §8 non-goals respected (no tap, no signing, no filter-repo). +- Per-pillar adversarial reviews (user-directed) map onto the task reviews: T1 = engine/import fidelity, T2+T3 = CLI surface & hardening, T4 = structure/docs, T5 = supply chain/CI — plus one whole-PR final review before merge. - Risk center: T1's pin-parity check (fold drift would silently change engine behavior — the full-suite gate at T1 Step 3 is the proof). From 828a5da680737ae097614a0d30cf875281d5bbae Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 23:07:11 -0700 Subject: [PATCH 55/56] fix(figmog): session resolution, typed pull errors, open poisoning Co-Authored-By: Claude Fable 5 --- examples/figmog/src/serve.rs | 150 +++++++--- examples/figmog/src/sessions.rs | 502 +++++++++++++++++++++++++------- 2 files changed, 511 insertions(+), 141 deletions(-) diff --git a/examples/figmog/src/serve.rs b/examples/figmog/src/serve.rs index b66e4c3..d1f5773 100644 --- a/examples/figmog/src/serve.rs +++ b/examples/figmog/src/serve.rs @@ -99,8 +99,14 @@ pub(crate) fn run_serve( let interval_dur = Duration::from_secs(interval); let token = std::env::var("FIGMA_TOKEN").ok(); - let (mut manager, track_current) = - build_sessions(db_override, &files, no_watch, &token, &figmog_root)?; + let (mut manager, track_current) = build_sessions( + db_override, + &files, + no_watch, + &token, + &figmog_root, + interval_dur, + )?; // Upstream probe: no mid-session re-probe in v3 — an unreachable // desktop server at startup means local-only tools for the process's @@ -198,6 +204,7 @@ pub(crate) fn run_serve( upstream_status, no_watch, track_current, + interval_dur, &mut next_deadline, name, args, @@ -217,18 +224,29 @@ pub(crate) fn run_serve( /// `.figmog/current` (`track_current`) — only true in the no-override /// path, matching pre-v4 behavior exactly: `--db` always resolved with no /// established key (see `cli::resolve_db`'s old short-circuit), so it -/// never wrote `.figmog/current` either. +/// never wrote `.figmog/current` either. A startup pull failure is a hard +/// error (matches old `do_pull(...)?` — the process never starts serving +/// on a file it couldn't mirror), so only the plain message half of +/// [`sessions::do_pull`]'s `(String, Duration)` failure is used here; +/// there is no retry loop yet to push a deadline out on. fn build_sessions( db_override: Option, files: &[String], no_watch: bool, token: &Option, figmog_root: &Path, + interval: Duration, ) -> Result<(SessionManager, bool), String> { if let Some(path) = db_override { // A `file` positional may still be given alongside `--db` (pre-v4 // behavior: `--db` fixes the store path, an optional file arg - // still resolves the key network operations need). + // still resolves the key network operations need). With neither, + // `network_key` stays `None` and the session's own `pull` closure + // refuses immediately with the old clean "no file key" message — + // `figmog_sync`/watch under `--no-watch` reach that at call time, + // same as pre-v4 (`!no_watch` is checked here too, eagerly, so + // watch mode still fails fast at startup rather than waiting for + // a tick). let key_opt = files.first().and_then(|f| parse_file_ref(f)); if !no_watch { key_opt @@ -238,20 +256,32 @@ fn build_sessions( .clone() .ok_or_else(|| "FIGMA_TOKEN not set — required for watch".to_string())?; } - let session_key = key_opt.unwrap_or_else(|| path.display().to_string()); - let session = sessions::open_session_at(path, session_key, token.as_deref(), false)?; + let display_key = key_opt + .clone() + .unwrap_or_else(|| path.display().to_string()); + let session = sessions::open_session_at( + path, + display_key.clone(), + key_opt.as_deref(), + token.as_deref(), + false, + )?; let mut manager = SessionManager { sessions: vec![session], root: figmog_root.to_path_buf(), token: token.clone(), + default_key: Some(display_key), }; if !no_watch { let session = &mut manager.sessions[0]; - if (session.watermark)().is_none() { - let outcome = (session.pull)()?; - session.note_pull_success(&outcome); + if !session.mirrored { + sessions::do_pull(session, interval).map_err(|(message, _wait)| message)?; } } + // `--db` never resolves a tracked key (pre-v4: `resolve_db` + // short-circuited to `Db { key: None, .. }` whenever `--db` was + // given), so it never wrote `.figmog/current` either — preserved + // verbatim via `track_current = false` below. return Ok((manager, false)); } @@ -270,19 +300,35 @@ fn build_sessions( sessions: Vec::new(), root: figmog_root.to_path_buf(), token: token.clone(), + default_key: None, }; - for f in files { + for (i, f) in files.iter().enumerate() { let session = manager.open(f)?; - if !no_watch && (session.watermark)().is_none() { - let outcome = (session.pull)()?; - session.note_pull_success(&outcome); + let key = session.key.clone(); + let just_pulled = if !no_watch && !session.mirrored { + sessions::do_pull(session, interval).map_err(|(message, _wait)| message)?; + true + } else { + false + }; + if i == 0 { + manager.default_key = Some(key.clone()); + } + // I4: a startup pull that actually ran refreshes `.figmog/current` + // for the default session, matching the old single-file + // `do_pull`'s own behavior — only on a pull that happened, not on + // every startup file regardless. + if just_pulled { + refresh_current(&manager, true, &key); } } Ok((manager, true)) } /// One round-robin watch tick: poll exactly one session's [`Watcher`](crate::watch::Watcher) -/// and, on `Changed`, pull it. Returns the next deadline. +/// and, on `Changed`, pull it via [`sessions::do_pull`] (typed-error +/// backoff, shared with every other pull call site — see that function's +/// doc comment). Returns the next deadline. fn watch_tick( manager: &mut SessionManager, next_idx: &mut usize, @@ -294,9 +340,10 @@ fn watch_tick( } // A session can only exist if opening it (an auto-open, or a startup // pull) already required a token — *except* a session whose own - // startup/auto-open pull failed (sessions.rs leaves it in place empty - // rather than evicting it — no idle eviction, spec §14 non-goal). - // Either way, without a token there's nothing safe to poll this tick. + // startup/auto-open pull failed (sessions.rs leaves it in place, + // `mirrored: false`, rather than evicting it — no idle eviction, spec + // §14 non-goal; a later `resolve()` retries it). Either way, without a + // token there's nothing safe to poll this tick. let Some(token) = manager.token.clone() else { return Instant::now() + interval; }; @@ -313,24 +360,13 @@ fn watch_tick( match session.watcher.tick(&api, &key) { Tick::Unchanged => Instant::now() + deadline, Tick::Wait { after } => Instant::now() + after, - Tick::Changed { .. } => match (session.pull)() { - Ok(outcome) => { - session.note_pull_success(&outcome); + Tick::Changed { .. } => match sessions::do_pull(session, interval) { + Ok(_outcome) => { refresh_current(manager, track_current, &key); Instant::now() + deadline } - Err(e) => { - eprintln!("figmog: pull failed for {key}: {e}"); - // No typed `ApiError` survives a session's `pull` closure - // (spec §14's interface returns a plain `String` — see - // sessions.rs), so watch-triggered pull failures get plain - // per-session exponential backoff rather than the - // Retry-After-aware `cli::pull_failure_wait` the CLI's own - // `pull`/`watch` commands still use unchanged. - let session = &mut manager.sessions[idx]; - session.watcher = crate::watch::Watcher::new((session.watermark)()); - let wait = session.backoff; - session.backoff = (session.backoff * 2).min(crate::watch::BACKOFF_CAP); + Err((message, wait)) => { + eprintln!("figmog: pull failed for {key}: {message}"); Instant::now() + wait } }, @@ -338,11 +374,12 @@ fn watch_tick( } /// Refresh `.figmog/current` to `key` — only when `track` (the no-`--db`- -/// override startup path) and `key` is the *default* session's, matching -/// old single-file behavior for the one invocation shape that used to do -/// this (`figmog serve `, no `--db`). +/// override startup path) and `key` is the *default* session's (spec §14's +/// default rule via [`SessionManager::effective_default_key`], not merely +/// index 0), matching old single-file behavior for the one invocation +/// shape that used to do this (`figmog serve `, no `--db`). fn refresh_current(manager: &SessionManager, track: bool, key: &str) { - if track && manager.sessions.first().map(|s| s.key.as_str()) == Some(key) { + if track && manager.effective_default_key().as_deref() == Some(key) { let _ = crate::cli::write_current(key); } } @@ -352,6 +389,12 @@ fn refresh_current(manager: &SessionManager, track: bool, key: &str) { /// argument is extracted (and stripped before tool-specific arg parsing) /// and routed through [`SessionManager::resolve`]; non-local names are /// proxied — see this module's doc comment for the cache-routing choice. +/// A pull failure anywhere here (auto-open inside `resolve`, an explicit +/// `figmog_sync`, `figmog_open`) pushes `next_deadline` out by the same +/// Retry-After-aware wait [`sessions::do_pull`] computed, so a rate-limited +/// on-demand pull doesn't let the background watch loop immediately +/// re-hit the same limit for that session (build design §12, restored +/// from the pre-refactor single-session `figmog_sync` handler). #[allow(clippy::too_many_arguments)] fn handle_tool_call( manager: &mut SessionManager, @@ -359,6 +402,7 @@ fn handle_tool_call( upstream_status: &'static str, no_watch: bool, track_current: bool, + interval: Duration, next_deadline: &mut Instant, name: &str, args: &Value, @@ -370,8 +414,13 @@ fn handle_tool_call( if name == "figmog_open" { let file = dispatch::require_str(args, "file")?; let session = manager.open(&file)?; - let outcome = (session.pull)()?; - session.note_pull_success(&outcome); + let outcome = match sessions::do_pull(session, interval) { + Ok(outcome) => outcome, + Err((message, wait)) => { + *next_deadline = Instant::now() + wait; + return Err(message); + } + }; let key = session.key.clone(); // The node count alone — everything else in the result comes // straight from `outcome`, which already has this pull's @@ -401,11 +450,30 @@ fn handle_tool_call( } if proxy::is_local_tool(name) { - let session = manager.resolve(file_arg.as_deref())?; + let (session, just_pulled) = + manager + .resolve(file_arg.as_deref(), interval) + .map_err(|e| { + if let Some(wait) = e.retry_after { + *next_deadline = Instant::now() + wait; + } + e.message + })?; if name == "figmog_sync" { - let outcome = (session.pull)()?; - session.note_pull_success(&outcome); + // `resolve` already spent this call's one pull if the session + // was new/unmirrored — skip the redundant second Tier-1 pull + // `figmog_sync` would otherwise always perform. + let outcome = match just_pulled { + Some(outcome) => outcome, + None => match sessions::do_pull(session, interval) { + Ok(outcome) => outcome, + Err((message, wait)) => { + *next_deadline = Instant::now() + wait; + return Err(message); + } + }, + }; let key = session.key.clone(); let churn_value = serde_json::to_value(&outcome.churn).map_err(|e| e.to_string())?; refresh_current(manager, track_current, &key); diff --git a/examples/figmog/src/sessions.rs b/examples/figmog/src/sessions.rs index ccbd776..9ddb838 100644 --- a/examples/figmog/src/sessions.rs +++ b/examples/figmog/src/sessions.rs @@ -1,20 +1,26 @@ //! Multi-file serve (spec §14): each mirrored Figma file is a [`FileSession`] //! — its own store, opened at its own `open_store!` call site, with //! everything that touches it captured in boxed closures (`dispatch`, -//! `pull`, `watermark`). This generalizes the single-store logic -//! `serve.rs` used to inline directly: the store's pipeline type contains -//! fn items and can't be named (see the doc comment on `store.rs`'s -//! `open_store!` macro and the identical note in `cli::dispatch`), so a -//! session's three closures share the one open handle via `Rc>` -//! — the only way three independently-boxed `FnMut`s can all reach the -//! same unnameable, non-`Copy` value. +//! `pull`, `watermark`, `proxy_cache` — four, not three; see +//! [`FileSession`]'s own doc comment for why a fourth closure was +//! necessary beyond spec §14's literal three). This generalizes the +//! single-store logic `serve.rs` used to inline directly: the store's +//! pipeline type contains fn items and can't be named (see the doc +//! comment on `store.rs`'s `open_store!` macro and the identical note in +//! `cli::dispatch`), so a session's closures share the one open handle via +//! `Rc>` — the only way several independently-boxed `FnMut`s +//! can all reach the same unnameable, non-`Copy` value. //! -//! [`SessionManager`] owns every open session, in open order (first = -//! default — spec §14's resolution rule for an omitted `file` argument), -//! and implements the `file`-argument routing rule: explicit `file` → -//! that mirror, auto-opening (and spending exactly one Tier-1 pull) if -//! it's new to this manager; omitted → the first-opened session, or an -//! error naming `figmog_open`/`figmog_files` if none exists yet. +//! [`SessionManager`] owns every open session, in open order, and +//! implements the `file`-argument routing rule (spec §14): explicit +//! `file` → that mirror, auto-opening (and spending exactly one Tier-1 +//! pull, with backoff/retry — see [`do_pull`]) if it's new *or was never +//! successfully mirrored*; omitted → the first **startup** FILE if one +//! was given, else the single mirrored file if exactly one exists, else +//! an error naming `figmog_open`/`figmog_files`. `default_key` carries +//! the startup-established default independently of open order, so a +//! later auto-open (or two) never silently becomes "the default" the way +//! plain first-in-`Vec` order would. //! //! **Proxied (upstream) tools stay outside this module entirely** — spec //! §14's documented caveat is that the desktop server has no concept of @@ -33,7 +39,7 @@ use std::time::Duration; use serde_json::{Value, json}; use crate::api::{FigmaApi, UreqApi}; -use crate::cli::{now_ms, open_store_checked}; +use crate::cli::{PullError, now_ms, open_store_checked, pull_failure_wait}; use crate::dispatch; use crate::flatten::flatten_file; use crate::ident::parse_file_ref; @@ -45,6 +51,7 @@ use crate::watch::{BACKOFF_START, Watcher}; /// What one [`FileSession::pull`] call did: the sync churn plus the file's /// name/version as of that pull — `figmog_open`'s own result and /// `figmog_sync`'s churn value are both built from this. +#[derive(Debug)] pub(crate) struct PullOutcome { pub(crate) churn: Churn, pub(crate) name: String, @@ -57,7 +64,7 @@ pub(crate) struct PullOutcome { // can't be named (this module's doc comment), so it can never appear in a // named struct field directly — only behind one of these closures. type DispatchFn = Box Result>; -type PullFn = Box Result>; +type PullFn = Box Result>; type WatermarkFn = Box Option>; type ProxyCacheFn = Box< dyn FnMut(&mut crate::upstream::HttpUpstream, &str, &Value) -> Result<(Value, bool), String>, @@ -66,9 +73,19 @@ type ProxyCacheFn = Box< /// One mirrored Figma file. `dispatch` answers the 16 read-only /// `figmog_*` tools (everything [`dispatch::dispatch_read_tool`] knows); /// `pull` runs one Tier-1 fetch-flatten-sync-evict cycle (used by -/// startup, `figmog_sync`, `figmog_open`, and a watch tick's `Changed` -/// branch); `watermark` reads the stored `FileMeta.last_modified`, used to -/// (re)seed `watcher` after every successful pull. `watcher`/`backoff` are +/// startup, `figmog_sync`, `figmog_open`, a watch tick's `Changed` branch, +/// and [`SessionManager::resolve`]'s auto-open) and returns the *typed* +/// [`PullError`] on failure — not a plain string — so every call site can +/// apply the same Retry-After-aware backoff `cli::pull_failure_wait` +/// gives the CLI's own `pull`/`watch` commands (see [`do_pull`], the +/// shared helper every one of those call sites goes through); `watermark` +/// reads the stored `FileMeta.last_modified`, used to (re)seed `watcher` +/// after every successful pull. `mirrored`: whether this session has ever +/// completed a successful pull — `false` right after a *failed* auto-open +/// (a session that pushed into `SessionManager::sessions` before its pull +/// could succeed must not be treated as "already mirrored" forever, or a +/// transient failure would silently poison the key into permanent empty +/// results — see [`SessionManager::resolve`]). `watcher`/`backoff` are /// plain per-session state (not closures) so `serve.rs`'s round-robin tick /// loop can drive them directly, exactly as the old single-session loop /// drove its own local variables. @@ -90,28 +107,59 @@ pub(crate) struct FileSession { pub(crate) proxy_cache: ProxyCacheFn, pub(crate) watcher: Watcher, pub(crate) backoff: Duration, + pub(crate) mirrored: bool, } impl FileSession { - /// Reseed `watcher`/`backoff` after a successful pull from any call - /// site (startup, `figmog_sync`, `figmog_open`, a watch tick) — the - /// same bookkeeping every one of those paths used to repeat inline. + /// Reseed `watcher`/`backoff`/`mirrored` after a successful pull from + /// any call site (startup, `figmog_sync`, `figmog_open`, a watch + /// tick, auto-open) — the same bookkeeping every one of those paths + /// used to repeat inline. pub(crate) fn note_pull_success(&mut self, outcome: &PullOutcome) { self.name = outcome.name.clone(); let seen = (self.watermark)(); self.watcher = Watcher::new(seen); self.backoff = BACKOFF_START; + self.mirrored = true; + } +} + +/// Run `session.pull()` and apply the same failure-backoff discipline +/// `cli::pull_failure_wait` gives the CLI's own `pull`/`watch` commands +/// (Retry-After honored for a rate limit, exponential otherwise, capped): +/// on success, reseed the session via [`FileSession::note_pull_success`]; +/// on failure, reset `watcher` to the last successfully-synced watermark +/// (so the same change is re-detected next time — same discipline +/// `cmd_watch`/the old single-session `run_serve` used) and advance +/// `backoff`, returning the resulting wait alongside the stringified +/// error so every call site (watch tick, `figmog_sync`, `figmog_open`, +/// `SessionManager::resolve`'s auto-open) can push its own `next_deadline` +/// out by the same amount — spec §14 "backoff discipline per session". +/// Every one of those call sites shares this single implementation so +/// "same treatment" is structural, not a convention to keep in sync by +/// hand. +pub(crate) fn do_pull( + session: &mut FileSession, + interval: Duration, +) -> Result { + match (session.pull)() { + Ok(outcome) => { + session.note_pull_success(&outcome); + Ok(outcome) + } + Err(e) => { + session.watcher = Watcher::new((session.watermark)()); + let wait = pull_failure_wait(&e, &mut session.backoff, interval); + Err((e.to_string(), wait)) + } } } /// Build a [`FileSession`] mirroring `key` under `root` (`root//db` — /// the per-key store layout every mirror has used since v1, see -/// `cli::db_path_for`). This owns the concrete `open_store!` call site; -/// `pull_now`: perform one Tier-1 pull-and-sync cycle before returning, -/// unconditionally (callers that only want a pull when the store happens -/// to be empty check that themselves — via the returned session's own -/// `watermark()` — since deciding requires opening the store anyway, and -/// this function is the only thing that does). +/// `cli::db_path_for`). Thin wrapper over [`open_session_at`] with +/// `network_key = Some(key)` — every session built this way (startup +/// files, auto-open) always has a real, parsed Figma key to pull with. pub(crate) fn open_session( root: &Path, key: &str, @@ -119,22 +167,33 @@ pub(crate) fn open_session( pull_now: bool, ) -> Result { let path = root.join(key).join("db"); - open_session_at(path, key.to_string(), api_token, pull_now) + open_session_at(path, key.to_string(), Some(key), api_token, pull_now) } /// Like [`open_session`], but at an explicit store path rather than one -/// derived from `root`/`key` — the CLI's legacy `--db ` escape hatch -/// (`serve.rs`'s `run_serve`) needs this: it predates multi-file serve and -/// its existing tests pin an explicit, arbitrary store directory (no -/// `--figmog-root` layout involved), so `figmog serve --db ` keeps -/// opening exactly that path as a single session, unchanged. +/// derived from `root`/`key`, and with the Figma key used for *network* +/// calls tracked separately from `key` (the session's display/dedupe +/// identity). The CLI's legacy `--db ` escape hatch (`serve.rs`'s +/// `run_serve`) needs both: it predates multi-file serve and its existing +/// tests pin an explicit, arbitrary store directory (no `--figmog-root` +/// layout involved), so `figmog serve --db ` keeps opening exactly +/// that path as a single session, unchanged — and when no file ref was +/// given alongside `--db` either, that session has no real Figma key at +/// all. `network_key: None` is exactly that case: the built session still +/// dedupes/displays under a path-derived sentinel `key`, but any attempt +/// to pull it fails immediately with the same clean message pre-v4 +/// `figmog serve`/`do_pull` always gave ("no file key: pass a file key or +/// figma.com URL") instead of trying — and failing confusingly — to fetch +/// a "file" named after a filesystem path. pub(crate) fn open_session_at( path: PathBuf, key: String, + network_key: Option<&str>, api_token: Option<&str>, pull_now: bool, ) -> Result { let token = api_token.map(str::to_string); + let network_key = network_key.map(str::to_string); let st = Rc::new(RefCell::new(open_store_checked(|| { crate::open_store!(&path) })?)); @@ -143,21 +202,25 @@ pub(crate) fn open_session_at( // sequence): fetch, flatten, sync, evict stale cache rows on a version // change. Defined once and reused for the immediate `pull_now` call // below and — moved as-is — for the `pull` closure every other call - // site (`figmog_sync`, `figmog_open`, watch) drives. + // site (`figmog_sync`, `figmog_open`, watch, auto-open) drives via + // [`do_pull`]. let pull_closure = { let st = st.clone(); - let key = key.clone(); + let network_key = network_key.clone(); let token = token.clone(); - move || -> Result { - let token = token - .clone() - .ok_or_else(|| "FIGMA_TOKEN not set — required for network pulls".to_string())?; + move || -> Result { + let key = network_key.clone().ok_or_else(|| { + PullError::from("no file key: pass a file key or figma.com URL".to_string()) + })?; + let token = token.clone().ok_or_else(|| { + PullError::from("FIGMA_TOKEN not set — required for network pulls".to_string()) + })?; let api = UreqApi::new(token); - let resp = api.file(&key).map_err(|e| e.to_string())?; + let resp = api.file(&key)?; // Opportunistic Enterprise variables sync (spec §12): `Ok(None)` // on non-Enterprise plans is not an error — v1 behavior // (import/inference, sweep-exempt) holds unchanged below. - let vars_resp = api.variables_local(&key).map_err(|e| e.to_string())?; + let vars_resp = api.variables_local(&key)?; let mut flattened = flatten_file(&resp).map_err(|e| e.to_string())?; let mut st = st.borrow_mut(); @@ -197,9 +260,12 @@ pub(crate) fn open_session_at( }; if pull_now { - pull_closure()?; + pull_closure().map_err(|e| e.to_string())?; } + let meta_present = st + .borrow() + .rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).is_some()); let name = st .borrow() .rtx(|(_, _, _, _, _, _, meta, _)| meta.get(&0).map(|m| m.name.clone())) @@ -265,16 +331,41 @@ pub(crate) fn open_session_at( proxy_cache, watcher: Watcher::new(seen), backoff: BACKOFF_START, + mirrored: meta_present, }) } -/// Every mirrored file for one `figmog serve` process, in open order -/// (index 0 = default — spec §14). `root`/`token` are what every -/// auto-opened session is built with ([`open_session`]). +/// Every mirrored file for one `figmog serve` process, in open order. +/// `root`/`token` are what every auto-opened session is built with +/// ([`open_session`]). `default_key`: the startup-established default (spec +/// §14's "first startup FILE"), set once by `serve.rs`'s startup +/// orchestration and never mutated by a later auto-open — see this +/// module's doc comment and [`SessionManager::resolve`]. pub(crate) struct SessionManager { pub(crate) sessions: Vec, pub(crate) root: PathBuf, pub(crate) token: Option, + pub(crate) default_key: Option, +} + +/// A [`SessionManager::resolve`] failure: the message plus, when this was +/// a failed *pull* (rather than a bad `file` argument or an unresolvable +/// default), how long the caller should wait before this session's next +/// watch tick (see [`do_pull`]) — `None` for every other kind of failure, +/// which carries no backoff information to act on. +#[derive(Debug)] +pub(crate) struct ResolveError { + pub(crate) message: String, + pub(crate) retry_after: Option, +} + +impl From for ResolveError { + fn from(message: String) -> Self { + ResolveError { + message, + retry_after: None, + } + } } /// The `file`-argument resolution error's shared text (spec §14: must name @@ -298,44 +389,76 @@ impl SessionManager { Ok(self.sessions.last_mut().expect("just pushed")) } - /// Spec §14's `file`-argument resolution rule. Explicit `file`: - /// that mirror, auto-opening it (and spending exactly one Tier-1 pull, - /// only for a session that's genuinely new to this manager) if - /// unknown. Omitted: the first-opened session (the first startup - /// FILE if any were given, else whichever file got mirrored first), - /// or [`NO_DEFAULT_FILE_MSG`] if none exists yet. - pub(crate) fn resolve(&mut self, file_arg: Option<&str>) -> Result<&mut FileSession, String> { + /// Spec §14's default-file rule, shared by [`Self::resolve`]'s omitted + /// branch and [`Self::list`]'s `default` flag: the startup-established + /// `default_key` if one was set, else the single mirrored file if + /// exactly one exists, else `None` (ambiguous or nothing mirrored yet). + pub(crate) fn effective_default_key(&self) -> Option { + if let Some(key) = &self.default_key { + return Some(key.clone()); + } + match self.sessions.len() { + 1 => Some(self.sessions[0].key.clone()), + _ => None, + } + } + + /// Spec §14's `file`-argument resolution rule. Explicit `file`: that + /// mirror, auto-opening it if unknown *or opened-but-never- + /// successfully-mirrored* (a session whose first pull failed is + /// retried here rather than served empty forever — see + /// [`FileSession::mirrored`]), spending exactly one Tier-1 pull via + /// [`do_pull`] (whose typed failure carries the retry-after wait the + /// caller should push its own scheduling out by). Omitted: + /// [`Self::effective_default_key`]'s session, or [`NO_DEFAULT_FILE_MSG`]. + /// Returns, alongside the session, the [`PullOutcome`] of a pull this + /// call itself just performed (`None` if it didn't need to) — so a + /// caller like `figmog_sync` can skip its own redundant pull. + pub(crate) fn resolve( + &mut self, + file_arg: Option<&str>, + interval: Duration, + ) -> Result<(&mut FileSession, Option), ResolveError> { match file_arg { Some(f) => { - let key = - parse_file_ref(f).ok_or_else(|| format!("not a Figma file key or URL: {f}"))?; - let existed = self.sessions.iter().any(|s| s.key == key); let session = self.open(f)?; - if !existed { - let outcome = (session.pull)()?; - session.note_pull_success(&outcome); + if session.mirrored { + Ok((session, None)) + } else { + match do_pull(session, interval) { + Ok(outcome) => Ok((session, Some(outcome))), + Err((message, wait)) => Err(ResolveError { + message, + retry_after: Some(wait), + }), + } } - Ok(session) } None => { - if self.sessions.is_empty() { - Err(NO_DEFAULT_FILE_MSG.to_string()) - } else { - Ok(&mut self.sessions[0]) - } + let key = self + .effective_default_key() + .ok_or_else(|| NO_DEFAULT_FILE_MSG.to_string())?; + let pos = self + .sessions + .iter() + .position(|s| s.key == key) + .ok_or_else(|| NO_DEFAULT_FILE_MSG.to_string())?; + Ok((&mut self.sessions[pos], None)) } } } - /// `figmog_files`: every mirrored file, in open order (index 0 = - /// default), as `{key, name, version, nodes, last_synced, default}`. - /// Deterministic — plain `Vec` order, no `HashMap` involved. + /// `figmog_files`: every mirrored file, in open order, as `{key, + /// name, version, nodes, last_synced, default}` — `default` per + /// [`Self::effective_default_key`], not merely index 0 (spec §14: two + /// auto-opened mirrors with no startup default have *no* default at + /// all). Deterministic — plain `Vec` order, no `HashMap` involved. pub(crate) fn list(&mut self) -> Value { + let default_key = self.effective_default_key(); let rows: Vec = self .sessions .iter_mut() - .enumerate() - .map(|(i, s)| { + .map(|s| { let status = (s.dispatch)("figmog_status", &json!({})).ok(); let (name, version, nodes, last_synced) = match status { Some(ToolOutput::Json(v)) => ( @@ -346,13 +469,14 @@ impl SessionManager { ), _ => (Value::Null, Value::Null, Value::Null, Value::Null), }; + let is_default = default_key.as_deref() == Some(s.key.as_str()); json!({ "key": s.key, "name": name, "version": version, "nodes": nodes, "last_synced": last_synced, - "default": i == 0, + "default": is_default, }) }) .collect(); @@ -363,21 +487,39 @@ impl SessionManager { #[cfg(test)] mod tests { use super::*; + use crate::api::ApiError; /// A scripted stand-in for a [`FileSession`] built without ever /// touching a real store — proves [`SessionManager`]'s routing/dedupe - /// logic in isolation, per the brief's Step 1. + /// logic in isolation, per the brief's Step 1. `mirrored` matches what + /// a real session freshly opened without a `pull_now` would have. fn scripted_session(key: &str, pull_calls: Rc>) -> FileSession { + scripted_session_with(key, pull_calls, false, |_| { + Ok(PullOutcome { + churn: Churn::default(), + name: "Scripted".to_string(), + version: "1".to_string(), + }) + }) + } + + /// Like [`scripted_session`], but the caller controls `mirrored` at + /// construction and scripts the pull outcome per call (`call_index` + /// starting at 0) — used for the retry-after-failure and typed-error + /// regression tests below. + fn scripted_session_with( + key: &str, + pull_calls: Rc>, + mirrored: bool, + script: impl Fn(u32) -> Result + 'static, + ) -> FileSession { let watermark: WatermarkFn = Box::new(|| Some("t".to_string())); let pull: PullFn = { let pull_calls = pull_calls.clone(); Box::new(move || { + let call_index = *pull_calls.borrow(); *pull_calls.borrow_mut() += 1; - Ok(PullOutcome { - churn: Churn::default(), - name: "Scripted".to_string(), - version: "1".to_string(), - }) + script(call_index) }) }; FileSession { @@ -393,6 +535,7 @@ mod tests { }), watcher: Watcher::new(None), backoff: BACKOFF_START, + mirrored, } } @@ -401,67 +544,136 @@ mod tests { sessions: Vec::new(), root: PathBuf::from("/nonexistent"), token: None, + default_key: None, } } #[test] fn resolve_omitted_with_no_sessions_names_figmog_open_and_figmog_files() { let mut mgr = empty_manager(); - let err = mgr.resolve(None).map(|_| ()).unwrap_err(); - assert!(err.contains("figmog_open"), "{err}"); - assert!(err.contains("figmog_files"), "{err}"); + let err = mgr + .resolve(None, Duration::from_secs(10)) + .map(|_| ()) + .unwrap_err(); + assert!(err.message.contains("figmog_open"), "{}", err.message); + assert!(err.message.contains("figmog_files"), "{}", err.message); } #[test] - fn resolve_omitted_returns_first_opened_session() { + fn resolve_omitted_errors_when_two_mirrors_were_auto_opened_with_no_startup_default() { + // C1 regression: zero-file startup (no `default_key` ever set), + // then two files get auto-opened via explicit-`file` tool calls — + // an omitted `file` after that must still error naming + // figmog_open/figmog_files, NOT silently fall back to whichever + // was opened first. let mut mgr = empty_manager(); let calls = Rc::new(RefCell::new(0)); mgr.sessions .push(scripted_session("keyA1234567890", calls.clone())); mgr.sessions .push(scripted_session("keyB1234567890", calls.clone())); - let session = mgr.resolve(None).unwrap(); - assert_eq!(session.key, "keyA1234567890"); + assert_eq!(mgr.default_key, None); + let err = mgr + .resolve(None, Duration::from_secs(10)) + .map(|_| ()) + .unwrap_err(); + assert!(err.message.contains("figmog_open"), "{}", err.message); + assert!(err.message.contains("figmog_files"), "{}", err.message); + } + + #[test] + fn resolve_omitted_returns_the_startup_default_even_out_of_open_order() { + let mut mgr = empty_manager(); + let calls = Rc::new(RefCell::new(0)); + mgr.sessions + .push(scripted_session("keyA1234567890", calls.clone())); + mgr.sessions + .push(scripted_session("keyB1234567890", calls.clone())); + // B was opened second but is the startup-established default — + // resolve(None) must follow `default_key`, not `sessions[0]`. + mgr.default_key = Some("keyB1234567890".to_string()); + let (session, outcome) = mgr.resolve(None, Duration::from_secs(10)).unwrap(); + assert_eq!(session.key, "keyB1234567890"); + assert!(outcome.is_none()); } #[test] - fn resolve_explicit_known_key_never_pulls() { + fn resolve_omitted_returns_the_single_mirrored_file_with_no_startup_default() { let mut mgr = empty_manager(); let calls = Rc::new(RefCell::new(0)); mgr.sessions - .push(scripted_session("flAtUnMfzvA5daBSTFQK35", calls.clone())); - let session = mgr.resolve(Some("flAtUnMfzvA5daBSTFQK35")).unwrap(); + .push(scripted_session("keyA1234567890", calls.clone())); + let (session, _) = mgr.resolve(None, Duration::from_secs(10)).unwrap(); + assert_eq!(session.key, "keyA1234567890"); + } + + #[test] + fn resolve_explicit_known_mirrored_key_never_pulls() { + let mut mgr = empty_manager(); + let calls = Rc::new(RefCell::new(0)); + mgr.sessions.push(scripted_session_with( + "flAtUnMfzvA5daBSTFQK35", + calls.clone(), + true, + |_| unreachable!("must not pull an already-mirrored session"), + )); + let (session, outcome) = mgr + .resolve(Some("flAtUnMfzvA5daBSTFQK35"), Duration::from_secs(10)) + .unwrap(); assert_eq!(session.key, "flAtUnMfzvA5daBSTFQK35"); + assert!(outcome.is_none()); assert_eq!( *calls.borrow(), 0, - "an already-known session must not be re-pulled" + "an already-mirrored session must not be re-pulled" ); } #[test] - fn resolve_explicit_unknown_key_dedupes_by_key_from_a_url() { + fn resolve_explicit_unknown_key_dedupes_by_key_from_a_url_and_pulls_once() { let mut mgr = empty_manager(); let calls = Rc::new(RefCell::new(0)); - mgr.sessions - .push(scripted_session("flAtUnMfzvA5daBSTFQK35", calls.clone())); + mgr.sessions.push(scripted_session_with( + "flAtUnMfzvA5daBSTFQK35", + calls.clone(), + false, + |_| { + Ok(PullOutcome { + churn: Churn::default(), + name: "F".to_string(), + version: "1".to_string(), + }) + }, + )); // A full figma.com URL for the same key resolves to the existing - // session rather than creating a second one (dedupe by parsed key). - let session = mgr - .resolve(Some( - "https://www.figma.com/design/flAtUnMfzvA5daBSTFQK35/whatever", - )) + // session rather than creating a second one (dedupe by parsed + // key), and pulls it exactly once since it wasn't mirrored yet. + let (session, outcome) = mgr + .resolve( + Some("https://www.figma.com/design/flAtUnMfzvA5daBSTFQK35/whatever"), + Duration::from_secs(10), + ) .unwrap(); assert_eq!(session.key, "flAtUnMfzvA5daBSTFQK35"); + assert!(session.mirrored); + assert!(outcome.is_some()); assert_eq!(mgr.sessions.len(), 1); - assert_eq!(*calls.borrow(), 0); + assert_eq!(*calls.borrow(), 1); } #[test] fn resolve_rejects_garbage_file_ref() { let mut mgr = empty_manager(); - let err = mgr.resolve(Some("not a key!")).map(|_| ()).unwrap_err(); - assert!(err.contains("not a Figma file key or URL"), "{err}"); + let err = mgr + .resolve(Some("not a key!"), Duration::from_secs(10)) + .map(|_| ()) + .unwrap_err(); + assert!( + err.message.contains("not a Figma file key or URL"), + "{}", + err.message + ); + assert!(err.retry_after.is_none()); } #[test] @@ -476,19 +688,109 @@ mod tests { } #[test] - fn list_marks_only_the_first_session_default_and_is_ordered() { + fn list_marks_default_via_effective_default_key_not_vec_order() { let mut mgr = empty_manager(); let calls = Rc::new(RefCell::new(0)); mgr.sessions .push(scripted_session("keyA1234567890", calls.clone())); mgr.sessions .push(scripted_session("keyB1234567890", calls.clone())); + mgr.default_key = Some("keyB1234567890".to_string()); let list = mgr.list(); let rows = list.as_array().unwrap(); assert_eq!(rows.len(), 2); assert_eq!(rows[0]["key"], json!("keyA1234567890")); - assert_eq!(rows[0]["default"], json!(true)); + assert_eq!(rows[0]["default"], json!(false)); assert_eq!(rows[1]["key"], json!("keyB1234567890")); - assert_eq!(rows[1]["default"], json!(false)); + assert_eq!(rows[1]["default"], json!(true)); + } + + #[test] + fn list_marks_no_default_when_two_mirrors_and_no_startup_default() { + let mut mgr = empty_manager(); + let calls = Rc::new(RefCell::new(0)); + mgr.sessions + .push(scripted_session("keyA1234567890", calls.clone())); + mgr.sessions + .push(scripted_session("keyB1234567890", calls.clone())); + let list = mgr.list(); + let rows = list.as_array().unwrap(); + assert!(rows.iter().all(|r| r["default"] == json!(false))); + } + + #[test] + fn resolve_retries_the_pull_for_a_session_whose_first_pull_failed() { + // I3 regression: `open()` pushes the session before any pull can + // succeed or fail — a failed auto-open must not poison the key + // into permanent empty results. `mirrored` starts `false` (as a + // real failed-open session would), and the scripted pull fails on + // the first call, succeeds on the second. + let mut mgr = empty_manager(); + let calls = Rc::new(RefCell::new(0)); + mgr.sessions.push(scripted_session_with( + "keyA1234567890", + calls.clone(), + false, + |call_index| { + if call_index == 0 { + Err(PullError::from("network down".to_string())) + } else { + Ok(PullOutcome { + churn: Churn::default(), + name: "Recovered".to_string(), + version: "2".to_string(), + }) + } + }, + )); + + let err = mgr + .resolve(Some("keyA1234567890"), Duration::from_secs(10)) + .map(|_| ()) + .unwrap_err(); + assert!(!err.message.is_empty()); + assert!(err.retry_after.is_some()); + assert_eq!( + mgr.sessions.len(), + 1, + "the poisoned session is kept, not evicted" + ); + assert!(!mgr.sessions[0].mirrored); + + // A second resolve for the same key retries the pull (not treated + // as already-mirrored) and this time succeeds. + let (session, outcome) = mgr + .resolve(Some("keyA1234567890"), Duration::from_secs(10)) + .unwrap(); + assert!(session.mirrored); + assert_eq!(outcome.unwrap().name, "Recovered"); + assert_eq!(*calls.borrow(), 2); + } + + #[test] + fn typed_pull_error_survives_the_closure_boundary_for_rate_limit_backoff() { + // C2 regression: a `PullError::Api(RateLimited { .. })` returned + // from the boxed `pull` closure must still be *typed* by the time + // `do_pull` sees it — not degraded to a plain string — so + // `pull_failure_wait` can honor `Retry-After` instead of falling + // back to plain exponential backoff. + let mut backoff = BACKOFF_START; + let mut session = + scripted_session_with("keyA1234567890", Rc::new(RefCell::new(0)), false, |_| { + Err(PullError::Api(ApiError::RateLimited { + retry_after: Duration::from_secs(90), + })) + }); + session.backoff = backoff; + + let (message, wait) = do_pull(&mut session, Duration::from_secs(10)).unwrap_err(); + assert!(message.contains("retry after"), "{message}"); + // `pull_failure_wait` honors Retry-After for a rate limit + // regardless of the configured interval, and does NOT touch the + // exponential-backoff budget — proving the typed variant, not a + // stringly-typed fallback, reached `pull_failure_wait`. + assert_eq!(wait, Duration::from_secs(90)); + assert_eq!(session.backoff, BACKOFF_START); + let _ = &mut backoff; } } From bf9fc05763decfe2bdbf333348a48d700809fda9 Mon Sep 17 00:00:00 2001 From: hhff Date: Sun, 16 Aug 2026 23:22:08 -0700 Subject: [PATCH 56/56] feat(figmog): multi-file serve e2e and docs Co-Authored-By: Claude Fable 5 --- README.md | 4 +- examples/figmog/README.md | 74 +++++- examples/figmog/tests/common/mod.rs | 28 ++ examples/figmog/tests/serve.rs | 380 +++++++++++++++++++++++++++- 4 files changed, 473 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0d73609..79924f8 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ In this directory you'll find a few examples that show bog style databases in va - `chat` — a chat backend where fold is the source of truth and every update is broadcast to clients over a websocket. `cargo run -p chat`, then open http://localhost:3000 - `search` — text search three ways over one document stream: BM25 keyword search, HNSW semantic search over ese embeddings, and hybrid rank fusion. A good base for agent memory or document search projects. `cargo run -p search` - `figmog` — a local mirror of a Figma file: sync once, then search, walk, and - query components/styles/variables with zero API calls, and an MCP server - (`figmog serve`), with a built-in load-test demo (`figmog bench`). + query components/styles/variables with zero API calls, and a multi-file MCP + server (`figmog serve`), with a built-in load-test demo (`figmog bench`). `cargo run -p figmog -- --help` ## More about Bog diff --git a/examples/figmog/README.md b/examples/figmog/README.md index 64af071..3ec0d3a 100644 --- a/examples/figmog/README.md +++ b/examples/figmog/README.md @@ -80,11 +80,12 @@ is one process — fjall is single-writer, so a standalone MCP server would fight `figmog watch` for the store lock — that owns the mirror, polls for changes exactly like `watch`, and (unless `--no-upstream`) also attaches Figma's native desktop MCP server as a **cached proxy**: `tools/list` -merges figmog's 17 local `figmog_*` tools with every tool the desktop +merges figmog's 19 local `figmog_*` tools with every tool the desktop server advertises, verbatim, so an agent gets one server, one connection, and the full native tool surface (`get_design_context`, `get_screenshot`, `get_variable_defs`, code-generation tools, …) without figmog reimplementing -any of it. +any of it. `figmog serve` also mirrors more than one file in one process — +see "Multiple files" below. ```console $ cargo build -p figmog @@ -112,6 +113,59 @@ first. (`figmog tools` never opens the store, so it works fine even while a second `figmog serve` or `figmog watch` against a store one of them already owns fails with the same clean message rather than a raw panic. +### Multiple files + +`figmog serve [FILE]...` takes zero or more Figma file URLs/keys at +startup — one process can mirror several files. Every local `figmog_*` +tool gains an optional `file` argument (a URL or bare key, parsed the same +way as the CLI's own file arguments): pass it to route the call at a +specific mirror, auto-opening it (spending one Tier-1 pull) the first time +an agent references it; omit it and the call answers from the *default* +file — the first `FILE` given at startup, or whichever got mirrored first +if none were. + +```console +$ claude mcp add figmog -- /absolute/path/to/clog/target/debug/figmog serve +``` + +Zero files at startup is valid and needs no `FIGMA_TOKEN` up front — the +server starts empty and mirrors files as an agent references them by URL. +This is the shape to reach for with `claude mcp add` when you don't want +to commit to one file ahead of time; passing one or more files at startup +(as in the single-file examples above) still works exactly as before, and +the first one becomes the default so every existing single-file tool call +still needs no `file` argument at all. + +Two tools manage the mirror set directly: + +- `figmog_open {file}` — mirror a file now (spends one Tier-1 pull); + returns its churn and node count. Creates the mirror if it's new, or + re-syncs it if already mirrored. +- `figmog_files` — list every mirrored file: key, name, version, node + count, last synced time, and which one (if any) is the default. + +**Proxied tools caveat (spec §14, verbatim):** "the desktop server +operates on the file open in the Figma app; the `file` argument does not +route proxied tools." A `file` argument sent on a non-`figmog_*` call is +simply ignored — the desktop server has no concept of "which file", so +`get_code`/`get_design_context`/etc. always answer for whatever file is +open in the Figma app, independent of any mirror `figmog serve` manages. + +**Accepted divergence:** `.figmog/current` — the file `pull`/`watch`/plain +`figmog serve ` remember so later CLI commands can drop the file +argument — is only refreshed by a startup pull that actually *ran*. A +startup file whose store is already populated (including every +`--no-watch` invocation, which never pulls at startup at all) leaves +`.figmog/current` untouched; only a genuine network pull — the initial +watch-mode pull against an empty store, or a later watch-tick pull — +writes it. + +CLI commands (`pull`, `watch`, `status`, and the rest) are unchanged and +still address exactly one file via `--db`/`.figmog/current` — multi-file +addressing is a `serve` capability only (spec §14 non-goal: no CLI +multi-file addressing, no cross-file queries, no idle-session eviction — +a session opened stays open for the process's life). + ### The cached proxy Proxying targets **paid Dev/Full seats**: it requires the Figma desktop @@ -124,8 +178,8 @@ process — no mid-session re-probe, so restart `figmog serve` once the desktop server is reachable to attach it. - `--upstream ` overrides the desktop server's URL. -- `--no-upstream` disables proxying entirely — figmog serves its 17 - `figmog_*` tools only, exactly like v2. +- `--no-upstream` disables proxying entirely — figmog serves its 19 + `figmog_*` tools only, exactly like v2 (plus v4's multi-file surface). - **Namespace rule:** `figmog_*` tools are always local; every other tool name is always proxied. If the desktop server ever advertised a tool named `figmog_*`, figmog would drop it and log a warning rather than @@ -177,7 +231,9 @@ first`. ### Core read tools Each mirrors a CLI read command one-to-one and answers instantly from the -local store — zero Figma API cost, zero rate-limit exposure. +local store — zero Figma API cost, zero rate-limit exposure. Every tool +below also takes an optional `file` argument (URL or key) routing the call +at a specific mirror — see "Multiple files" above. | tool | input | reads | |---|---|---| @@ -199,7 +255,8 @@ local store — zero Figma API cost, zero rate-limit exposure. The local mirror's unfair advantage: full-file answers no rate-limited API surface could offer, each a read-only scan/join over the same indexes. Every one has a matching CLI subcommand, so the CLI/tool surface stays -one-to-one. +one-to-one, and (like the core read tools above) each also takes an +optional `file` argument. | tool | CLI equivalent | input | answer | |---|---|---|---| @@ -220,7 +277,8 @@ figmog for everything: > plus a cached proxy to Figma's native capabilities. Call figmog for > everything Figma-related. figmog_* tools answer from the local mirror > at zero API cost; native-named tools (get_*, …) go to Figma, cached by -> file version where possible. +> file version where possible. Pass the Figma file URL as the `file` +> argument when you have one; figmog mirrors files on first reference. Every figmog-native tool lives in the `figmog_*` namespace, so it never collides by name with a proxied tool; local tools only ever read the @@ -229,7 +287,7 @@ that spends Figma's Tier-1 rate budget (a forced pull) — every other local tool call is instant, free, and backed by the same fold-materialized indexes the CLI reads. Proxied tools go through the cache described above. `--no-upstream` recovers the older, "second, -separate server" shape (v2) if that's ever preferable — figmog's 17 +separate server" shape (v2) if that's ever preferable — figmog's 19 `figmog_*` tools alongside Figma's own, unrelated MCP connection. ## Variables diff --git a/examples/figmog/tests/common/mod.rs b/examples/figmog/tests/common/mod.rs index 22635a5..1f65763 100644 --- a/examples/figmog/tests/common/mod.rs +++ b/examples/figmog/tests/common/mod.rs @@ -99,6 +99,34 @@ pub fn fixture_v2() -> Value { v } +/// A second, small, distinct fixture — used by the multi-file `serve` e2e +/// (`tests/serve.rs`) to prove `file`-argument routing actually reaches a +/// *different* mirror rather than always answering from the first one. +/// Deliberately tiny (3 nodes) and textually disjoint from [`fixture_v1`]: +/// its one TEXT node's `characters` contains "zephyr", a word that appears +/// nowhere in `fixture_v1`, so a search hit for it proves routing. +#[allow(dead_code)] // not every test binary that includes this module calls it +pub fn fixture_other() -> Value { + json!({ + "name": "OtherFixture", + "version": "1", + "lastModified": "2026-08-03T00:00:00Z", + "document": { + "id": "0:0", "name": "Document", "type": "DOCUMENT", + "children": [ + { "id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [ + { "id": "1:1", "name": "Banner", "type": "TEXT", + "characters": "Feel the zephyr breeze", + "children": [] } + ] } + ] + }, + "components": {}, + "componentSets": {}, + "styles": {} + }) +} + /// Materialize [`fixture_v1`] into a DB via `pull --from-file` and return the /// (tempdir, db-path) pair every read command — CLI or `serve` — needs. /// Shared so `tests/cli.rs` and `tests/serve.rs` build the same fixture the diff --git a/examples/figmog/tests/serve.rs b/examples/figmog/tests/serve.rs index 0890f90..1c0abd3 100644 --- a/examples/figmog/tests/serve.rs +++ b/examples/figmog/tests/serve.rs @@ -51,10 +51,40 @@ fn spawn_serve_with_args( extra_args: &[&str], ) -> (ChildGuard, ChildStdin, Receiver) { let bin = assert_cmd::cargo::cargo_bin("figmog"); - let mut child = Command::new(bin) - .args(["serve", "--no-watch", "--db"]) + let mut cmd = Command::new(bin); + cmd.args(["serve", "--no-watch", "--db"]) .arg(db) - .args(extra_args) + .args(extra_args); + spawn_child(cmd) +} + +/// Spawn `figmog serve --no-watch --no-upstream --figmog-root +/// ` (spec §14's multi-file surface, with the hidden +/// `--figmog-root` testability flag pointed at a pre-built fixture root), +/// with `FIGMA_TOKEN` scrubbed from the child's environment — every +/// multi-file e2e that touches `figmog_open` needs the missing-token +/// isError, not whatever real token the test runner's own shell happens to +/// export. +fn spawn_serve_multifile( + root: &std::path::Path, + files: &[&str], +) -> (ChildGuard, ChildStdin, Receiver) { + let bin = assert_cmd::cargo::cargo_bin("figmog"); + let mut cmd = Command::new(bin); + cmd.args(["serve", "--no-watch", "--no-upstream", "--figmog-root"]) + .arg(root) + .args(files) + .env_remove("FIGMA_TOKEN"); + spawn_child(cmd) +} + +/// Shared plumbing behind every `spawn_serve*` helper: pipe stdio, spawn, +/// drain stderr for debugging visibility (never asserted on), and feed +/// stdout lines into a channel — driving the child through a channel +/// (rather than reading its stdout inline) means a hung child blocks only +/// the bounded `recv_timeout` in [`recv`], never the test thread itself. +fn spawn_child(mut cmd: Command) -> (ChildGuard, ChildStdin, Receiver) { + let mut child = cmd .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -85,6 +115,38 @@ fn spawn_serve_with_args( (ChildGuard(child), stdin, rx) } +/// Pre-build one fixture store per `(key, fixture)` pair under a fresh temp +/// `--figmog-root` layout — `//db`, exactly the path +/// [`sessions::open_session`](../src/sessions.rs) derives (spec §14) — each +/// via `figmog pull --from-file`, so the multi-file `serve` e2e can start +/// against pre-populated mirrors without ever touching the network. Returns +/// the tempdir; the caller must keep it alive for the duration of the test. +fn build_fixture_root(entries: &[(&str, Value)]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for (key, fixture) in entries { + let resp = dir.path().join(format!("{key}.json")); + std::fs::write(&resp, serde_json::to_string(fixture).unwrap()).unwrap(); + let db = dir.path().join(key).join("db"); + assert_cmd::Command::cargo_bin("figmog") + .unwrap() + .args(["pull", "--from-file"]) + .arg(&resp) + .arg("--db") + .arg(&db) + .assert() + .success(); + } + dir +} + +/// Bare file keys (spec §14: 10+ alphanumeric chars — see `ident::parse_file_ref`), +/// deliberately readable rather than realistic, for the multi-file e2e +/// tests below. `KEY_A` mirrors [`common::fixture_v1`] and is always the +/// first startup file (so the default-routing rule picks it); `KEY_B` +/// mirrors [`common::fixture_other`]. +const KEY_A: &str = "figmogkeyoneaaaa1111"; +const KEY_B: &str = "figmogkeytwobbbb2222"; + /// Write one JSON-RPC frame, newline-delimited (the protocol this crate's /// `mcp`/`serve` modules speak). fn send(stdin: &mut ChildStdin, msg: &Value) { @@ -529,3 +591,315 @@ fn serve_e2e_proxied_tool_lists_round_trips_and_second_call_is_cache_served() { .join() .expect("fake upstream server thread should finish after exactly 4 requests"); } + +// ---- multi-file serve e2e (spec §14) ---- +// +// Two pre-built stores under a temp `--figmog-root` (built via `pull +// --from-file --db //db`, never touching the network), started +// with BOTH keys as positional args, proves the whole v4 surface: 19 tools, +// every local tool's optional `file` schema property, `figmog_files`, +// `file`-argument routing to a *specific* mirror, default-file routing on +// an omitted `file`, and `figmog_open`'s isError on a missing token. A +// second spawn with zero startup files covers the omitted-`file`-with-no- +// default error text a single default file can never trigger. + +#[test] +fn serve_e2e_multi_file_routes_by_file_arg_and_first_startup_key_is_default() { + let root = build_fixture_root(&[ + (KEY_A, common::fixture_v1()), + (KEY_B, common::fixture_other()), + ]); + let (mut guard, mut stdin, rx) = spawn_serve_multifile(root.path(), &[KEY_A, KEY_B]); + + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }), + ); + let resp = recv(&rx); + assert_eq!(resp["id"], json!(1)); + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + ); + + // -- tools/list: 19 tools; every tool but figmog_open/figmog_files + // carries an *optional* `file` property, figmog_open's `file` is + // required, figmog_files takes none (spec §14). -- + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), + ); + let resp = recv(&rx); + let tools = resp["result"]["tools"].as_array().expect("tools array"); + assert_eq!(tools.len(), 19, "tools: {tools:#?}"); + for tool in tools { + let name = tool["name"].as_str().expect("tool name"); + let schema = &tool["inputSchema"]; + let required: Vec = schema["required"].as_array().cloned().unwrap_or_default(); + match name { + "figmog_open" => { + assert!( + schema["properties"]["file"].is_object(), + "figmog_open should take a file property" + ); + assert!( + required.contains(&json!("file")), + "figmog_open's file should be required" + ); + } + "figmog_files" => { + assert!( + schema["properties"].get("file").is_none(), + "figmog_files should not take a file argument" + ); + } + _ => { + assert!( + schema["properties"]["file"].is_object(), + "{name} is missing the optional file routing property" + ); + assert!( + !required.contains(&json!("file")), + "{name}'s file property must be optional" + ); + } + } + } + + // -- figmog_files: both mirrors, in open order, KEY_A (first startup + // FILE) is the default. -- + let resp = call(&mut stdin, &rx, 3, "figmog_files", json!({})); + assert_eq!(resp["result"]["isError"], json!(false)); + let rows = result_json(&resp); + let rows = rows.as_array().expect("files array"); + assert_eq!(rows.len(), 2, "files: {rows:#?}"); + assert_eq!(rows[0]["key"], json!(KEY_A)); + assert_eq!(rows[0]["name"], json!("Fixture")); + assert_eq!(rows[0]["default"], json!(true)); + assert_eq!(rows[1]["key"], json!(KEY_B)); + assert_eq!(rows[1]["name"], json!("OtherFixture")); + assert_eq!(rows[1]["default"], json!(false)); + + // -- figmog_search {query: "zephyr", file: KEY_B} hits: "zephyr" only + // appears in fixture_other's one TEXT node, so a hit here proves the + // `file` argument actually reached the *other* mirror. -- + let resp = call( + &mut stdin, + &rx, + 4, + "figmog_search", + json!({"query": "zephyr", "file": KEY_B}), + ); + assert_eq!(resp["result"]["isError"], json!(false)); + let hits = result_json(&resp); + let hits = hits.as_array().expect("hits array"); + assert!(!hits.is_empty(), "expected a 'zephyr' hit in {KEY_B}"); + assert_eq!(hits[0]["id"], json!("1:1")); + + // -- same query with `file` omitted routes to the default (KEY_A / + // fixture_v1, which never mentions "zephyr" anywhere) and misses — + // proving omission really does route to the default session rather + // than reusing whichever mirror answered the previous call. -- + let resp = call( + &mut stdin, + &rx, + 5, + "figmog_search", + json!({"query": "zephyr"}), + ); + assert_eq!(resp["result"]["isError"], json!(false)); + let hits = result_json(&resp); + assert!( + hits.as_array().expect("hits array").is_empty(), + "default file should have no 'zephyr' hits: {hits:?}" + ); + + // -- figmog_status {file: KEY_B}: the *other* file's own name. -- + let resp = call(&mut stdin, &rx, 6, "figmog_status", json!({"file": KEY_B})); + assert_eq!(resp["result"]["isError"], json!(false)); + assert_eq!(result_json(&resp)["name"], json!("OtherFixture")); + + // -- figmog_open {file: "garbagekey1234567890"}: a brand-new key + // auto-opens (no pull yet — see sessions::SessionManager::open), then + // figmog_open's own pull fails cleanly because FIGMA_TOKEN is scrubbed + // from this child's environment (spawn_serve_multifile). -- + let resp = call( + &mut stdin, + &rx, + 7, + "figmog_open", + json!({"file": "garbagekey1234567890"}), + ); + assert_eq!(resp["result"]["isError"], json!(true)); + + drop(stdin); + let status = wait_with_timeout(&mut guard.0, TIMEOUT); + assert!(status.success(), "figmog serve exited with {status:?}"); +} + +/// Zero startup files (spec §14: valid, token-free, idle startup) — the +/// only shape in which the omitted-`file`-with-no-default error is +/// triggerable at all (a single startup file, or an established default, +/// always resolves the omitted case; two auto-opened mirrors are covered +/// by `sessions.rs`'s own unit tests). Proves the error names both new +/// tools, per spec §14's resolution rule. +#[test] +fn serve_e2e_multi_file_zero_startup_omitted_file_errors_naming_figmog_open() { + let root = tempfile::tempdir().unwrap(); + let (mut guard, mut stdin, rx) = spawn_serve_multifile(root.path(), &[]); + + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }), + ); + let resp = recv(&rx); + assert_eq!(resp["id"], json!(1)); + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + ); + + // figmog_files: nothing mirrored yet. + let resp = call(&mut stdin, &rx, 2, "figmog_files", json!({})); + assert_eq!(resp["result"]["isError"], json!(false)); + assert_eq!(result_json(&resp), json!([])); + + // A tool call with `file` omitted and no default mirrored file: + // isError naming figmog_open/figmog_files, not a silent empty answer. + let resp = call(&mut stdin, &rx, 3, "figmog_status", json!({})); + assert_eq!(resp["result"]["isError"], json!(true)); + let text = resp["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!( + text.contains("figmog_open"), + "error should name figmog_open: {text}" + ); + assert!( + text.contains("figmog_files"), + "error should name figmog_files: {text}" + ); + + drop(stdin); + let status = wait_with_timeout(&mut guard.0, TIMEOUT); + assert!(status.success(), "figmog serve exited with {status:?}"); +} + +/// `--db ` with no FILE positional predates multi-file serve (spec +/// §14 non-goal: CLI multi-file addressing is out of scope) — the single +/// session it opens has no real Figma key to pull with (see +/// `sessions::open_session_at`'s `network_key: None` case). Any tool that +/// forces a pull must fail with the same clean pre-v4 message, never panic +/// or attempt a network call against a filesystem-path-shaped "key". +#[test] +fn serve_e2e_db_override_with_no_file_figmog_sync_errors_no_file_key() { + let (_dir, db) = common::fixture_db(); + let (mut guard, mut stdin, rx) = spawn_serve(&db); + + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }), + ); + let resp = recv(&rx); + assert_eq!(resp["id"], json!(1)); + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + ); + + let resp = call(&mut stdin, &rx, 2, "figmog_sync", json!({})); + assert_eq!(resp["result"]["isError"], json!(true)); + let text = resp["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!( + text.contains("no file key"), + "expected the no-file-key message, got: {text}" + ); + assert!( + !text.to_lowercase().contains("panic"), + "must not panic: {text}" + ); + + drop(stdin); + let status = wait_with_timeout(&mut guard.0, TIMEOUT); + assert!(status.success(), "figmog serve exited with {status:?}"); +} + +/// Documents an accepted divergence (see this crate's README, "Multiple +/// files" section, and `serve.rs::build_sessions`'s doc comment): +/// `.figmog/current` is only refreshed by a startup pull that actually +/// *ran* (`!no_watch && !session.mirrored`). `--no-watch` never pulls at +/// startup — not even against a pre-built store under the default +/// `.figmog` root — so `figmog serve --no-watch` (no `--db`) must +/// NOT write `.figmog/current`, even though pre-v4 `figmog serve ` +/// (which always watched) did. A real pull that writes it (an initial +/// watch-mode pull against an empty store, or a later watch-tick pull) +/// needs a live network + token and is deliberately not exercised here — +/// this test only pins the `--no-watch` half, which is fully offline. +#[test] +fn serve_e2e_no_watch_default_root_startup_does_not_write_figmog_current() { + let cwd = tempfile::tempdir().unwrap(); + let response = cwd.path().join("resp.json"); + std::fs::write( + &response, + serde_json::to_string(&common::fixture_v1()).unwrap(), + ) + .unwrap(); + let db = cwd.path().join(".figmog").join(KEY_A).join("db"); + assert_cmd::Command::cargo_bin("figmog") + .unwrap() + .args(["pull", "--from-file"]) + .arg(&response) + .arg("--db") + .arg(&db) + .assert() + .success(); + + let bin = assert_cmd::cargo::cargo_bin("figmog"); + let mut cmd = Command::new(bin); + cmd.args(["serve", "--no-watch", "--no-upstream", KEY_A]) + .current_dir(cwd.path()) + .env_remove("FIGMA_TOKEN"); + let (mut guard, mut stdin, rx) = spawn_child(cmd); + + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}, + }), + ); + let resp = recv(&rx); + assert_eq!(resp["id"], json!(1)); + send( + &mut stdin, + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + ); + + drop(stdin); + let status = wait_with_timeout(&mut guard.0, TIMEOUT); + assert!(status.success(), "figmog serve exited with {status:?}"); + + assert!( + !cwd.path().join(".figmog").join("current").exists(), + "--no-watch startup against a pre-built store must not write .figmog/current" + ); +}