diff --git a/.changepacks/changepack_log_honest_verdicts_and_current_binary.json b/.changepacks/changepack_log_honest_verdicts_and_current_binary.json new file mode 100644 index 00000000..b290bc48 --- /dev/null +++ b/.changepacks/changepack_log_honest_verdicts_and_current_binary.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "Four places said work had been done that had not been, and one place made a caller chase a failure that could not be retried. `devup_ui_validate` reported `file-placement` against Next.js files whose default export the framework requires - `app/layout.tsx` without one is a build error, so the advice could not be taken, and because the finding is a warning it failed `strict: true` on a correct application; `not-found.tsx` collected two findings for the same reason. The page-name rule judged every component in a route file rather than the one the route renders, so a correct `export default function NoticePage` was reported for its own `PageContent` helper, and `one-component-per-file` then asked for that helper to be moved somewhere the file-placement rule forbids - leaving `src/components/` as the only destination for a component used by one route. At a monorepo root `theme_for_validation` took the first `devup.json` the sorted scan found, so `apps/front` code was validated against `apps/admin`'s theme and its real tokens came back as `unknown-token` *errors* suggesting the other package's token as the fix; it now refuses to choose and returns `candidateProjectRoots` naming the `projectRoot` that selects each. A call with neither `tsx` nor `files` validated the empty string and answered `ok: true, okReason: \"clean\"` about code never sent, and is now refused; `clean` itself is split so `clean-unverified-tokens` says a `$token` reference existed but no theme was resolved to check it against. Tokens inside `css()`/`globalCss()`/`keyframes()` were invisible to the token check, so an unknown one there passed in silence. On the Figma side a Section whose candidate menu exceeded the plugin transport was refused outright, returning nothing for a menu that was merely long and reproducing the same refusal on every retry because the ceiling follows the Section's size; it now surrenders text previews first, then shortens from the end under the same `projectionTruncated` the traversal and candidate caps already raise, and `allScreens` still refuses a truncated index rather than pretending to be whole. Of the twenty-five codes the plugin scripts throw only two carry a JSON payload, so the rest arrived as `pluginCode: null` with generic advice; the code is now read from the message for all of them, and the two that name a caller-fixable condition are mapped - a missing node or page reached callers as `-32603 INTERNAL_ERROR` when the README promises the JSON-RPC code alone separates \"fix the arguments\" from \"stop and report\", which is the most common mistake there is. The placement contract now carries the collected parent's fill, because learning that the desktop frame behind an app screen was `#E6EDE6` otherwise took a second Figma acquisition for a colour the capture already held. Finally, a new `self_update` module keeps the binary current without anyone tending it: the existing release check now fetches the published asset for the platform, proves it by running `--self-check` on it, and renames it into place - never touching the running image, so the stdio pipe the host already holds is never at risk, and the next start is the one that differs. It stands down where it is not the owner of its install, and reports which mechanism is in force under `server.updateAvailable.autoUpdate`.", + "date": "2026-09-21T21:40:00+09:00" +} diff --git a/README.md b/README.md index cfee845b..7ef74d84 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,16 @@ devup-mcp는 Figma Plugin API의 readable data property를 raw JSON으로 보존 에러는 호출 자체가 잘못된 경우(`DEVUP_INVALID_INPUT`, 없는 node/파일, 만료·부적합한 `artifactId` 등) JSON-RPC `-32602 INVALID_PARAMS`로, 그 밖의 실패는 `-32603 INTERNAL_ERROR`로 옵니다. 인자를 고쳐 다시 부를 일인지 멈추고 보고할 일인지를 메시지를 파싱하지 않고 구분할 수 있습니다. 정확한 `code`와 `retryable`은 예전처럼 `data`에 그대로 실립니다. +**이 구분은 플러그인이 던진 오류에도 적용됩니다.** 스크립트가 던지는 코드는 25종인데 그중 JSON payload를 함께 싣는 것은 둘뿐이라, 나머지는 `pluginCode: null`에 일반 안내만 달려 도착했습니다. 지금은 **메시지에서 코드를 읽어** `pluginCode`에 채우고, 호출자가 고칠 수 있는 것만 그에 맞는 `code`로 올립니다. + +| 플러그인 코드 | `code` | JSON-RPC | +|---|---|---| +| `DEVUP_NODE_NOT_FOUND` · `DEVUP_PAGE_NOT_FOUND` | `DEVUP_FIGMA_NODE_NOT_FOUND` | **-32602** | +| `DEVUP_ENVELOPE_TOO_LARGE` · `DEVUP_EXPLORE_PROJECTION_TOO_LARGE` | `DEVUP_FIGMA_RESPONSE_TOO_LARGE` | -32603 | +| 그 밖(수집 내부 조건) | `DEVUP_SNAPSHOT_UNSUPPORTED` | -32603 | + +없는 node를 가리키는 링크는 **가장 흔한 실수**인데 예전에는 `-32603 INTERNAL_ERROR`로 와서, 위 문단이 약속한 구분이 바로 그 지점에서 깨졌습니다. 내부 수집 조건까지 호출자 실수로 올리지는 않습니다 — 고칠 것이 없는 사람에게 인자를 고치라고 보내는 셈이기 때문입니다. + devup-mcp는 Figma Remote MCP에 직접 붙습니다 — OAuth discovery, Dynamic Client Registration, PKCE S256, 일시적인 `127.0.0.1` callback을 구현합니다. Figma는 MCP Catalog에 승인된 client의 registration만 허용하므로 등록은 allowlist에 있는 `client_name`으로 이루어집니다(기본값 `Codex`). Figma PAT나 사용자가 만든 OAuth app은 필요하지 않습니다. ## 빌드와 설치 @@ -102,6 +112,35 @@ credential backend 초기화와 server 구성을 안전한 JSON으로 확인합 stdio pipe를 보유한 상태이므로 host의 MCP 연결을 재시작하거나 다시 등록해야 합니다. 새로 실행된 server가 host가 보유한 이전 pipe를 스스로 복구할 수는 없습니다. +### 최신 유지 — 다음 시작 때 바뀝니다 + +**직접 올려 두지 않아도 최신 릴리스로 수렴합니다.** 릴리스 확인(24시간 주기, 첫 확인은 `initialize` 20초 뒤)이 새 버전을 찾으면 이 플랫폼의 에셋을 받아 `devup-mcp.exe.staged`로 두고, 그 파일이 **실제로 실행돼 `--self-check`에 답해야만** 그 자리에서 실행 파일의 이름을 넘겨받습니다. + +**적용은 다음 실행부터입니다. 재시작 한 번이면 됩니다.** + +| 시점 | 일어나는 일 | 그 세션이 도는 build | +|---|---|---| +| 세션 N | 감지 → 다운로드 → 검증 → **교체** | 이전 build (이미 메모리에 로드됨) | +| 세션 N+1 | — | **새 build** | + +**실행 중인 프로세스는 절대 교체하지 않습니다.** 위 문단이 말한 그대로, 이미 host가 쥔 stdio pipe는 교체된 binary가 복구할 수 없기 때문입니다. 바꾸는 것은 **디스크의 파일**뿐이고, 이 프로세스가 실행 중인 image는 spawn 시점에 로드된 것이라 rename이 닿지 않습니다. 그래서 검증 직후 바로 교체해도 안전하며, 교체가 일어난 세션은 stderr에 그 사실을 적고 계속 이전 build로 돕니다. + +기동 시점의 승격은 이제 **복구 경로**입니다 — 검증까지 끝낸 파일을 두고 프로세스가 죽은 경우에만 쓰입니다. + +| 상태 | 뜻 | +|---|---| +| `owned` | 이 프로세스가 설치 위치의 주인이라 스테이징합니다 | +| `package-managed` | binary가 패키지 매니저 디렉터리(`_npx`, `node_modules`, `.cargo` 등) 안에 있습니다. 그쪽이 자기 주기로 갱신하므로 건드리지 않습니다 | +| `not-writable` | 설치 디렉터리에 쓸 수 없으므로 설치한 주체의 몫입니다 | +| `unsupported-platform` | 이 플랫폼/아키텍처용으로 발행된 에셋이 없습니다 | +| `disabled` | `DEVUP_MCP_NO_AUTO_UPDATE`로 껐거나, **`DEVUP_MCP_NO_UPDATE_CHECK`로 릴리스 확인 자체를 껐습니다.** 후자면 새 릴리스를 발견하는 일이 없어 스테이징이 할 일이 없으므로, 스테이징을 끄지 않았어도 결과는 꺼진 것과 같습니다. `note`가 둘 중 어느 쪽인지 알려줍니다 | + +`server.updateAvailable.autoUpdate`가 이 상태와 `stagedForNextStart`, 이 플랫폼의 `asset`, 그리고 **교체할 파일의 실제 경로인 `installedPath`**를 함께 보고합니다. 자동이 물러난 경우 사람이 무엇을 고쳐야 하는지가 응답 안에 있습니다. + +검증되지 않은 bytes는 이름을 넘겨받지 못하고 그 자리에서 삭제됩니다 — 매 시작마다 같은 파일을 다시 검사하지 않기 위해서입니다. 승격은 삭제가 아니라 rename이라 직전 binary가 `devup-mcp.exe.previous`로 남고, 되돌리는 일은 rename 한 번입니다. + +**런처로 설치했다면 이 기능은 필요 없습니다.** `npx ... @latest` 같은 런처는 매 실행마다 최신을 해결하므로 그쪽이 더 나은 방법이고, 두 갱신 주체가 한 경로를 두고 다투는 것이 둘 중 하나만 있는 것보다 나쁩니다. 그런 설치는 위 표의 `package-managed`로 스스로 물러나며, 아니라면 `DEVUP_MCP_NO_AUTO_UPDATE=1`로 끄십시오. + 서버 시작 시 OAuth 준비가 실패하면 **프로세스가 죽지 않고 진단 가능한 오류로 나옵니다.** HTTP 클라이언트 초기화가 실패하면 `DEVUP_FIGMA_DIRECT_UNAVAILABLE`로 `Cannot start Figma OAuth: HTTP client initialization failed. Check TLS configuration and system certificate initialization, then restart devup-mcp.`와 함께 원인 체인을 붙여 돌려줍니다. endpoint URL이 잘못됐으면 `DEVUP_INVALID_INPUT`입니다. 예전에는 이 경로가 panic이라 무엇이 잘못됐는지 알 수 없었습니다 — TLS나 시스템 인증서 설정을 먼저 확인하세요. 소스에서 검증하려면 다음을 실행합니다. @@ -554,6 +593,18 @@ SECTION 기본 응답은 화면 아티팩트가 아닌 선택 목록입니다. ` 긴 페이지는 높이·비율 어느 쪽으로도 화면으로 측정되지 않습니다. 그렇다고 그 안을 뜯어 조각들만 후보로 내놓으면 정작 페이지 자체는 목록에 없게 되므로, 페이지는 `explicitCandidates`에 통째로 남기고 그 **안에 있는 자동 화면 후보도 함께** 보존합니다. 둘 중 무엇을 고를지는 호출자가 정합니다. 미리보기는 보이는 텍스트만 모아 최대 120자, 전체 최대 2KB로 제한하므로 비어 있거나 짧아도 실제 화면 내용이 없다는 뜻은 아닙니다. `nextAction.example`에는 첫 후보를 선택하는 `devup_figma_export` 호출 예시가 들어 있습니다. 예시의 `frameIds`를 검토한 후보 ID로 바꿔 호출하면 선택한 화면만 수집합니다. +#### 메뉴가 전송 한계를 넘을 때 + +플러그인 응답은 19 KiB(측정된 상한 20,480바이트 아래)를 넘을 수 없습니다. 후보가 많은 Section은 이 한계를 넘는데, 예전에는 **거절**했습니다 — 호출자는 아무것도 받지 못했고, 한계가 Section 크기의 성질이라 같은 요청을 다시 보내도 결과가 같았습니다. + +지금은 순서대로 양보합니다. + +1. **텍스트 미리보기를 먼저 버립니다.** 미리보기는 힌트일 뿐이라 선택 가능한 화면과 바꿀 값어치가 없습니다(`textPreviewState: "budget-exhausted"`). +2. 그래도 넘으면 **뒤에서부터 후보를 잘라내고 `projectionTruncated`를 세웁니다.** 이미 traversal 상한과 후보 100개 상한이 쓰던 그 플래그입니다. +3. 후보 하나도 들어가지 않으면 그때 거절하며, `discoveredCandidates`와 `retainedCandidates`를 함께 보고합니다. + +**잘렸다고 조용히 넘어가지 않습니다.** 잘린 인덱스에서는 `allScreens`가 거절되고(`DEVUP_FIGMA_RESPONSE_TOO_LARGE`), 목록에 없는 ID는 `not-found-in-truncated-index`로 보고하며 "없다"고 단정하지 않습니다. 짧아도 자기가 짧다고 말하는 메뉴가 빈 응답보다 낫고, 애초에 금지된 것은 잘림이 아니라 **말 없는 잘림**이었습니다. + ### 한 화면의 여러 폭 — 반응형 모듈 Section 안의 frame이 `mobile` / `tablet` / `desktop`처럼 **breakpoint 이름**을 가지면, 그 frame 하나를 요청해도 같은 이름 규칙의 형제 frame이 함께 수집됩니다(Section 자체는 수집 범위 밖이며, 그 이름은 각 frame의 `parentName`으로 전달됩니다). 이때 `tsx`나 `responsiveTsx`를 요청하면 결과에 `responsiveTsx`가 추가됩니다 — 세 폭을 하나의 트리로 접고 폭마다 다른 값을 devup-ui 반응형 배열 `[mobile, sm, tablet, lg, pc]`로 쓴 모듈입니다. 각 폭이 놓이는 slot은 frame **이름이 아니라 폭**으로 정해집니다(`≤480 / ≤768 / ≤992 / ≤1280 / 그 이상`). 컴포넌트 이름은 `componentName`이 우선이고, 없으면 Section 이름의 PascalCase에 `Page`를 붙입니다(`about` → `AboutPage`). @@ -644,7 +695,7 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 | 필드 | 내용 | |---|---| | `ok` | 심각도로 판정. error가 있으면 실패, `strict: true`면 warning도 실패 | -| `okReason` | `clean` · `info-only` · `warnings-only` · `warnings-and-info` · `strict-warnings` · `error-violations` | +| `okReason` | `clean` · `clean-unverified-tokens` · `info-only` · `warnings-only` · `warnings-and-info` · `strict-warnings` · `error-violations` | | `violations[]` | `rule`, `severity`(`info`\|`warning`\|`error`), `byteRange`, `message`, `suggestion` | | `violationCounts` | `error` / `warning` / `info` 개수 | | `themeNotes[]` | 비어 있는 토큰 카테고리를 종류마다 한 번씩만 요약 | @@ -652,7 +703,30 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 | `checkedTokens` / `availableTokenCount` | 위 두 값과 같은 수를 예전 이름으로 유지 | | `themeAvailable` / `themeGuardrail` | 테마를 찾았는지, 못 찾았으면 왜 | -규칙은 `invalid-syntax`, `unknown-token`, `hardcoded-color`, `hardcoded-length`, `unknown-prop`, `runtime-value`입니다. +규칙은 **두 갈래**입니다. 앞의 여섯은 `tsx` 하나만 줘도 돌고, 뒤의 다섯은 `files` 번들로 줄 때만 돕니다 — 파일 경로와 파일 사이의 관계를 봐야 판정할 수 있기 때문입니다. + +| 갈래 | 규칙 | +|---|---| +| 항상 | `invalid-syntax`, `unknown-token`, `hardcoded-color`, `hardcoded-length`, `unknown-prop`, `runtime-value` | +| `files` 번들에서만 | `file-placement`, `one-component-per-file`, `client-boundary`, `inline-style`, `react-query-states` | + +`file-placement`는 **프레임워크가 강제하는 파일을 면제합니다.** `app/layout.tsx`에 default export가 없으면 빌드가 깨지므로, 그걸 지적하는 것은 따를 수 없는 권고이고 warning이라 `strict: true`에서 멀쩡한 Next.js 앱을 탈락시켰습니다. 면제 대상은 App Router의 segment 파일(`page`, `layout`, `loading`, `error`, `global-error`, `not-found`, `template`, `default`)과 metadata·이미지 규약(`sitemap`, `robots`, `manifest`, `icon`, `apple-icon`, `opengraph-image`, `twitter-image`), Pages Router의 `_app`·`_document`, 그리고 `middleware`입니다. **그 이름이 예약된 디렉터리 안에서만** 면제되므로 `src/components/error.tsx`는 여전히 일반 모듈로 판정합니다. + +페이지 이름 규칙은 **라우트가 실제로 렌더하는 컴포넌트**, 즉 default export만 봅니다. 예전에는 파일 안의 모든 컴포넌트를 검사해서, 올바른 `export default function NoticePage`를 가진 파일이 그 안의 `PageContent` 헬퍼 때문에 경고를 받았습니다. `page`가 라우트 이름인 것도 라우터 디렉터리 안에서뿐이라, `src/components/page.tsx`는 라우트로 판정하지 않습니다. + +`one-component-per-file`도 **라우트 segment 파일에서는 적용하지 않습니다.** 그 파일은 컴포넌트 모듈이 아니라 프레임워크 진입점이라 안의 헬퍼를 이름으로 import할 수 있는 곳이 없고, 그래서 "각 컴포넌트를 자기 파일로 옮기라"가 소비자에게 주는 것이 없습니다. 게다가 바로 위 규칙과 충돌했습니다 — `src/app/` 아래 형제 파일은 그 자체로 위반이므로, 한 라우트만 쓰는 헬퍼를 옮길 수 있는 곳이 `src/components/`뿐이었습니다. **일반 컴포넌트 파일은 그대로이고, 비공개 헬퍼도 여전히 잡습니다.** + +**`tsx`도 `files`도 주지 않은 호출은 거절합니다.** 예전에는 빈 문자열을 검증해 `ok: true`, `okReason: "clean"`을 돌려줬는데, 보낸 적도 없는 코드가 통과한 것처럼 읽혔습니다. + +`clean`과 `clean-unverified-tokens`는 다릅니다. 앞은 검사했고 문제가 없었다는 뜻이고, **뒤는 위반이 없었지만 `devup.json`을 찾지 못해 이 코드가 쓴 `$token`을 애초에 검사하지 못했다는 뜻**입니다. + +#### 모노레포에서 어느 `devup.json`을 쓰는가 + +프로젝트 루트에 `devup.json`이 있으면 그것을 씁니다. 없으면 프로젝트 안을 4단계까지 찾는데, **후보가 둘 이상이면 아무것도 고르지 않고 거절합니다.** 루트에 없는 여러 테마 중 무엇도 프로젝트 전체를 지배하지 않으므로 "그" 테마라는 것이 존재하지 않기 때문입니다. + +예전에는 정렬 순서상 첫 번째를 조용히 골랐고, 그 결과 모노레포 루트를 넘기면 `apps/front` 코드를 `apps/admin` 테마로 검증해 **실재하는 토큰을 `unknown-token` error로 단정하고 다른 앱의 토큰을 고치라고 제안**했습니다. + +지금은 `themeAvailable: false`와 함께 `themeGuardrail.candidateProjectRoots`가 후보마다 `devupJson`·`projectRoot`·`authority`·`appliesTo`를 돌려줍니다. **거기 적힌 `projectRoot` 값을 그대로 넘겨 다시 부르면 됩니다.** 후보가 하나뿐이면 예전처럼 그대로 해석하므로 단일 패키지 레이아웃의 동작은 바뀌지 않습니다. 하드코딩 값의 판정 기준은 **일치하는 토큰이 실제로 있는지**입니다. 있으면 `warning`으로 올리고 토큰 이름을 알려줍니다. 없으면 `info`로 낮추고 **권고 없이 사실만** 적으며 strict 모드에서도 실패시키지 않습니다. 그래서 `length` 토큰이 하나도 없는 테마가 "토큰을 쓰라"는 권고를 받는 일은 없고, 대신 `themeNotes`가 "이 테마에는 length 토큰이 없다"고 한 번 알려줍니다. diff --git a/crates/devup-mcp-devup-ui/src/codegen/evidence.rs b/crates/devup-mcp-devup-ui/src/codegen/evidence.rs index 86f950cb..9a342972 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/evidence.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/evidence.rs @@ -34,6 +34,21 @@ pub(super) fn placement_contract( let parent = view .string("parentId") .and_then(|id| snapshot.nodes.get(id)); + // The parent's own paint, when the capture holds it. A screen exported on + // its own showed nothing of the surface it is drawn on, so learning that + // the desktop frame behind an app screen was #E6EDE6 took a second export + // of that parent — a second Figma acquisition to read a colour this + // capture was already carrying. + let parent_background = parent.and_then(|parent| { + let parent = parent.typed_view(); + let color = style::first_solid_color(parent.value("fills"))?; + Some(json!({ + "color": color, + "layoutMode": parent.string("layoutMode"), + "name": parent.string("name"), + "note": "The surface this root is drawn on in Figma, read from the collected parent. It is reported, not emitted: the parent is not part of this component. Apply it to the host when the screen is meant to sit on it.", + })) + }); let source = output .source_map .entries @@ -45,7 +60,11 @@ pub(super) fn placement_contract( let absolute = tag.contains("pos=\"absolute\""); let positioned = super::layout::holds_positioned_children(snapshot, node); let vertical_fill = super::layout::vertical_fill_container(snapshot, node); - if parent.is_some() && !absolute && !positioned && !vertical_fill { + // An in-flow root inside a collected parent needs no placement contract — + // unless that parent paints the surface underneath it, which nothing else + // in the response would say. + if parent.is_some() && !absolute && !positioned && !vertical_fill && parent_background.is_none() + { return None; } let relative = tag.contains("pos=\"relative\""); @@ -102,6 +121,7 @@ pub(super) fn placement_contract( "sourceSize": {"width":view.number("width"), "height":view.number("height"), "horizontal":view.string("layoutSizingHorizontal"), "vertical":view.string("layoutSizingVertical")}, "sourcePosition": {"x":view.number("x"), "y":view.number("y"), "constraints":view.value("constraints")}, "sourceParentSize": parent.map(|p| json!({"width":p.typed_view().number("width"), "height":p.typed_view().number("height")})), + "sourceParentBackground": parent_background, "generatedRoot": format!("{tag}>"), "hostRequirements":requirements, "classification":"placement-contract", "nextAction": if dependent { "Satisfy hostRequirements or export the containing parent in standalone mode before accepting placement." } else { "Insert in normal flow using the emitted root props. External parent placement is outside this capture." }, diff --git a/crates/devup-mcp-devup-ui/src/ui_structure.rs b/crates/devup-mcp-devup-ui/src/ui_structure.rs index b066c99d..f4daa838 100644 --- a/crates/devup-mcp-devup-ui/src/ui_structure.rs +++ b/crates/devup-mcp-devup-ui/src/ui_structure.rs @@ -465,6 +465,52 @@ fn add_declaration<'a>(components: &mut Vec>, declaration: &'a Dec } } +/// Next.js file conventions whose default export the framework *requires*. +/// +/// The named-export preference below is a project convention; these files are +/// not free to follow it. `app/layout.tsx` without a default export is a build +/// error, so reporting one is advice that cannot be taken — and because the +/// finding is a warning, `strict: true` failed the validation of a correct +/// Next.js app over it. The same applies to every segment and metadata file +/// the framework loads by name, not just the two this rule used to know about. +/// +/// Matched on the stem, so `.js`/`.jsx`/`.ts`/`.tsx` are all covered, and on +/// the router directory too: these names are only reserved where the +/// framework looks for them, so `src/components/error.tsx` is still an +/// ordinary module and still subject to the convention. +const APP_ROUTER_DEFAULT_EXPORT_STEMS: &[&str] = &[ + // Segment files + "page", + "layout", + "loading", + "error", + "global-error", + "not-found", + "template", + "default", + // Metadata and image conventions + "sitemap", + "robots", + "manifest", + "icon", + "apple-icon", + "opengraph-image", + "twitter-image", +]; +const PAGES_ROUTER_DEFAULT_EXPORT_STEMS: &[&str] = &["_app", "_document"]; +const ANYWHERE_DEFAULT_EXPORT_STEMS: &[&str] = &["middleware"]; + +fn file_stem(basename: &str) -> &str { + basename.rsplit_once('.').map_or(basename, |(stem, _)| stem) +} + +fn framework_file(path: &str, stem: &str) -> bool { + let under = |directory: &str| path.split('/').any(|segment| segment == directory); + ANYWHERE_DEFAULT_EXPORT_STEMS.contains(&stem) + || (APP_ROUTER_DEFAULT_EXPORT_STEMS.contains(&stem) && under("app")) + || (PAGES_ROUTER_DEFAULT_EXPORT_STEMS.contains(&stem) && under("pages")) +} + fn structural(file: &SourceFile, bundle_boundary: bool) -> Vec { let allocator = Allocator::default(); let parsed = Parser::new(&allocator, &file.content, source_type(&file.path)).parse(); @@ -476,6 +522,7 @@ fn structural(file: &SourceFile, bundle_boundary: bool) -> Vec { let mut motion = false; let mut components = Vec::new(); let mut defaults = Vec::new(); + let mut default_component: Option = None; for statement in &program.body { match statement { Statement::ImportDeclaration(import) => { @@ -515,7 +562,17 @@ fn structural(file: &SourceFile, bundle_boundary: bool) -> Vec { if let ExportDefaultDeclarationKind::FunctionDeclaration(function) = &export.declaration { + if let Some(id) = &function.id { + default_component = Some(id.name.to_string()); + } add_function(&mut components, function); + } else if let Some(Expression::Identifier(id)) = export.declaration.as_expression() + { + // `export default NoticePage` names the route component + // elsewhere in the file. The page-name rule below judges + // the component the route actually renders, so it has to + // know which one that is in this form too. + default_component = Some(id.name.to_string()); } } Statement::ExportDeclaration(export) => { @@ -540,32 +597,41 @@ fn structural(file: &SourceFile, bundle_boundary: bool) -> Vec { let path = file.path.replace('\\', "/"); let path = path.trim_start_matches("./"); let basename = path.rsplit('/').next().unwrap_or(path); + let stem = file_stem(basename); for span in defaults { - if basename != "page.tsx" { + if !framework_file(path, stem) { out.push(finding( file, span, "file-placement", Severity::Warning, - "Default export outside page.tsx violates the named-export convention.", - "Use a named export; retain framework-required exports when applicable.", + "Default export outside a framework-required file violates the named-export convention.", + "Use a named export. Framework-required files (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt.", )); } } for component in &components { if (path.starts_with("src/app/") || path.contains("/src/app/")) - && !matches!(basename, "page.tsx" | "layout.tsx") + && !framework_file(path, stem) { out.push(finding( file, component.span, "file-placement", Severity::Warning, - "Component is defined under src/app/ outside page.tsx or layout.tsx.", + "Component is defined under src/app/ outside a framework-required segment file.", "Move reusable components to src/components/.", )); } - if basename == "page.tsx" && !component.name.ends_with("Page") { + // Only the component the route renders is the page. A helper defined + // beside it is not one, and judging every component in the file + // reported a correct `export default function NoticePage` route for + // its own inner `PageContent`. + if stem == "page" + && framework_file(path, stem) + && default_component.as_deref() == Some(component.name.as_str()) + && !component.name.ends_with("Page") + { out.push(finding( file, component.span, @@ -601,15 +667,24 @@ fn structural(file: &SourceFile, bundle_boundary: bool) -> Vec { sites.extend(components.iter().map(|c| (c.name.clone(), c.span))); sites.sort_by_key(|(_, span)| span.start); sites.dedup_by_key(|(_, span)| span.start); - for (_, span) in sites.iter().skip(1) { - out.push(finding( - file, - *span, - "one-component-per-file", - Severity::Warning, - "More than one JSX component is defined in this file.", - "Move each component to its own file.", - )); + // A route segment file is a framework entry point, not a component module: + // nothing can import its helper by name, so "move each component to its + // own file" buys a consumer nothing. It also collided with the rule above + // — the only place left to move a single-use route helper was + // `src/components/`, because a sibling file under `src/app/` is itself a + // violation. Ordinary component files are unchanged, private helpers + // included. + if !framework_file(path, stem) { + for (_, span) in sites.iter().skip(1) { + out.push(finding( + file, + *span, + "one-component-per-file", + Severity::Warning, + "More than one JSX component is defined in this file.", + "Move each component to its own file.", + )); + } } let internal_handler = facts .handler_refs diff --git a/crates/devup-mcp-devup-ui/src/ui_validate.rs b/crates/devup-mcp-devup-ui/src/ui_validate.rs index b892be15..1cbe2f53 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate.rs @@ -437,6 +437,47 @@ impl<'t> TsxVisitor<'t> { return; }; self.check_static_object(object, callee.name.as_str()); + self.check_object_tokens(object); + } + + /// `$token` references inside `css()`/`globalCss()`/`keyframes()` object + /// literals. + /// + /// The token check used to read JSX attributes only, so + /// `globalCss({ body: { background: "$bg" } })` reported + /// `checkedTokens: 0`: a token that did not exist was accepted in + /// silence, and the response said `clean` about code referencing a token + /// nobody had verified. Only `$`-prefixed strings are inspected here — + /// the hardcoded-value advice stays on JSX props, so no call gains a new + /// warning severity from this walk. + fn check_object_tokens(&mut self, object: &ObjectExpression) { + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + let key = property_key_name(&property.key).unwrap_or_else(|| "?".to_owned()); + self.check_value_tokens(&key, &property.value); + } + } + + /// Nested objects carry the selector/breakpoint nesting devup-ui allows, + /// and arrays carry responsive values; the key that decides a token's + /// expected category is the innermost one, which is the CSS property. + fn check_value_tokens(&mut self, key: &str, value: &Expression) { + match value { + Expression::StringLiteral(text) if text.value.starts_with('$') => { + self.check_attribute_value(key, text.value.as_str(), text.span); + } + Expression::ObjectExpression(nested) => self.check_object_tokens(nested), + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(expression) = element.as_expression() { + self.check_value_tokens(key, expression); + } + } + } + _ => {} + } } fn check_static_object(&mut self, object: &ObjectExpression, call_name: &str) { diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap index 4f49910a..36fdab8a 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap @@ -33,6 +33,7 @@ expression: json!(output.diagnostics) } }, "sourceParentSize": null, + "sourceParentBackground": null, "generatedRoot": "", "hostRequirements": [], "classification": "placement-contract", diff --git a/crates/devup-mcp-figma/src/scripts/section_index.js b/crates/devup-mcp-figma/src/scripts/section_index.js index 5aecc83b..6432d258 100644 --- a/crates/devup-mcp-figma/src/scripts/section_index.js +++ b/crates/devup-mcp-figma/src/scripts/section_index.js @@ -283,19 +283,37 @@ const result = { diagnostics: [], }; // The measured upstream ceiling is 20,480 UTF-8 bytes. Never hand it JSON -// that it will silently cut; preserve the complete candidate list or refuse. +// that it will *silently* cut; what crosses has to describe itself. let responseBytes = utf8ByteLength(JSON.stringify(result)); -// Previews are optional menu hints. Yield their space before refusing a -// complete menu; never drop a selectable screen merely to fit the transport. +// Previews are optional menu hints. Yield their space first: a hint is never +// worth a selectable screen. for (let index = candidates.length - 1; index >= 0 && responseBytes > 19 * 1024; index -= 1) { candidates[index].fields.textPreview = ""; candidates[index].fields.textPreviewState = "budget-exhausted"; responseBytes = utf8ByteLength(JSON.stringify(result)); } +// Still over. Refusing outright returned *nothing* for a Section whose menu +// was merely long, and left no way forward: the ceiling is a property of the +// Section's size, so the same request reproduces it forever. Drop from the +// end and declare it with the same `projectionTruncated` the traversal and +// candidate caps above already raise — the server refuses `allScreens` on a +// truncated index and answers `not-found-in-truncated-index` instead of +// claiming a node is absent. A short menu that admits it is short beats an +// empty one. Silence was the thing forbidden here, not truncation. +const retained = () => result.nodes.length - 1; +const discoveredCandidates = candidates.length; +while (responseBytes > 19 * 1024 && retained() > 1) { + const dropped = result.nodes.pop(); + sectionNode.fields.projectionTruncated = true; + sectionNode.fields.childrenIds = sectionNode.fields.childrenIds + .filter((id) => id !== dropped.id); + responseBytes = utf8ByteLength(JSON.stringify(result)); +} if (responseBytes > 19 * 1024) { throw new Error("DEVUP_SECTION_INDEX_TOO_LARGE " + JSON.stringify({ pluginCode: "DEVUP_SECTION_INDEX_TOO_LARGE", stage: "section-index", responseBytes, maxResponseBytes: 19 * 1024, + discoveredCandidates, retainedCandidates: retained(), })); } return result; diff --git a/crates/devup-mcp-figma/tests/r7_section_error.rs b/crates/devup-mcp-figma/tests/r7_section_error.rs index c35fd1a8..88222128 100644 --- a/crates/devup-mcp-figma/tests/r7_section_error.rs +++ b/crates/devup-mcp-figma/tests/r7_section_error.rs @@ -1,4 +1,82 @@ use std::process::Command; + +/// Runs the real plugin script against a mock Figma scene graph and returns +/// whatever the harness printed. +fn run_section_index(scene: &str, report: &str) -> serde_json::Value { + let script = include_str!("../src/scripts/section_index.js"); + // `replace` rather than `format!`: the script is dense with braces and + // escaping every one of them for a format string hides what is tested. + let harness = r#" +__SCENE__ +globalThis.figma = { fileKey: 'test', getNodeByIdAsync: async () => section }; +(async () => { + const result = await (async () => { /*__SCRIPT__*/ })(); + console.log(JSON.stringify(__REPORT__)); +})().catch((error) => console.log(JSON.stringify({ threw: error.message }))); +"# + .replace("__SCENE__", scene) + .replace("/*__SCRIPT__*/", script) + .replace("__REPORT__", report); + let out = Command::new("node") + .args(["-e", &harness]) + .output() + .expect("node is required for plugin script regression"); + assert!(out.status.success(), "node failed: {out:?}"); + let text = String::from_utf8(out.stdout).unwrap(); + serde_json::from_str(text.trim()).unwrap_or_else(|error| panic!("{error}: {text}")) +} + +/// A Section holding many screens produced a menu past the plugin transport +/// ceiling, and the script refused outright — the caller got nothing, and +/// retrying reproduced it because the ceiling follows the Section's size. +/// It now drops from the end and raises the same `projectionTruncated` the +/// traversal and candidate caps already use. +#[test] +fn an_oversized_section_menu_is_truncated_rather_than_refused() { + let scene = r#" +const section = { id: 'sec', type: 'SECTION', name: 'Long Section', visible: true, + absoluteBoundingBox: { x: 0, y: 0, width: 40000, height: 4000 }, children: [] }; +for (let i = 0; i < 80; i += 1) { + section.children.push({ id: 'f' + i, type: 'FRAME', visible: true, children: [], + name: 'Screen ' + String(i).padStart(3, '0') + ' ' + 'x'.repeat(220), + absoluteBoundingBox: { x: i * 400, y: 0, width: 360, height: 740 }, parent: section }); +} +"#; + let report = r#"{ truncated: result.nodes[0].fields.projectionTruncated, + kept: result.nodes.length - 1, + childIds: result.nodes[0].fields.childrenIds.length, + bytes: JSON.stringify(result).length }"#; + let out = run_section_index(scene, report); + assert!(out["threw"].is_null(), "still refuses: {out}"); + assert_eq!(out["truncated"], true, "{out}"); + let kept = out["kept"].as_u64().unwrap(); + assert!( + (1..80).contains(&kept), + "expected a shortened menu, got {out}" + ); + // A dropped candidate must not survive as a dangling child reference. + assert_eq!(out["childIds"].as_u64().unwrap(), kept, "{out}"); + assert!(out["bytes"].as_u64().unwrap() <= 19 * 1024, "{out}"); +} + +/// Truncation is the overflow path only. A Section that fits keeps every +/// candidate and does not claim to have dropped any. +#[test] +fn a_section_that_fits_is_not_marked_truncated() { + let scene = r#" +const section = { id: 'sec', type: 'SECTION', name: 'Short Section', visible: true, + absoluteBoundingBox: { x: 0, y: 0, width: 4000, height: 4000 }, children: [] }; +for (let i = 0; i < 3; i += 1) { + section.children.push({ id: 'f' + i, type: 'FRAME', visible: true, children: [], name: 'Screen ' + i, + absoluteBoundingBox: { x: i * 400, y: 0, width: 360, height: 740 }, parent: section }); +} +"#; + let report = r#"{ truncated: result.nodes[0].fields.projectionTruncated, kept: result.nodes.length - 1 }"#; + let out = run_section_index(scene, report); + assert_eq!(out["truncated"], false, "{out}"); + assert_eq!(out["kept"], 3, "{out}"); +} + #[test] fn r7_section_script_reports_actual_ancestor_and_correction() { let script = include_str!("../src/scripts/section_index.js"); diff --git a/crates/devup-mcp-figma/tests/section.rs b/crates/devup-mcp-figma/tests/section.rs index da6e652d..062c0dce 100644 --- a/crates/devup-mcp-figma/tests/section.rs +++ b/crates/devup-mcp-figma/tests/section.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use devup_mcp_figma::{ - BatchLimits, ExploreBounds, FigmaTarget, RawNode, SectionCandidate, SectionIndex, + BatchLimits, ErrorCode, ExploreBounds, FigmaTarget, RawNode, SectionCandidate, SectionIndex, SectionSummary, Snapshot, build_section_index, plan_batches, }; use serde_json::{Map, json}; @@ -337,6 +337,36 @@ fn packing_uses_two_balanced_batches_for_a_nontrivial_visual_sequence() -> anyho Ok(()) } +/// The script now shortens an oversized menu instead of refusing it, so +/// `truncated` finally arrives from that path too. What a truncated index +/// must never do is pretend to be whole: `allScreens` cannot mean "all" over +/// a partial list, and an ID missing from it has not been proven absent. +#[test] +fn a_truncated_index_refuses_all_screens_and_never_proves_absence() { + let mut index = packing_index(&[10, 10]); + index.truncated = true; + + let error = index.select(&[], true).unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); + + let error = index.select(&["root-0".to_owned()], false); + assert!(error.is_ok(), "a retained candidate is still selectable"); + + let error = index + .select(&["dropped-root".to_owned()], false) + .unwrap_err(); + assert_eq!( + error.details["selectionIssues"][0]["reason"], + "not-found-in-truncated-index" + ); + assert!( + error.details["nextActionReason"] + .as_str() + .unwrap() + .contains("truncated index cannot prove absence") + ); +} + fn packing_index(weights: &[usize]) -> SectionIndex { let bounds = ExploreBounds { x: 0.0, diff --git a/crates/devup-mcp/src/main.rs b/crates/devup-mcp/src/main.rs index 65ba151a..1aea5fc8 100644 --- a/crates/devup-mcp/src/main.rs +++ b/crates/devup-mcp/src/main.rs @@ -46,5 +46,17 @@ async fn main() -> anyhow::Result<()> { .with_writer(std::io::stderr) .init(); + // Before the server exists, and therefore before the host has handed it a + // stdio pipe. A binary swapped after that point could not recover the pipe + // the host already holds, which is exactly why this runs here and why + // nothing replaces the running image later. + if let Some(version) = devup_mcp::server::self_update::promote() { + // stderr, because stdout carries MCP frames and nothing else. + eprintln!( + "devup-mcp: promoted staged release {version}; this process still runs {}", + env!("CARGO_PKG_VERSION") + ); + } + devup_mcp::run_stdio_with_config(config).await } diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 425cb059..fdad3c41 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -16,6 +16,7 @@ mod quality; mod release_check; pub mod resources; mod result_contract; +pub mod self_update; mod skills; mod stack_diff; mod tools; @@ -1547,7 +1548,7 @@ impl DevupServer { #[tool( description = "Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls. \ - `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations. `themeNotes` summarizes each encountered empty token category once. \ + `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, clean-unverified-tokens, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations; `clean-unverified-tokens` means no violation was found but no devup.json was resolved, so the $token references this code makes were never checked. Supplying neither tsx nor files is rejected rather than answered `clean`. Structural rules (file-placement, one-component-per-file, client-boundary, inline-style, react-query-states) run only in `files` bundle mode; framework-required default exports (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt from file-placement. `themeNotes` summarizes each encountered empty token category once. \ `checkedTokens` counts $token references this TSX makes and `availableTokenCount` counts tokens devup.json defines; they count different things and are not a ratio, which `tokens` restates by name.", output_schema = permissive_object_output_schema() )] @@ -1557,6 +1558,17 @@ impl DevupServer { ) -> Result { use devup_mcp_devup_ui::ui_validate::{bundle_theme, check_bundle, validate_bundle}; let invalid = |message: String| ErrorData::invalid_params(message, None); + // Neither source supplied. This used to validate the empty string and + // answer `ok: true, okReason: "clean"` — which reads as "this code + // passed" about code that was never sent. There is no verdict to give + // here, so the call is refused instead of being given a passing one. + if input.files.as_ref().is_none_or(|files| files.is_empty()) && input.tsx.trim().is_empty() + { + return Err(invalid( + "Supply either tsx or files; neither was provided, so there is nothing to validate." + .into(), + )); + } let theme_lookup = if let Some(files) = &input.files { check_bundle(files).map_err(invalid)?; if !input.tsx.is_empty() { @@ -1607,12 +1619,19 @@ impl DevupServer { let errors = count_severity(Severity::Error); let warnings = count_severity(Severity::Warning); let infos = count_severity(Severity::Info); + // `clean` has to mean "checked, and nothing was wrong". With no theme + // resolved the unknown-token check never ran, so code that references + // tokens is unverified rather than clean. This is the monorepo case + // that answered `clean` for a `$bg` reference while no devup.json had + // been read at all. + let token_check_skipped = theme_lookup.theme.is_none() && report.checked_tokens > 0; let ok_reason = match (errors, warnings, infos, input.strict) { (1.., _, _, _) => "error-violations", (_, 1.., _, true) => "strict-warnings", (_, 1.., 1.., _) => "warnings-and-info", (_, 1.., _, _) => "warnings-only", (_, _, 1.., _) => "info-only", + _ if token_check_skipped => "clean-unverified-tokens", _ => "clean", }; let mut result = json!({ diff --git a/crates/devup-mcp/src/server/operation.rs b/crates/devup-mcp/src/server/operation.rs index 45c316da..0eb04cfc 100644 --- a/crates/devup-mcp/src/server/operation.rs +++ b/crates/devup-mcp/src/server/operation.rs @@ -178,28 +178,116 @@ pub(crate) fn upstream_error(value: &Value) -> Option { } let mut details = json!({"stage":"plugin-execution","pluginCode":null, "nextAction":{"how":"Inspect the original upstream message and retry the original URL after correcting the reported plugin error."}}); - if message.contains("DEVUP_SECTION_REQUIRED") { + // The plugin serializes its own structured payload after the code. + // Reading it for every code, rather than only for DEVUP_SECTION_REQUIRED, + // is what lets a caller act on the others. DEVUP_SECTION_INDEX_TOO_LARGE + // measured the bytes it produced and the transport ceiling it hit, and + // every bit of that was dropped in favour of a generic "retry the + // original URL" — the one move that cannot work, because the ceiling is + // a property of the Section's size and not of the attempt. + let structured = message + .find('{') + .and_then(|start| { + serde_json::Deserializer::from_str(&message[start..]) + .into_iter::() + .next() + }) + .and_then(Result::ok) + .filter(|value| value.is_object() && value["pluginCode"].is_string()); + // The code the script threw, whether or not it serialized a payload with + // it. Only two of the scripts' twenty-five codes carry JSON, so reading + // `pluginCode` off the message is what stops the other twenty-three from + // arriving as `pluginCode: null` with nothing to act on. + let code = plugin_code(&message).map(str::to_owned); + if let Some(mut structured) = structured { + if structured["nextAction"].is_null() + && let Some(action) = + plugin_next_action(structured["pluginCode"].as_str().unwrap_or_default()) + { + structured["nextAction"] = action; + } + details = structured; + } else if message.contains("DEVUP_SECTION_REQUIRED") { details = json!({"pluginCode":"DEVUP_SECTION_REQUIRED","stage":"section-index","sectionId":null, "nextAction":{"tool":"devup_figma_export","requiredArguments":["url"], "how":"The url must point to a SECTION, while frameIds selects frames inside it. Open the requested frame's containing SECTION in Figma and copy its link. This legacy response did not include its ancestor SECTION ID."}}); - if let Some(start) = message.find('{') - && let Some(Ok(structured)) = serde_json::Deserializer::from_str(&message[start..]) - .into_iter::() - .next() - && structured["pluginCode"] == "DEVUP_SECTION_REQUIRED" - && structured.is_object() + } + if let Some(code) = &code { + if details["pluginCode"].is_null() { + details["pluginCode"] = json!(code); + } + if details["nextAction"]["tool"].is_null() + && let Some(action) = plugin_next_action(code) { - details = structured; + details["nextAction"] = action; } } Some(DevupError::with_details( - ErrorCode::DevupSnapshotUnsupported, + code.as_deref() + .and_then(plugin_error_code) + .unwrap_or(ErrorCode::DevupSnapshotUnsupported), message, false, details, )) } +/// The `DEVUP_…` token the script threw, read out of the message text. +fn plugin_code(message: &str) -> Option<&str> { + message + .split_whitespace() + .map(|token| token.trim_matches(|c: char| !(c.is_ascii_uppercase() || c == '_'))) + .find(|token| token.len() > "DEVUP_".len() && token.starts_with("DEVUP_")) +} + +/// The codes that mean something specific to the caller rather than to the +/// collector. +/// +/// Everything else stays `DevupSnapshotUnsupported`, because an internal +/// acquisition condition is not a tool argument anyone can fix. But a node or +/// a page that is not there *is* a wrong argument, and without this it +/// reached the caller as `-32603 INTERNAL_ERROR` — breaking the README's +/// promise that the JSON-RPC code alone separates "fix the arguments and call +/// again" from "stop and report", on the most common mistake there is. +fn plugin_error_code(code: &str) -> Option { + match code { + "DEVUP_NODE_NOT_FOUND" | "DEVUP_PAGE_NOT_FOUND" => Some(ErrorCode::DevupFigmaNodeNotFound), + "DEVUP_ENVELOPE_TOO_LARGE" | "DEVUP_EXPLORE_PROJECTION_TOO_LARGE" => { + Some(ErrorCode::DevupFigmaResponseTooLarge) + } + _ => None, + } +} + +/// Recovery for plugin codes whose payload measures the failure but says +/// nothing about what to do with it. +fn plugin_next_action(code: &str) -> Option { + match code { + "DEVUP_NODE_NOT_FOUND" => Some(json!({ + "tool": "devup_figma_search", + "how": "The node-id in this url does not exist in this file. Figma links can outlive the node they pointed at, and a link copied from a comment or a requirement often names a node that has since been renamed or deleted. Search this file by name for the screen you meant and use the canonicalUrl the search returns.", + "requiredArguments": ["url", "query"] + })), + "DEVUP_PAGE_NOT_FOUND" => Some(json!({ + "tool": "devup_figma_search", + "how": "The page this url names is not in this file. Search the file without a node-id to list what it actually contains.", + "requiredArguments": ["url", "query"] + })), + "DEVUP_ENVELOPE_TOO_LARGE" | "DEVUP_EXPLORE_PROJECTION_TOO_LARGE" => Some(json!({ + "tool": "devup_figma_export", + "how": "The selection produced more than the plugin transport carries. Narrow it: export one frame at a time rather than a Section or a whole page, and leave debug outputs off.", + "doNot": "Do not raise limit to recover this; limit bounds the answer, not the collection." + })), + "DEVUP_SECTION_INDEX_TOO_LARGE" => Some(json!({ + "tool": "devup_figma_search", + "how": "This Section's candidate list does not fit the plugin transport. The list is never truncated to fit, because dropping a candidate would hide a screen the caller can select; text previews are already surrendered first. Narrow the target instead: search this Section by name for the screen you want, or open a nested Section and export from there.", + "doNot": "Do not retry the same Section URL. The ceiling is a property of this Section's size, so the same request produces the same refusal.", + "requiredArguments": ["url", "query"] + })), + _ => None, + } +} + #[cfg(test)] mod r7_tests { use super::*; @@ -214,6 +302,136 @@ mod r7_tests { json!(["3997:46333"]) ); } + fn plugin_failure(text: &str) -> DevupError { + upstream_error(&json!({"isError":true,"content":[{"type":"text", + "text":format!("Error: {text}\n at (PLUGIN_17_SOURCE:3:48)")}]})) + .unwrap() + } + + /// Every code the scripts in `devup-mcp-figma/src/scripts/` throw, in + /// both shapes they arrive in: bare, and followed by a JSON payload. The + /// point of reading the code out of the message is that it works for all + /// of them — only two scripts serialize a payload, so a rule that only + /// covered those would leave the other eleven reporting `pluginCode: + /// null` exactly as before. + #[test] + fn every_plugin_code_is_named_in_both_message_shapes() { + for code in [ + "DEVUP_NODE_NOT_FOUND", + "DEVUP_PAGE_NOT_FOUND", + "DEVUP_NODE_BOUNDS_UNAVAILABLE", + "DEVUP_EXPLORE_PROJECTION_TOO_LARGE", + "DEVUP_ENVELOPE_TOO_LARGE", + "DEVUP_ENVELOPE_LENGTH_UNSTABLE", + "DEVUP_ROOTS_INVALID", + "DEVUP_SNAPSHOT_RANGE_INVALID", + "DEVUP_TARGET_IS_SECTION", + "DEVUP_LARGE_VALUE_CHANGED", + "DEVUP_LARGE_VALUE_RANGE_INVALID", + "DEVUP_SECTION_INDEX_TOO_LARGE", + "DEVUP_SECTION_REQUIRED", + ] { + assert_eq!(plugin_code(code), Some(code), "bare: {code}"); + assert_eq!( + plugin_code(&format!( + "Error: {code}\n at (PLUGIN_17_SOURCE:3:48)" + )), + Some(code), + "thrown: {code}" + ); + assert_eq!( + plugin_code(&format!(r#"Error: {code} {{"pluginCode":"{code}"}}"#)), + Some(code), + "with payload: {code}" + ); + assert_eq!( + plugin_failure(code).details["pluginCode"], + code, + "reported: {code}" + ); + } + // A script failure that names no code must not invent one. + assert_eq!(plugin_code("Error: unsupported"), None); + assert_eq!(plugin_code("Error: something went wrong"), None); + } + + /// A node that is not there is a wrong argument, not a server fault. + /// Every script code except two carries no JSON payload, so this arrived + /// as `pluginCode: null` on `DevupSnapshotUnsupported` — which + /// `is_caller_mistake` reads as an internal failure, making it + /// `-32603 INTERNAL_ERROR`. The README promises the JSON-RPC code alone + /// separates "fix the arguments" from "stop and report"; on the most + /// common mistake there is, it did the opposite. + #[test] + fn a_missing_node_is_a_caller_mistake_not_an_internal_failure() { + for code in ["DEVUP_NODE_NOT_FOUND", "DEVUP_PAGE_NOT_FOUND"] { + let error = plugin_failure(code); + assert_eq!(error.code, ErrorCode::DevupFigmaNodeNotFound, "{code}"); + assert!( + super::super::validation::is_caller_mistake(&error), + "{code} must map to INVALID_PARAMS" + ); + assert_eq!(error.details["pluginCode"], code); + assert_eq!(error.details["nextAction"]["tool"], "devup_figma_search"); + } + } + + #[test] + fn an_oversized_envelope_reports_a_size_code_rather_than_an_unsupported_snapshot() { + for code in [ + "DEVUP_ENVELOPE_TOO_LARGE", + "DEVUP_EXPLORE_PROJECTION_TOO_LARGE", + ] { + let error = plugin_failure(code); + assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge, "{code}"); + assert_eq!(error.details["pluginCode"], code); + assert!(error.details["nextAction"]["doNot"].is_string(), "{code}"); + } + } + + /// Only the codes that name a caller-fixable condition are remapped. An + /// internal acquisition failure is not a tool argument anyone can edit, + /// and calling it one would send the caller to change something that was + /// never wrong. + #[test] + fn an_internal_plugin_condition_stays_an_internal_failure() { + for code in [ + "DEVUP_ENVELOPE_LENGTH_UNSTABLE", + "DEVUP_SNAPSHOT_RANGE_INVALID", + "DEVUP_NODE_BOUNDS_UNAVAILABLE", + ] { + let error = plugin_failure(code); + assert_eq!(error.code, ErrorCode::DevupSnapshotUnsupported, "{code}"); + // Still named, so the caller is not left with `pluginCode: null`. + assert_eq!(error.details["pluginCode"], code); + assert!( + !super::super::validation::is_caller_mistake(&error), + "{code}" + ); + } + } + + /// The plugin measured the refusal and named the ceiling. That payload + /// used to be dropped for a generic "retry the original URL", which is + /// the one move guaranteed to fail again. + #[test] + fn r7_oversized_section_index_reports_its_measurements_and_a_way_out() { + let detail = json!({"pluginCode":"DEVUP_SECTION_INDEX_TOO_LARGE","stage":"section-index", + "responseBytes":19613,"maxResponseBytes":19456}); + let value = json!({"isError":true,"content":[{"type":"text","text":format!("Error: DEVUP_SECTION_INDEX_TOO_LARGE {}\n at (PLUGIN_17_SOURCE:3:48)",detail)}]}); + let error = upstream_error(&value).unwrap(); + assert_eq!(error.details["pluginCode"], "DEVUP_SECTION_INDEX_TOO_LARGE"); + assert_eq!(error.details["responseBytes"], 19613); + assert_eq!(error.details["maxResponseBytes"], 19456); + assert_eq!(error.details["nextAction"]["tool"], "devup_figma_search"); + assert!( + error.details["nextAction"]["doNot"] + .as_str() + .unwrap() + .contains("retry") + ); + } + #[test] fn r7_legacy_section_error_has_action_without_invented_ancestor() { let value = json!({"isError":true,"content":[{"type":"text","text":"Error: DEVUP_SECTION_REQUIRED\n at (PLUGIN_17_SOURCE:3:48)"}]}); diff --git a/crates/devup-mcp/src/server/project_context.rs b/crates/devup-mcp/src/server/project_context.rs index 10a00f51..cf73adc0 100644 --- a/crates/devup-mcp/src/server/project_context.rs +++ b/crates/devup-mcp/src/server/project_context.rs @@ -94,9 +94,41 @@ pub struct ThemeLookup { pub guardrail: Option, } +/// Several `devup.json` files and none of them at the project root. Names +/// each one and the `projectRoot` value that selects it, because the +/// caller's next move is to pass one of them. +fn ambiguous_theme_guardrail(root: &Path, candidates: &[PathBuf], excluded: Vec) -> Value { + let choices = candidates + .iter() + .map(|file| { + let (authority, applies_to) = file_authority(root, file); + json!({ + "devupJson": display_path(file), + "projectRoot": display_path(file.parent().unwrap_or(root)), + "authority": authority, + "appliesTo": applies_to, + }) + }) + .collect::>(); + let mut guardrail = guardrail_object( + format!( + "This project has {} devup.json files and none of them is at the project root, so none of them governs all of it. The unknown-token check was skipped rather than run against an arbitrarily chosen one. Call again with projectRoot set to the package whose code is being validated; candidateProjectRoots lists the value that selects each theme.", + candidates.len() + ), + candidates.iter().map(|file| display_path(file)).collect(), + ); + if let Some(object) = guardrail.as_object_mut() { + object.insert("candidateProjectRoots".to_owned(), Value::Array(choices)); + if !excluded.is_empty() { + object.insert("excludedPaths".to_owned(), Value::Array(excluded)); + } + } + guardrail +} + /// Resolves the theme `devup_ui_validate` should check `$token` references /// against: the project root's own `devup.json` if present, otherwise the -/// first `devup.json` found within the project (bounded search), otherwise +/// single `devup.json` found within the project (bounded search), otherwise /// `None` with an explanatory guardrail. Never caches: reads fresh on every /// call, per this module's no-session-cache requirement. pub fn theme_for_validation(project_root: Option<&str>) -> Result { @@ -129,6 +161,19 @@ pub fn theme_for_validation(project_root: Option<&str>) -> Result 1 { + return Ok(ThemeLookup { + theme: None, + guardrail: Some(ambiguous_theme_guardrail(&root, &candidates, excluded)), + }); + } (candidates.into_iter().next(), excluded) }; let Some(file) = file else { diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index d7b47327..72686f15 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -3980,6 +3980,49 @@ mod w1_regressions { assert!(contract["coordinateBasis"].is_string()); } + /// A screen exported on its own said nothing about the surface it is + /// drawn on, so learning that the desktop frame behind an app screen was + /// `#E6EDE6` took a second export of that parent — a second Figma + /// acquisition to read a colour this capture was already carrying. + #[tokio::test] + async fn r4_placement_contract_reports_the_collected_parent_background() { + let mut data = payload(); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("parentId".into(), json!("0:1")); + data.snapshot.nodes.insert( + "0:1".into(), + serde_json::from_value(json!({ + "id":"0:1", "type":"FRAME", "fields":{ + "name":"Desktop", "parentId":null, "childrenIds":["1:1"], "visible":true, + "x":0, "y":0, "width":1920, "height":1080, "layoutMode":"NONE", + "fills":[{"type":"SOLID", "visible":true, "opacity":1, "blendMode":"NORMAL", + "color":{"r":230.0/255.0, "g":237.0/255.0, "b":230.0/255.0}}]} + })) + .unwrap(), + ); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { + include_diagnostics, + .. + } = &mut op + { + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + let details = &result["placementContracts"][0]["details"]; + assert_eq!(details["parentCollected"], true, "{result}"); + let background = &details["sourceParentBackground"]; + assert_eq!(background["color"], "#E6EDE6", "{result}"); + assert_eq!(background["name"], "Desktop", "{result}"); + assert!(background["note"].is_string(), "{result}"); + // Reported, never emitted: the parent is still not this component. + assert_eq!(details["parentIncludedInOutput"], false, "{result}"); + } + #[tokio::test] async fn r4_export_exposes_placement_contract_without_diagnostics() { let data: CollectedPayload = serde_json::from_str(include_str!( diff --git a/crates/devup-mcp/src/server/release_check.rs b/crates/devup-mcp/src/server/release_check.rs index dd057a85..86dc0335 100644 --- a/crates/devup-mcp/src/server/release_check.rs +++ b/crates/devup-mcp/src/server/release_check.rs @@ -70,20 +70,28 @@ fn disabled() -> bool { /// The `updateAvailable` object for [`super::delivery::server_identity`]. /// Synchronous, cache-only, and cheap enough to run on every response. pub fn snapshot() -> Value { - if disabled() { - return classify(env!("CARGO_PKG_VERSION"), None, true, None, false); + let mut value = if disabled() { + classify(env!("CARGO_PKG_VERSION"), None, true, None, false) + } else { + let cached = cache() + .read() + .map(|guard| guard.clone()) + .unwrap_or_default(); + classify( + env!("CARGO_PKG_VERSION"), + cached.latest.as_deref(), + false, + cached.checked_at, + cached.attempted, + ) + }; + // Whether the difference this reports will close by itself, and which + // file to act on when it will not. Both are cached in `self_update`, so + // riding on every response costs no syscall. + if let Some(object) = value.as_object_mut() { + object.insert("autoUpdate".into(), super::self_update::report()); } - let cached = cache() - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); - classify( - env!("CARGO_PKG_VERSION"), - cached.latest.as_deref(), - false, - cached.checked_at, - cached.attempted, - ) + value } /// The whole decision, as a pure function, so every state is testable without a @@ -134,8 +142,10 @@ fn classify( ), "known" => concat!( "A difference between the running build and the newest release. ", - "devup-mcp never replaces its own binary: the MCP host owns this ", - "process, so update it and reconnect the client." + "This process is never replaced under itself: the MCP host owns it ", + "and the stdio pipes it already holds. Read autoUpdate for whether ", + "the difference closes on its own at the next start, and when it ", + "does not, for the file to replace." ), _ => concat!( "The newest release is not known: the check has not run yet, or it ", @@ -165,6 +175,16 @@ fn parse_version(value: &str) -> Option<(u64, u64, u64)> { Some((major, minor, patch)) } +/// Whether `candidate` is a later release than `current`. A pair this build +/// cannot compare is not a difference, and that judgement lives here beside +/// [`parse_version`] rather than being repeated by everything that needs it. +pub(super) fn is_newer(candidate: &str, current: &str) -> bool { + match (parse_version(candidate), parse_version(current)) { + (Some(candidate), Some(current)) => candidate > current, + _ => false, + } +} + /// Pull the version out of a per-crate release tag, but only when the tag names /// this crate's manifest. A `devup-mcp-figma` release says nothing about this /// binary's version. @@ -205,7 +225,7 @@ pub fn spawn() { /// One lookup. Every failure path ends the same way: the cache records that an /// attempt happened and keeps `latest` absent. async fn refresh_once() { - let latest = fetch_latest_version().await; + let fetched = fetch_latest().await; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .ok() @@ -213,13 +233,24 @@ async fn refresh_once() { if let Ok(mut guard) = cache().write() { guard.attempted = true; guard.checked_at = now; - if latest.is_some() { - guard.latest = latest; + if let Some((version, _)) = &fetched { + guard.latest = Some(version.clone()); } } + // Reporting is complete on its own above. Staging is a separate concern + // that fails quietly and changes nothing about what was just reported. + if let Some((version, Some(asset))) = fetched { + super::self_update::stage(&asset, &version).await; + } } -async fn fetch_latest_version() -> Option { +/// The newest release's version and, when this platform has one, the download +/// URL of its asset. +/// +/// The URL comes out of the release payload rather than being built from the +/// tag. These tags carry `(`, `/` and `@`, so composing a download URL from +/// one is a percent-encoding bug waiting for the first person to hit it. +async fn fetch_latest() -> Option<(String, Option)> { let client = reqwest::Client::builder() .timeout(REQUEST_TIMEOUT) .user_agent(concat!("devup-mcp/", env!("CARGO_PKG_VERSION"))) @@ -231,13 +262,56 @@ async fn fetch_latest_version() -> Option { } let body = response.json::().await.ok()?; let tag = body.get("tag_name")?.as_str()?; - version_from_tag(tag).map(str::to_owned) + let version = version_from_tag(tag)?.to_owned(); + let asset = super::self_update::asset_name().and_then(|name| asset_url(&body, name)); + Some((version, asset)) +} + +fn asset_url(release: &Value, name: &str) -> Option { + release + .get("assets")? + .as_array()? + .iter() + .find(|asset| asset.get("name").and_then(Value::as_str) == Some(name)) + .and_then(|asset| asset.get("browser_download_url")) + .and_then(Value::as_str) + .map(str::to_owned) } #[cfg(test)] mod tests { use super::*; + /// Staging acts on this, so "later" has to mean later and an + /// uncomparable pair has to mean no. + #[test] + fn only_a_comparably_later_version_counts_as_newer() { + assert!(is_newer("0.9.0", "0.8.0")); + assert!(is_newer("1.0.0", "0.9.9")); + assert!(!is_newer("0.9.0", "0.9.0")); + assert!(!is_newer("0.8.0", "0.9.0")); + // A tag this build cannot parse is never treated as an upgrade. + assert!(!is_newer("nightly", "0.9.0")); + assert!(!is_newer("0.9.0", "nightly")); + assert!(!is_newer("0.9", "0.8.0")); + } + + /// The download URL is read from the release payload, never built from + /// the tag: these tags carry `(`, `/` and `@`. + #[test] + fn the_asset_url_comes_from_the_release_payload() { + let release = json!({"assets":[ + {"name":"devup-mcp-linux-x86_64","browser_download_url":"https://example.test/linux"}, + {"name":"devup-mcp-windows-x86_64.exe","browser_download_url":"https://example.test/win"} + ]}); + assert_eq!( + asset_url(&release, "devup-mcp-windows-x86_64.exe").as_deref(), + Some("https://example.test/win") + ); + assert_eq!(asset_url(&release, "devup-mcp-macos-universal"), None); + assert_eq!(asset_url(&json!({}), "devup-mcp-linux-x86_64"), None); + } + #[test] fn a_cold_cache_is_unknown_and_never_claims_staleness() { let value = classify("0.4.5", None, false, None, false); diff --git a/crates/devup-mcp/src/server/self_update.rs b/crates/devup-mcp/src/server/self_update.rs new file mode 100644 index 00000000..06d045e8 --- /dev/null +++ b/crates/devup-mcp/src/server/self_update.rs @@ -0,0 +1,596 @@ +//! Staging the next start's binary, so a host that only ever launches +//! devup-mcp still ends up on the current release. +//! +//! [`super::release_check`] reports a difference and deliberately never acts. +//! Its reason is about *this* process: a binary replaced under a running +//! server cannot recover the stdio pipe the host already holds. That reason is +//! load-bearing and nothing here weakens it — this module never touches the +//! running image. A verified download is written as a sibling file, and the +//! only moment it becomes the binary is [`promote`], which runs at startup, +//! before a session and therefore before a pipe exists. +//! +//! Three properties are enforced rather than trusted: +//! +//! * **The candidate is executed before it is promoted.** `--self-check` is +//! the gate: a truncated download, an asset for the wrong platform or a +//! corrupted file cannot answer it. Bytes that cannot run are never renamed +//! into place. +//! * **It stands down where it is not the owner.** An install directory this +//! process cannot write, or one that belongs to a package manager, is +//! someone else's to update. A launcher that resolves the newest release on +//! every spawn is the better mechanism where it exists, and two updaters +//! writing one path is worse than either of them alone. +//! * **The previous binary is kept.** Promotion renames rather than deletes, +//! so a release that fails in a way `--self-check` cannot see is one rename +//! away from being undone. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde_json::{Value, json}; + +/// Opt out of staging while leaving the release check reporting. A launcher +/// that owns the install should set this: it already guarantees the newest +/// release at spawn, and a second updater underneath it only fights with it. +pub const DISABLE_ENV: &str = "DEVUP_MCP_NO_AUTO_UPDATE"; + +/// Refuse an implausible asset before it reaches memory. The published +/// binaries are tens of megabytes; this only rules out a redirect to +/// something that is not one of them. +const MAX_ASSET_BYTES: u64 = 256 * 1024 * 1024; +const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// Directory names that mean the install belongs to a package manager, which +/// updates it on its own schedule and would overwrite anything staged here. +const FOREIGN_INSTALL_DIRS: &[&str] = &["_npx", "node_modules", ".cargo", ".npm", ".bun", "uv"]; + +fn env_disabled(name: &str) -> bool { + std::env::var(name).is_ok_and(|value| { + let value = value.trim(); + !(value.is_empty() || value == "0" || value.eq_ignore_ascii_case("false")) + }) +} + +fn with_suffix(exe: &Path, suffix: &str) -> PathBuf { + let mut name = exe.file_name().unwrap_or_default().to_os_string(); + name.push("."); + name.push(suffix); + exe.with_file_name(name) +} + +fn staged_path(exe: &Path) -> PathBuf { + with_suffix(exe, "staged") +} + +fn previous_path(exe: &Path) -> PathBuf { + with_suffix(exe, "previous") +} + +/// Why staging is or is not this process's business. Reported rather than +/// inferred, because "no update arrived" and "updates were never mine to +/// fetch" are answers a caller acts on differently. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ownership { + Owned, + Disabled, + /// The release check is off, so nothing ever discovers a newer release + /// and staging has nothing to act on. Reported as `disabled` like the + /// switch above, because from the caller's side the mechanism is off + /// either way; only the note differs, and it names the switch that did + /// it. Without this the response said `owned` — promising an update + /// that could never arrive. + CheckDisabled, + NotWritable, + PackageManaged, + UnsupportedPlatform, + Unknown, +} + +impl Ownership { + fn as_str(self) -> &'static str { + match self { + Self::Owned => "owned", + Self::Disabled | Self::CheckDisabled => "disabled", + Self::NotWritable => "not-writable", + Self::PackageManaged => "package-managed", + Self::UnsupportedPlatform => "unsupported-platform", + Self::Unknown => "unknown", + } + } + + fn note(self) -> &'static str { + match self { + Self::Owned => { + "A newer release is downloaded in the background, proved by running --self-check on it, and renamed into place at the next start. This process is never replaced under its own stdio pipes." + } + Self::Disabled => { + "Staging is off (DEVUP_MCP_NO_AUTO_UPDATE). The release check still reports a difference." + } + Self::CheckDisabled => { + "The release check is off (DEVUP_MCP_NO_UPDATE_CHECK), so no newer release is ever discovered and staging has nothing to act on. Staging itself was not switched off." + } + Self::NotWritable => { + "The install directory is not writable by this process, so updating it belongs to whoever installed it." + } + Self::PackageManaged => { + "The binary lives inside a package manager's directory, which updates it on its own schedule. Staging here would fight that, so it is left alone." + } + Self::UnsupportedPlatform => { + "No published release asset matches this platform and architecture, so there is nothing to stage." + } + Self::Unknown => { + "The running binary's own path could not be resolved, so nothing can be staged beside it." + } + } + } +} + +fn foreign_install(exe: &Path) -> bool { + exe.components().any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|name| FOREIGN_INSTALL_DIRS.contains(&name)) + }) +} + +fn writable(directory: &Path) -> bool { + let probe = directory.join(format!(".devup-mcp-write-probe-{}", std::process::id())); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + true + } + Err(_) => false, + } +} + +/// The published asset for this platform. macOS ships one universal binary; +/// the other two are per-architecture and only x86_64 is published today. +pub fn asset_name() -> Option<&'static str> { + Some(match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => "devup-mcp-linux-x86_64", + ("macos", _) => "devup-mcp-macos-universal", + ("windows", "x86_64") => "devup-mcp-windows-x86_64.exe", + _ => return None, + }) +} + +/// [`ownership`] probes the filesystem, and `server.updateAvailable` rides on +/// every response, so the answer is settled once. It cannot change under a +/// running process in any way this module would act on. +fn cached_ownership() -> Ownership { + static OWNERSHIP: OnceLock = OnceLock::new(); + *OWNERSHIP.get_or_init(ownership) +} + +/// Whether the next start will run a different build than this process. Held +/// in memory rather than restated by a `stat` on every response. +static STAGED: AtomicBool = AtomicBool::new(false); + +/// The release this process has already put in place, so a long session does +/// not re-download it on every check. +static PLACED: std::sync::RwLock> = std::sync::RwLock::new(None); + +fn installed_path() -> Option<&'static str> { + static PATH: OnceLock> = OnceLock::new(); + PATH.get_or_init(|| { + std::env::current_exe() + .ok() + .map(|exe| exe.display().to_string()) + }) + .as_deref() +} + +fn ownership() -> Ownership { + if env_disabled(DISABLE_ENV) { + return Ownership::Disabled; + } + // Staging is fed by the release check's background task. With that off, + // nothing ever reaches `stage`, so reporting anything but "disabled" + // would promise an update that cannot arrive. + if env_disabled(super::release_check::DISABLE_ENV) { + return Ownership::CheckDisabled; + } + if asset_name().is_none() { + return Ownership::UnsupportedPlatform; + } + let Ok(exe) = std::env::current_exe() else { + return Ownership::Unknown; + }; + if foreign_install(&exe) { + return Ownership::PackageManaged; + } + match exe.parent() { + Some(directory) if writable(directory) => Ownership::Owned, + Some(_) => Ownership::NotWritable, + None => Ownership::Unknown, + } +} + +/// What `--self-check` has to say for a candidate to be accepted, as a pure +/// function so every acceptance and rejection is testable without running a +/// process. +fn accept_self_check(stdout: &[u8], current: &str) -> Option { + let report: Value = serde_json::from_slice(stdout).ok()?; + if report.get("status")?.as_str()? != "ok" { + return None; + } + let version = report.get("version")?.as_str()?.to_owned(); + super::release_check::is_newer(&version, current).then_some(version) +} + +/// Make the candidate prove it is a working devup-mcp newer than this one. +fn verify(candidate: &Path, current: &str) -> Option { + let output = std::process::Command::new(candidate) + .arg("--self-check") + .output() + .ok()?; + if !output.status.success() { + return None; + } + accept_self_check(&output.stdout, current) +} + +/// Give the staged file the binary's name, keeping what it displaces. +/// +/// This touches the filesystem, never the running process: the image this +/// server is executing was loaded at spawn and a rename cannot reach it. That +/// is why it is safe to do the moment a candidate is proved, and why it is +/// *not* the thing [`super::release_check`] refuses to do. +fn swap(exe: &Path, staged: &Path) -> bool { + let previous = previous_path(exe); + let _ = std::fs::remove_file(&previous); + // Windows allows renaming a running image but not overwriting it, so the + // current binary moves aside before the staged one takes the name. + if std::fs::rename(exe, &previous).is_err() { + return false; + } + if std::fs::rename(staged, exe).is_err() { + let _ = std::fs::rename(&previous, exe); + return false; + } + true +} + +/// Startup recovery for a candidate that was staged but never swapped in — +/// the process died between writing it and placing it. The verifier is +/// injected so the rename dance is testable without producing a real binary. +fn promote_at( + exe: &Path, + current: &str, + verify: impl Fn(&Path, &str) -> Option, +) -> Option { + let staged = staged_path(exe); + if !staged.is_file() { + return None; + } + let Some(version) = verify(&staged, current) else { + // Bytes that cannot prove themselves are removed, not left to be + // re-examined at every start for the rest of the install's life. + let _ = std::fs::remove_file(&staged); + return None; + }; + swap(exe, &staged).then_some(version) +} + +/// Runs once at startup, before the server is built. Touches the network +/// never and a subprocess only when something is actually staged. +pub fn promote() -> Option { + // The same gate staging uses, so "no updates" means no updates: a file + // left staged before either switch was set does not slip through, and an + // install this process does not own is not rewritten at startup either. + if cached_ownership() != Ownership::Owned { + return None; + } + let exe = std::env::current_exe().ok()?; + let version = promote_at(&exe, env!("CARGO_PKG_VERSION"), verify)?; + // This process keeps the image it was spawned with, so from here on the + // next start is the one that differs. + place(&version); + Some(version) +} + +/// Fetch the published asset and leave it staged for the next start. Called +/// from the release check's background task, so it is already off the call +/// path; a failure at any step simply leaves nothing staged. +pub async fn stage(asset_url: &str, latest: &str) { + if cached_ownership() != Ownership::Owned { + return; + } + let Ok(exe) = std::env::current_exe() else { + return; + }; + let staged = staged_path(&exe); + // Something is already waiting for the next start; promotion decides + // whether it is good, and re-downloading would only race with it. + if staged.exists() { + return; + } + // Measured against what the next start will actually run, not against + // this process. Once a release is in place the running image still + // reports the old version forever, and comparing with that would + // re-download the same asset on every check for the life of the session. + if !super::release_check::is_newer(latest, &next_start_version()) { + return; + } + let partial = with_suffix(&exe, "staged.partial"); + let _ = std::fs::remove_file(&partial); + if download(asset_url, &partial).await.is_none() { + let _ = std::fs::remove_file(&partial); + return; + } + if verify(&partial, env!("CARGO_PKG_VERSION")).is_none() { + let _ = std::fs::remove_file(&partial); + return; + } + if std::fs::rename(&partial, &staged).is_err() { + let _ = std::fs::remove_file(&partial); + return; + } + // Place it now rather than at the next start. Waiting cost a restart for + // nothing: staging happens mid-session and promotion at startup, so a + // release found here did not reach a running server until the *second* + // restart. The rename does not touch this process, so there is nothing to + // wait for. + if swap(&exe, &staged) { + place(latest); + // stderr, because stdout carries MCP frames and nothing else. Said + // here rather than left to the next startup: this is the moment the + // binary on disk stopped being the one this process is running, and + // a silent swap is the kind of thing someone later has to reverse + // engineer from file timestamps. + eprintln!( + "devup-mcp: placed release {latest}; this process keeps running {}, the next start uses {latest}", + env!("CARGO_PKG_VERSION") + ); + } else { + let _ = std::fs::remove_file(&staged); + } +} + +/// The version the next start will run: whatever was last placed here, or the +/// compiled-in one when nothing has been. +fn next_start_version() -> String { + placed().unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_owned()) +} + +fn placed() -> Option { + PLACED.read().ok().and_then(|guard| guard.clone()) +} + +fn place(version: &str) { + if let Ok(mut guard) = PLACED.write() { + *guard = Some(version.to_owned()); + } + STAGED.store(true, Ordering::Relaxed); +} + +async fn download(url: &str, destination: &Path) -> Option<()> { + let client = reqwest::Client::builder() + .timeout(DOWNLOAD_TIMEOUT) + .user_agent(concat!("devup-mcp/", env!("CARGO_PKG_VERSION"))) + .build() + .ok()?; + let response = client.get(url).send().await.ok()?; + if !response.status().is_success() { + return None; + } + if response + .content_length() + .is_some_and(|n| n > MAX_ASSET_BYTES) + { + return None; + } + let bytes = response.bytes().await.ok()?; + if bytes.is_empty() || bytes.len() as u64 > MAX_ASSET_BYTES { + return None; + } + std::fs::write(destination, &bytes).ok()?; + executable(destination) +} + +#[cfg(unix)] +fn executable(path: &Path) -> Option<()> { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(path).ok()?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).ok() +} + +#[cfg(not(unix))] +fn executable(_path: &Path) -> Option<()> { + Some(()) +} + +/// The `autoUpdate` object inside `server.updateAvailable`. Says which of the +/// two mechanisms is in force and, when it is this one, whether a binary is +/// already waiting for the next start. +pub fn report() -> Value { + let ownership = cached_ownership(); + json!({ + "state": ownership.as_str(), + "stagedForNextStart": STAGED.load(Ordering::Relaxed), + "asset": asset_name(), + // Which file a human would replace, for the cases this module stands + // down on. Reporting a difference without naming the file it applies + // to left the reader to find it, and on this machine three different + // devup-mcp binaries were installed at once. + "installedPath": installed_path(), + "note": ownership.note(), + "disableWith": DISABLE_ENV, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "devup-self-update-{name}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn staged_and_previous_sit_beside_the_binary_they_replace() { + let exe = Path::new("/opt/devup/devup-mcp.exe"); + assert_eq!( + staged_path(exe), + Path::new("/opt/devup/devup-mcp.exe.staged") + ); + assert_eq!( + previous_path(exe), + Path::new("/opt/devup/devup-mcp.exe.previous") + ); + } + + /// A launcher that resolves the newest release on every spawn already + /// owns this; staging underneath it would fight the thing doing the job. + #[test] + fn a_package_manager_install_is_not_ours_to_update() { + for path in [ + "/home/u/.npm/_npx/abc/node_modules/devup-mcp/devup-mcp", + "/home/u/.cargo/bin/devup-mcp", + "C:/Users/u/AppData/Local/npm-cache/_npx/x/devup-mcp.exe", + ] { + assert!(foreign_install(Path::new(path)), "{path}"); + } + assert!(!foreign_install(Path::new( + "C:/Users/u/.config/opencode/mcpb/devup-mcp-0.9.0/server/win/devup-mcp.exe" + ))); + } + + #[test] + fn only_an_ok_self_check_from_a_newer_build_is_accepted() { + let ok = br#"{"status":"ok","version":"0.9.0","binary":"ok"}"#; + assert_eq!(accept_self_check(ok, "0.8.0"), Some("0.9.0".into())); + // Same or older is not an update, however healthy it is. + assert_eq!(accept_self_check(ok, "0.9.0"), None); + assert_eq!(accept_self_check(ok, "1.0.0"), None); + // A report that is not ok, and bytes that are not a report at all. + assert_eq!( + accept_self_check(br#"{"status":"error","version":"9.9.9"}"#, "0.1.0"), + None + ); + assert_eq!(accept_self_check(b"not json", "0.1.0"), None); + assert_eq!(accept_self_check(b"", "0.1.0"), None); + } + + #[test] + fn promotion_replaces_the_binary_and_keeps_the_previous_one() { + let dir = temp_dir("promote"); + let exe = dir.join("devup-mcp.exe"); + std::fs::write(&exe, b"old").unwrap(); + std::fs::write(staged_path(&exe), b"new").unwrap(); + + let promoted = promote_at(&exe, "0.8.0", |_, _| Some("0.9.0".into())); + + assert_eq!(promoted.as_deref(), Some("0.9.0")); + assert_eq!(std::fs::read(&exe).unwrap(), b"new"); + assert_eq!(std::fs::read(previous_path(&exe)).unwrap(), b"old"); + assert!(!staged_path(&exe).exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The gate is the point: bytes that cannot prove themselves must not + /// become the binary, and must not be re-examined forever either. + #[test] + fn an_unverifiable_candidate_is_discarded_and_the_binary_is_untouched() { + let dir = temp_dir("reject"); + let exe = dir.join("devup-mcp.exe"); + std::fs::write(&exe, b"old").unwrap(); + std::fs::write(staged_path(&exe), b"garbage").unwrap(); + + let promoted = promote_at(&exe, "0.8.0", |_, _| None); + + assert_eq!(promoted, None); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + assert!(!staged_path(&exe).exists()); + assert!(!previous_path(&exe).exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Placing a release is what makes the *next* start differ, and it is + /// what a later check has to measure against. Comparing with the running + /// image instead would re-download the same asset every 24 hours for the + /// life of the session, because that image reports its old version + /// forever. + #[test] + fn an_already_placed_release_is_not_fetched_again() { + use super::super::release_check::is_newer; + place("0.9.0"); + assert_eq!(next_start_version(), "0.9.0"); + assert!(!is_newer("0.9.0", &next_start_version())); + assert!(is_newer("0.9.1", &next_start_version())); + assert!(STAGED.load(Ordering::Relaxed)); + } + + /// The swap is a filesystem move and nothing else — it must not depend on + /// having verified anything, because staging verifies before calling it. + #[test] + fn swapping_keeps_what_it_displaces() { + let dir = temp_dir("swap"); + let exe = dir.join("devup-mcp.exe"); + let staged = staged_path(&exe); + std::fs::write(&exe, b"old").unwrap(); + std::fs::write(&staged, b"new").unwrap(); + + assert!(swap(&exe, &staged)); + + assert_eq!(std::fs::read(&exe).unwrap(), b"new"); + assert_eq!(std::fs::read(previous_path(&exe)).unwrap(), b"old"); + assert!(!staged.exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn nothing_staged_is_not_an_event() { + let dir = temp_dir("empty"); + let exe = dir.join("devup-mcp.exe"); + std::fs::write(&exe, b"old").unwrap(); + assert_eq!(promote_at(&exe, "0.8.0", |_, _| Some("9.9.9".into())), None); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Staging is fed by the release check. Switching that check off used to + /// leave the report saying `owned` — promising an update that nothing + /// would ever discover. Both switches now read as off, and the note says + /// which one did it. + #[test] + fn switching_off_the_release_check_also_switches_off_staging() { + assert_eq!(Ownership::CheckDisabled.as_str(), "disabled"); + assert_eq!(Ownership::Disabled.as_str(), "disabled"); + assert!( + Ownership::CheckDisabled + .note() + .contains(super::super::release_check::DISABLE_ENV) + ); + assert!(Ownership::Disabled.note().contains(DISABLE_ENV)); + // The two notes must not be interchangeable: each names its own switch. + assert!(!Ownership::CheckDisabled.note().contains(DISABLE_ENV)); + } + + #[test] + fn the_report_names_the_mechanism_and_how_to_turn_it_off() { + let value = report(); + assert!(value["state"].is_string()); + assert!(value["note"].as_str().unwrap().len() > 40); + assert_eq!(value["disableWith"], DISABLE_ENV); + assert!(value["stagedForNextStart"].is_boolean()); + } + + /// Every published platform has an asset, and the test host is one of + /// them, so a supported build never reports `unsupported-platform`. + #[test] + fn this_platform_has_a_published_asset() { + assert!(asset_name().is_some(), "{}", std::env::consts::OS); + } +} diff --git a/crates/devup-mcp/tests/self_description.rs b/crates/devup-mcp/tests/self_description.rs index dd56be13..a86caee0 100644 --- a/crates/devup-mcp/tests/self_description.rs +++ b/crates/devup-mcp/tests/self_description.rs @@ -170,6 +170,34 @@ async fn every_tool_response_reports_staleness_without_blocking() -> anyhow::Res "the state must explain itself: {update}" ); + // Reporting a difference without saying whether it closes by itself left + // the reader to guess, and reporting one without naming the file to act + // on left them to find it — on a machine that had three devup-mcp + // binaries installed at once. + let auto = &update["autoUpdate"]; + let auto_state = auto["state"].as_str().expect("an autoUpdate state"); + assert!( + matches!( + auto_state, + "owned" + | "disabled" + | "not-writable" + | "package-managed" + | "unsupported-platform" + | "unknown" + ), + "unexpected autoUpdate state {auto_state}" + ); + assert!(auto["stagedForNextStart"].is_boolean(), "{auto}"); + assert_eq!(auto["disableWith"], "DEVUP_MCP_NO_AUTO_UPDATE", "{auto}"); + assert!( + auto["note"].as_str().is_some_and(|note| !note.is_empty()), + "the mechanism must explain itself: {auto}" + ); + // The published asset for this platform, and the file it would replace. + assert!(auto["asset"].is_string(), "{auto}"); + assert!(auto["installedPath"].is_string(), "{auto}"); + client.cancel().await?; let _ = server.await; Ok(()) diff --git a/crates/devup-mcp/tests/ui_validate_response.rs b/crates/devup-mcp/tests/ui_validate_response.rs index feb37072..97f31bc8 100644 --- a/crates/devup-mcp/tests/ui_validate_response.rs +++ b/crates/devup-mcp/tests/ui_validate_response.rs @@ -910,3 +910,220 @@ async fn bundle_typescript_files_use_typescript_syntax() -> anyhow::Result<()> { assert!(!has_rule(&output, "invalid-syntax"), "{output}"); Ok(()) } + +/// Next.js requires a default export from every segment file it loads by +/// name. Reporting one is advice that cannot be taken, and because the +/// finding is a warning it failed `strict: true` on a correct application. +#[tokio::test] +async fn bundle_framework_required_default_exports_are_exempt() -> anyhow::Result<()> { + for path in [ + "src/app/layout.tsx", + "src/app/not-found.tsx", + "src/app/error.tsx", + "src/app/loading.tsx", + "src/app/template.tsx", + "src/app/global-error.tsx", + "src/app/notice/[id]/layout.tsx", + "app/layout.tsx", + ] { + let output = structural("export default function Segment(){return
}", path).await?; + assert!(!has_rule(&output, "file-placement"), "{path}: {output}"); + } + let output = structural( + "export default function sitemap(){return []}", + "src/app/sitemap.ts", + ) + .await?; + assert!(!has_rule(&output, "file-placement"), "{output}"); + Ok(()) +} + +/// The exemption is for the directories the framework reserves these names +/// in. An ordinary module that happens to be called `error.tsx` is still +/// bound by the convention, so the rule did not lose its job. +#[tokio::test] +async fn bundle_framework_names_outside_the_router_are_still_flagged() -> anyhow::Result<()> { + for path in ["src/components/error.tsx", "src/components/default.tsx"] { + let output = structural("export default function Thing(){return
}", path).await?; + assert!(has_rule(&output, "file-placement"), "{path}: {output}"); + } + Ok(()) +} + +/// Only the component the route renders is the page. Judging every +/// component in the file reported a correct `export default function +/// NoticePage` route for its own inner helper. +#[tokio::test] +async fn bundle_page_helper_is_not_judged_as_the_page() -> anyhow::Result<()> { + let output = structural( + "function PageContent(){return
}\nexport default function NoticePage(){return }", + "src/app/notice/page.tsx", + ) + .await?; + assert!(!has_rule(&output, "file-placement"), "{output}"); + Ok(()) +} + +/// A route segment file is a framework entry point, not a component module. +/// Its helper cannot be imported by name, and the only place the rules left +/// to move it was `src/components/` — because a sibling under `src/app/` is +/// itself a violation — for a component used by exactly one route. +#[tokio::test] +async fn a_single_use_route_helper_is_not_pushed_into_shared_components() -> anyhow::Result<()> { + let output = structural( + "function PageContent(){return
}\nexport default function NoticePage(){return }", + "src/app/notice/page.tsx", + ) + .await?; + assert!(!has_rule(&output, "one-component-per-file"), "{output}"); + assert_eq!(output["ok"], true, "{output}"); + Ok(()) +} + +/// The exemption is for route files only. An ordinary component module with +/// a second component in it is still the thing the rule was written for, +/// private helper included. +#[tokio::test] +async fn an_ordinary_module_with_two_components_is_still_flagged() -> anyhow::Result<()> { + let output = structural( + "export function Card(){return
}\nfunction Other(){return }", + "src/components/Card.tsx", + ) + .await?; + assert!(has_rule(&output, "one-component-per-file"), "{output}"); + Ok(()) +} + +/// `page` is only a route name inside the router directory. A module that +/// happens to be called `page.tsx` elsewhere is not a route and owes the +/// route naming convention nothing. +#[tokio::test] +async fn a_page_named_module_outside_the_router_is_not_judged_as_a_route() -> anyhow::Result<()> { + let output = structural( + "export default function Paper(){return
}", + "src/components/page.tsx", + ) + .await?; + let messages: Vec<_> = output["violations"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v["message"].as_str()) + .collect(); + assert!( + !messages.iter().any(|m| m.contains("does not end in Page")), + "{output}" + ); + Ok(()) +} + +/// The route component still has to be named for the route, including when +/// the default export names it rather than declaring it. +#[tokio::test] +async fn bundle_page_default_export_by_identifier_is_still_judged() -> anyhow::Result<()> { + let output = structural( + "function Home(){return
}\nexport default Home;", + "src/app/page.tsx", + ) + .await?; + assert!(has_rule(&output, "file-placement"), "{output}"); + Ok(()) +} + +/// Validating nothing is not a pass. This used to check the empty string +/// and answer `ok: true, okReason: "clean"` about code never supplied. +#[tokio::test] +async fn validate_refuses_a_call_with_nothing_to_check() { + let error = validate(json!({})).await.unwrap_err().to_string(); + assert!(error.contains("nothing to validate"), "{error}"); + let error = validate(json!({"tsx":" "})) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("nothing to validate"), "{error}"); +} + +/// A monorepo root has no `devup.json` of its own. Taking the first one the +/// sorted scan finds validated one package's code against another package's +/// theme and called its real tokens `unknown-token` — an error verdict, +/// suggesting the other package's token as the fix. +#[tokio::test] +async fn monorepo_root_refuses_to_pick_one_of_several_themes() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("devup-mono-theme-{}", std::process::id())); + let front = root.join("apps").join("front"); + let admin = root.join("apps").join("admin"); + std::fs::create_dir_all(&front)?; + std::fs::create_dir_all(&admin)?; + std::fs::write(root.join("package.json"), "{}")?; + std::fs::write( + front.join("devup.json"), + r##"{"theme":{"colors":{"default":{"bg":"#F7F3EC"}}}}"##, + )?; + std::fs::write( + admin.join("devup.json"), + r##"{"theme":{"colors":{"default":{"adminBg":"#000000"}}}}"##, + )?; + let result = + validate(json!({"tsx":r##""##,"projectRoot":root.to_string_lossy()})).await; + std::fs::remove_dir_all(&root)?; + let out = result?; + assert!(!has_rule(&out, "unknown-token"), "{out}"); + assert_eq!(out["themeAvailable"], false, "{out}"); + assert_eq!(out["okReason"], "clean-unverified-tokens", "{out}"); + let choices = out["themeGuardrail"]["candidateProjectRoots"] + .as_array() + .cloned() + .unwrap_or_default(); + assert_eq!(choices.len(), 2, "{out}"); + assert!( + choices + .iter() + .all(|choice| choice["authority"] == "package-local"), + "{out}" + ); + Ok(()) +} + +/// The token check used to read JSX attributes only, so a theme token used +/// in a global style was never checked and an unknown one passed in silence. +#[tokio::test] +async fn tokens_inside_global_styles_are_checked() -> anyhow::Result<()> { + let out = validate(json!({ + "tsx": r##"globalCss({ body: { background: "$definitelyNotAToken" } })"##, + "projectRoot": fixture_project_root(), + })) + .await?; + assert!(has_rule(&out, "unknown-token"), "{out}"); + assert!(out["checkedTokens"].as_u64().unwrap_or(0) >= 1, "{out}"); + + // Nested selectors and responsive arrays carry tokens too. + let out = validate(json!({ + "tsx": r##"css({ _hover: { color: ["$alsoNotAToken"] } })"##, + "projectRoot": fixture_project_root(), + })) + .await?; + assert!(has_rule(&out, "unknown-token"), "{out}"); + Ok(()) +} + +/// One theme inside the project is still unambiguous, so the common +/// single-package layout keeps resolving without a guardrail. +#[tokio::test] +async fn a_single_nested_theme_is_still_resolved() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("devup-single-theme-{}", std::process::id())); + let front = root.join("apps").join("front"); + std::fs::create_dir_all(&front)?; + std::fs::write(root.join("package.json"), "{}")?; + std::fs::write( + front.join("devup.json"), + r##"{"theme":{"colors":{"default":{"bg":"#F7F3EC"}}}}"##, + )?; + let result = + validate(json!({"tsx":r##""##,"projectRoot":root.to_string_lossy()})).await; + std::fs::remove_dir_all(&root)?; + let out = result?; + assert_eq!(out["themeAvailable"], true, "{out}"); + assert_eq!(out["okReason"], "clean", "{out}"); + assert!(!has_rule(&out, "unknown-token"), "{out}"); + Ok(()) +} diff --git a/plugin/dist/code.js b/plugin/dist/code.js index 31e3666d..5c73c067 100644 --- a/plugin/dist/code.js +++ b/plugin/dist/code.js @@ -1 +1 @@ -(()=>{"use strict";let e={assets:async function(e){let t=e.asset;function r(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],l=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=l(r[e-2],17)^l(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,o,s,d,f,u,c,y]=a;for(let e=0;e<64;e+=1){let n=y+(l(f,6)^l(f,11)^l(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,a=(l(i,2)^l(i,13)^l(i,22))+(i&o^i&s^o&s)>>>0;y=c,c=u,u=f,f=d+n>>>0,d=s,s=o,o=i,i=n+a>>>0}for(let[e,t]of[i,o,s,d,f,u,c,y].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}function n(e){return{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:t.scale,status:"failed",byteLength:null,sha256:null,errorCode:e}}let i="string"==typeof t.field&&t.field.startsWith("$original-image/fills/");try{if(i&&"bridge"!==t.transport)return n("DEVUP_ORIGINAL_IMAGE_REQUIRES_BRIDGE");let e=await figma.getNodeByIdAsync(t.nodeId);if(!e||!i&&"function"!=typeof e.exportAsync)return n("DEVUP_ASSET_UNSUPPORTED_BY_UPSTREAM");if(i||"string"==typeof t.field&&t.field.startsWith("fills/")){let r=Number(t.field.slice(i?22:6)),a="fills"in e&&Array.isArray(e.fills)?e.fills:[],l=Number.isInteger(r)?a[r]:null,o=l&&"IMAGE"===l.type?l.imageHash||l.imageRef:null;if(!l||o!==t.imageHash)return n("DEVUP_ASSET_SOURCE_CHANGED")}else if("node"!==t.field)return n("DEVUP_ASSET_FIELD_UNSUPPORTED");if(i){let e=figma.getImageByHash(t.imageHash);if(!e)return n("DEVUP_ORIGINAL_IMAGE_NOT_FOUND");let i=await e.getBytesAsync();if(0===i.length||i.length>8388608)return n("DEVUP_ASSET_RESPONSE_TOO_LARGE");let a=e=>e.every((e,t)=>i[t]===e),l=a([137,80,78,71,13,10,26,10])?"image/png":a([255,216,255])?"image/jpeg":a([71,73,70,56])?"image/gif":a([82,73,70,70])&&87===i[8]&&69===i[9]&&66===i[10]&&80===i[11]?"image/webp":null;if(!l)return n("DEVUP_ORIGINAL_IMAGE_CODEC_UNSUPPORTED");let o=await e.getSizeAsync();return{...n(null),status:"exported",kind:"devupOriginalImage",representation:"original-image-v1",format:null,scale:null,mimeType:l,width:o.width,height:o.height,byteLength:i.length,sha256:r(i),data:figma.base64Encode(i)}}let a=String(t.format||"").toUpperCase();if(!["PNG","JPG","SVG","PDF"].includes(a))return n("DEVUP_ASSET_FORMAT_UNSUPPORTED");let l=Math.min(4,Math.max(1,Math.floor(Number(t.scale)||1))),o="SVG"===a,s={format:o?"SVG_STRING":a};("PNG"===a||"JPG"===a)&&(s.constraint={type:"SCALE",value:l});let d=await e.exportAsync(s),f=o&&"string"==typeof d?d:null,u=null===f?d instanceof Uint8Array?d:new Uint8Array(d):function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(f);if(0===u.length||u.length>8388608)return n("DEVUP_ASSET_RESPONSE_TOO_LARGE");let c=r(u),y=figma.io&&"function"==typeof figma.io.write,h="PNG"===a&&u.length>786432;if(y&&(null!==f&&u.length>12288||h))return{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:l,status:"chunked",byteLength:u.length,sha256:c,cursor:{nextOffset:0,maxChunkBytes:12288},errorCode:null};return y&&figma.io.write(`devup-asset-${t.assetId.replace(/[^A-Za-z0-9_-]/g,"_")}.${String(t.format).toLowerCase()}`,u),{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:l,status:"exported",byteLength:u.length,sha256:c,mimeType:y?null===f?null:"image/svg+xml":({PNG:"image/png",JPG:"image/jpeg",SVG:"image/svg+xml",PDF:"application/pdf"})[a],...!y&&null===f?{data:figma.base64Encode(u)}:{},text:f,errorCode:null}}catch(e){return n(i?"DEVUP_ORIGINAL_IMAGE_READ_FAILED":"DEVUP_ASSET_EXPORT_FAILED")}},explore:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=e.explore,n=Math.max(20,Math.min(400,Number.isInteger(r.projectionLimit)?r.projectionLimit:200)),i=Math.max(0,Math.min(500,Number.isInteger(r.textPreviewLimit)?r.textPreviewLimit:160));function a(e){return"string"==typeof e?e.slice(0,240):""}let l=t,o="SECTION"===t.type?t:null;for(;l.parent&&"PAGE"!==l.parent.type;)l=l.parent,o||"SECTION"!==l.type||(o=l);let s="PAGE"===t.type?t:l.parent;if(!s||"PAGE"!==s.type)throw Error("DEVUP_PAGE_NOT_FOUND");function d(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function f(e){let t=d(e);return t&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&t.width/Math.max(1,t.height)>=.25&&t.width/Math.max(1,t.height)<=2.5}function u(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type&&t.length<12;){let e=a(r.name);e&&t.push(e),r=r.parent}return t.reverse()}await figma.setCurrentPageAsync(s);let c=!f(t)&&o?o:l,y=d(c)||d(t);if(!y)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let h="children"in s?s.children:[],g=h.map((e,t)=>({node:e,pageChildIndex:t,bounds:d(e)})).filter(e=>e.bounds).filter(e=>{var t;return e.node.id===c.id||e.bounds.y>=y.y-240&&e.bounds.y<=y.y+12e3&&(t=e.bounds,Math.min(t.x+t.width,y.x+y.width)-Math.max(t.x,y.x)>0)}).sort((e,t)=>e.bounds.y-t.bounds.y||e.bounds.x-t.bounds.x||e.node.id.localeCompare(t.node.id)),p="SECTION"===c.type?g.filter(e=>e.node.id===c.id):g.slice(0,n),m=new Map(p.map(e=>[e.node.id,e]));m.has(l.id)||m.set(l.id,{node:l,pageChildIndex:-1,bounds:d(l)}),m.has(c.id)||m.set(c.id,{node:c,pageChildIndex:-1,bounds:d(c)}),m.has(t.id)||m.set(t.id,{node:t,pageChildIndex:-1,bounds:d(t)});let b=t.parent;for(;b&&"PAGE"!==b.type&&(m.has(b.id)||m.set(b.id,{node:b,pageChildIndex:-1,bounds:d(b)}),b.id!==c.id);)b=b.parent;if("children"in c)for(let e of c.children.slice(0,n))m.has(e.id)||m.set(e.id,{node:e,pageChildIndex:-1,bounds:d(e)});let x="SECTION"===c.type&&"children"in c&&c.children.length>n;if("SECTION"===c.type&&"children"in c){let e=8*n,t=c.children.filter(e=>!f(e)).map(e=>({node:e,ancestors:[]})),r=0;for(;t.length&&r!m.has(e.id));if(m.size+t.length>n+2){x=!0;continue}for(let e of t)m.set(e.id,{node:e,pageChildIndex:-1,bounds:d(e)});continue}if("children"in e)for(let r of e.children)t.push({node:r,ancestors:[...i,e]})}x||=t.length>0}let E=[...m.values()].filter(e=>e.bounds).slice(0,n+2).map(({node:e,pageChildIndex:t,bounds:r})=>({id:e.id,type:e.type,fields:{name:a(e.name),parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:[],x:r.x,y:r.y,width:r.width,height:r.height,childCount:"children"in e?e.children.length:0,textPreview:"",pageChildIndex:t>=0?t:null,visible:!("visible"in e)||!1!==e.visible,breadcrumb:u(e)},extra:{},fieldErrors:{}})),I=E.reduce((e,t)=>{let r=t.fields;return e?{x:Math.min(e.x,r.x),y:Math.min(e.y,r.y),right:Math.max(e.right,r.x+r.width),bottom:Math.max(e.bottom,r.y+r.height)}:{x:r.x,y:r.y,right:r.x+r.width,bottom:r.y+r.height}},null)||{x:0,y:0,right:0,bottom:0},S="SECTION"!==c.type&&g.length>p.length||m.size>n+2||x,A={id:s.id,type:s.type,fields:{name:a(s.name),parentId:null,childrenIds:[],x:I.x,y:I.y,width:I.right-I.x,height:I.bottom-I.y,childCount:h.length,textPreview:"",projectionTruncated:S,visible:!0,breadcrumb:u(s),pageChildIndex:null},extra:{},fieldErrors:{}},N={fileKey:figma.fileKey||"",version:null,rootIds:[s.id],nodes:[A,...E.filter(e=>e.id!==s.id)],diagnostics:[]},w=new Set([s.id,l.id,c.id,t.id]);for(;JSON.stringify(N).length>14e3;){let e=N.nodes.length-1;for(;e>=0&&w.has(N.nodes[e].id);)e-=1;if(e<0)break;N.nodes.splice(e,1),A.fields.projectionTruncated=!0}if(JSON.stringify(N).length>14e3&&(N.nodes=N.nodes.filter(e=>w.has(e.id)).map(e=>({...e,fields:{...e.fields,name:a(e.fields.name).slice(0,80),textPreview:"",breadcrumb:e.fields.breadcrumb.slice(-4).map(e=>e.slice(0,80)),projectionTruncated:e.id===s.id||e.fields.projectionTruncated}}))),JSON.stringify(N).length>14e3)throw Error("DEVUP_EXPLORE_PROJECTION_TOO_LARGE");let O=14e3-JSON.stringify(N).length;if(i>0&&O>0){let e=N.nodes.filter(e=>m.get(e.id)&&e.id!==s.id),t=e.length;for(let r of e){let e=Math.floor(O/t);if(t-=1,e<=0)continue;let n="",a=0;for(let t of function(e){if(0===i)return"";let t=[],r=[e],n=0;for(;r.length&&n<80&&t.join(" ").lengthe)break;n+=t,a+=r}r.fields.textPreview=n,O-=a}}return N},fastSnapshot:async function(e){let t=e.rootIds;if(!Array.isArray(t)||0===t.length)throw Error("DEVUP_ROOTS_INVALID");let r=await Promise.all(t.map(e=>figma.getNodeByIdAsync(e)));if(r.some(e=>!e))throw Error("DEVUP_NODE_NOT_FOUND");if(1===r.length&&"SECTION"===r[0].type)throw Error("DEVUP_TARGET_IS_SECTION");let n=["mobile","tablet","desktop"],i=e=>n.indexOf(String(e.name||"").trim().toLowerCase());if(1===r.length&&i(r[0])>=0){let e=r[0].parent;if(e&&"SECTION"===e.type&&"children"in e){let t=e.children.filter(e=>e.id===r[0].id||i(e)>=0).filter(e=>!1!==e.visible);t.length>1&&(r.length=0,r.push(...t))}}if(1===t.length){let e=[],t=new Set(r.map(e=>e.id)),n=[],i=e=>{if("reactions"in e&&Array.isArray(e.reactions)){for(let r of e.reactions)if(r&&r.trigger&&"AFTER_TIMEOUT"===r.trigger.type)for(let e of r.actions||[])e&&"NODE"===e.type&&e.transition&&"SMART_ANIMATE"===e.transition.type&&"string"==typeof e.destinationId&&!t.has(e.destinationId)&&(t.add(e.destinationId),n.push(e.destinationId));if("children"in e)for(let t of e.children)i(t)}};for(let e of r)i(e);for(;n.length>0;){let t=n.shift(),r=await figma.getNodeByIdAsync(t);r&&"DOCUMENT"!==r.type&&"PAGE"!==r.type&&(e.push(r),i(r))}r.push(...e)}let a=e.nodeId,l=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],o=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],s=e.snapshot,d=Math.max(0,Math.floor(Number(s.offset)||0)),f=Math.max(8192,Math.floor(Number(s.maxEnvelopeBytes)||19456)),u=Math.min(s.maxEnvelopeBytes?f-1024:18e3,Math.max(4096,Math.floor(Number(s.maxPayloadBytes)||15e3))),c=new Set(["backgroundStyleId","effectStyleId","fillStyleId","gridStyleId","strokeStyleId","textStyleId"]),y=new Set(["maxWidth","maxHeight","absoluteRenderBounds"]),h=new Map([["rotation",0],["cornerRadius",0],["isAsset",!1],["isMask",!1],["clipsContent",!1],["blendMode","PASS_THROUGH"],["strokeAlign","INSIDE"],["textCase","ORIGINAL"],["textDecoration","NONE"],["textAlignHorizontal","LEFT"],["textAlignVertical","TOP"],["counterAxisAlignItems","MIN"],["primaryAxisAlignItems","MIN"],["gridColumnCount",0],["gridRowCount",0],["gridColumnGap",0],["gridRowGap",0],["gridColumnAnchorIndex",-1],["gridRowAnchorIndex",-1],["gridColumnSpan",1],["gridRowSpan",1]]),g=new Set(["start","end","characters","fontWeight","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]),p=new Set(["parent","children","consumers"]);function m(e,t=!1,r=new WeakSet,n=0){let i;if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>m(e,t,r,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(r.has(e))return{$circular:!0};if(r.add(e),t){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}i=[...t].sort().filter(e=>!e.startsWith("_")&&!p.has(e))}else i=Object.keys(e).sort();let a={};for(let l of i)try{let i=m(e[l],t,r,n+1);i&&"function"===i.$unsupported||(a[l]=i)}catch(e){a[l]=t?{$error:"unavailable"}:{$error:String(e&&e.message?e.message:e)}}return r.delete(e),a}let b=[],x=[...r],E=new Set;for(let e=0;e=b.length&&b.length>0)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");function I(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1({id:e,styleType:t})).sort((e,t)=>e.id.localeCompare(t.id)),a=await Promise.all([...n.map(async e=>{try{let t=await figma.variables.getVariableByIdAsync(e);return t?{kind:"variable",value:m(t,!0),collectionId:t.variableCollectionId}:{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable"}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable"}}}}),...i.map(async({id:e,styleType:t})=>{try{let r=await figma.getStyleByIdAsync(e);if(!r)return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable"}};return{kind:"style",value:{...m(r,!0),styleType:t,value:m("PAINT"===t?r.paints:"EFFECT"===t?r.effects:"GRID"===t?r.layoutGrids:r,!0)}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable"}}}})]),l=[...new Set(a.filter(e=>"variable"===e.kind&&e.collectionId).map(e=>e.collectionId))].sort(),o=(await Promise.all(l.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?m(t,!0):null}catch(e){return null}}))).filter(e=>null!==e),s=a.filter(e=>"variable"===e.kind).map(e=>e.value),d=a.filter(e=>"style"===e.kind).map(e=>e.value),f=a.filter(e=>"unresolved"===e.kind).map(e=>e.value);return{collections:o,variables:s,styles:d,usedRemoteVariables:s.filter(e=>!0===e.remote),usedVariableIds:n,usedStyleIds:i.map(e=>e.id),localComplete:!1,usedRemoteComplete:0===f.length,unresolved:f,$variableRefCount:n.length,$styleRefCount:i.length}}let A=u-1024,N=null;for(let e=0;e<5;e+=1){let{pageNodes:e,packedBytes:t}=function(e){let t=[],r=2;for(let n=d;ne.id):[];for(let i of(n.length>0&&(t.childrenIds=n),l)){let n;try{if(!(i in e)){"overflowDirection"===i&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t[i]=null);continue}if(n=e[i],"function"==typeof n)continue;let r=m(n);if("overflowDirection"===i&&null==n){t[i]=null;continue}(null===r?!y.has(i):Array.isArray(r)?0===r.length:"object"==typeof r?0===Object.keys(r).length:!!(""===r&&c.has(i))||h.has(i)&&h.get(i)===r)||(t[i]=r)}catch(e){r[i]=String(e&&e.message?e.message:e)}}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{let r=m(e.getStyledTextSegments(o));if(1===r.length){let e=r[0];for(let t of Object.keys(e))g.has(t)||delete e[t]}r.length>0&&(t.styledTextSegments=r)}catch(e){r.styledTextSegments=String(e&&e.message?e.message:e)}let i={id:e.id,type:e.type,fields:t};return Object.keys(r).length>0&&(i.fieldErrors=r),i}(b[n]),a=I(JSON.stringify(i))+ +!!t.length;if(t.length&&r+a>e)break;t.push(i),r+=a}return{pageNodes:t,packedBytes:r}}(A);if(0===e.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let n=function(e,t){let n=Math.min(b.length,d+e.length),{$variableRefCount:i,$styleRefCount:l,...o}=t,s=[...e,{id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:d,nextOffset:n,complete:n>=b.length,totalNodes:b.length},extra:{},fieldErrors:{}}],f={kind:"devupFastSnapshotEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",rootId:a},snapshot:{fileKey:figma.fileKey||"",version:null,rootIds:r.map(e=>e.id),nodes:s,diagnostics:[]},resources:o,integrity:{nodeCount:s.length,variableRefCount:i,styleRefCount:l,utf8Bytes:0}},u=0;for(let e=0;e<8&&(u=I(JSON.stringify(f)),f.integrity.utf8Bytes!==u);e+=1)f.integrity.utf8Bytes=u;if(f.integrity.utf8Bytes!==I(JSON.stringify(f)))throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:f,bytes:u}}(e,await S(e));if(n.bytes<=f){N=n;break}if(1===e.length)throw Error("DEVUP_ENVELOPE_TOO_LARGE");A=Math.max(1,Math.min(A-1,t-(n.bytes-f)-256))}if(!N)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return N.envelope},fastTheme:async function(e){let t=Math.max(0,Math.floor(Number(e.theme.offset)||0));function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={};for(let a of function(e){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}return[...t].sort()}(e))if(!(a.startsWith("_")||["parent","children","consumers"].includes(a)))try{let l=r(e[a],t,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:"unavailable"}}return t.delete(e),i}let n=new Set,i=new Map;for(let e of[figma.root,...figma.root.findAll(()=>!0)])for(let t of["boundVariables","fills","strokes","effects","layoutGrids","textStyleId","fillStyleId","strokeStyleId","backgroundStyleId","effectStyleId","gridStyleId"])try{!function e(t,r="",a=new WeakSet,l=0){if(!(l>16)&&null!==t&&"object"==typeof t&&!a.has(t)){if(a.add(t),Array.isArray(t)){for(let n of t)e(n,r,a,l+1);return}for(let[o,s]of("VARIABLE_ALIAS"===t.type&&"string"==typeof t.id&&t.id&&"figma.mixed"!==t.id&&"MIXED"!==t.id&&n.add(t.id),Object.entries(t))){let t="textStyleId"===o?"TEXT":["fillStyleId","strokeStyleId","backgroundStyleId"].includes(o)?"PAINT":"effectStyleId"===o?"EFFECT":"gridStyleId"===o?"GRID":null;t&&"string"==typeof s&&s&&"figma.mixed"!==s&&"MIXED"!==s&&!i.has(s)&&i.set(s,t),e(s,o||r,a,l+1)}}}({[t]:e[t]})}catch(e){}let[a,l,o,s,d,f]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.variables.getLocalVariablesAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),u=new Set(l.map(e=>e.id)),c=new Set([...o,...s,...d,...f].map(e=>e.id)),y=[],h=[...n].filter(e=>!u.has(e)).sort().map(async e=>{try{return await figma.variables.getVariableByIdAsync(e)||null}catch(t){return y.push({id:e,kind:"variable",reason:"notFoundOrUnavailable"}),null}}),g=[...i.entries()].filter(([e])=>!c.has(e)).sort(([e],[t])=>e.localeCompare(t)).map(async([e,t])=>{try{let r=await figma.getStyleByIdAsync(e);return r?{style:r,styleType:t}:null}catch(t){return y.push({id:e,kind:"style",reason:"notFoundOrUnavailable"}),null}}),p=(await Promise.all(h)).filter(Boolean),m=(await Promise.all(g)).filter(Boolean);for(let e of[...n].filter(e=>!u.has(e)))p.some(t=>t.id===e)||y.some(t=>t.id===e)||y.push({id:e,kind:"variable",reason:"notFoundOrUnavailable"});for(let[e]of[...i.entries()].filter(([e])=>!c.has(e)))m.some(t=>t.style.id===e)||y.some(t=>t.id===e)||y.push({id:e,kind:"style",reason:"notFoundOrUnavailable"});let b=[...new Set(p.map(e=>e.variableCollectionId))].filter(e=>!a.some(t=>t.id===e)).sort(),x=(await Promise.all(b.map(async e=>{try{return await figma.variables.getVariableCollectionByIdAsync(e)}catch(e){return null}}))).filter(Boolean);function E(e,t){return{...r(e),styleType:t,value:r("PAINT"===t?e.paints:"EFFECT"===t?e.effects:"GRID"===t?e.layoutGrids:e)}}let I=[...o.map(e=>E(e,"PAINT")),...s.map(e=>E(e,"TEXT")),...d.map(e=>E(e,"EFFECT")),...f.map(e=>E(e,"GRID")),...m.map(({style:e,styleType:t})=>E(e,t))].sort((e,t)=>e.id.localeCompare(t.id)),S=[...l,...p].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id)),A=[...a,...x].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id));function N(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}y.sort((e,t)=>e.kind.localeCompare(t.kind)||e.id.localeCompare(t.id));let w=[...A.map(e=>({kind:"collection",value:e})),...S.map(e=>({kind:"variable",value:e})),...I.map(e=>({kind:"style",value:e})),...[...n].sort().map(e=>({kind:"usedVariableId",value:e})),...[...i.keys()].sort().map(e=>({kind:"usedStyleId",value:e})),...y.map(e=>({kind:"unresolved",value:e}))];if(t>w.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let O=w.map(e=>N(JSON.stringify(e.value)).length+1),T=18432,v=null;for(let e=0;e<6;e+=1){let{pageItems:e,packed:r}=function(e){let r=[],n=0;for(let i=t;i0)||!(n+O[i]>e));i+=1)r.push(w[i]),n+=O[i];return{pageItems:r,packed:n}}(T),n=function(e,r){let n=t=>e.filter(e=>e.kind===t).map(e=>e.value),i=n("collection"),a=n("variable"),l=n("style"),o=n("unresolved"),s={kind:"devupFastThemeEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",version:null},resources:{collections:i,variables:a,styles:l,usedRemoteVariables:[],usedVariableIds:n("usedVariableId"),usedStyleIds:n("usedStyleId"),localComplete:!0,usedRemoteComplete:0===y.length,unresolved:o},page:{offset:t,nextOffset:r,complete:r>=w.length,totalItems:w.length},integrity:{collectionCount:i.length,variableCount:a.length,styleCount:l.length,unresolvedCount:o.length,utf8Bytes:0}},d=new Uint8Array;for(let e=0;e<8&&(d=N(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length);e+=1)s.integrity.utf8Bytes=d.length;if(d=N(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length)throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:s,bytes:d.length}}(e,t+e.length);if(n.bytes<=19456){v=n;break}if(e.length<=1)break;T=Math.max(1,Math.min(T-1,r-(n.bytes-19456)-256))}if(null===v||v.bytes>8388608)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return v.envelope},largeValue:async function(e){let t,r=e.largeValue,n=await figma.getNodeByIdAsync(r.nodeId);if(!n)throw Error("DEVUP_NODE_NOT_FOUND");function i(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}let a=null;try{if("$export:svg"===r.field){if("function"!=typeof n.exportAsync)throw Error("unsupported");let e=await n.exportAsync({format:"SVG_STRING"});if("string"!=typeof e)throw Error("unsupported");a=i(e)}else if("string"==typeof r.field&&r.field.startsWith("$export:png")){if("function"!=typeof n.exportAsync)throw Error("unsupported");let e=Math.min(4,Math.max(1,Math.floor(Number(r.field.split("@")[1])||1))),t=await n.exportAsync({format:"PNG",constraint:{type:"SCALE",value:e}});a=t instanceof Uint8Array?t:new Uint8Array(t)}else if("styledTextSegments"===r.field&&"TEXT"===n.type&&"function"==typeof n.getStyledTextSegments)t=n.getStyledTextSegments(["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]);else if(r.field in n)t=n[r.field];else throw Error("unsupported")}catch(e){return{kind:"devupLargeValueUnsupported",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,byteLength:r.byteLength,sha256:r.sha256,errorCode:"DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM"}}let l=null===a?i(JSON.stringify(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let l=e(t[a],r,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(t))):a,o=function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=e.length,n=64*Math.ceil((r+9)/64),i=new Uint8Array(n);i.set(e),i[r]=128;let a=8*r;for(let e=0;e<8;e+=1)i[n-1-e]=255&Math.floor(a/2**(8*e));let l=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],o=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=o(r[e-2],17)^o(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[n,a,s,d,f,u,c,y]=l;for(let e=0;e<64;e+=1){let i=y+(o(f,6)^o(f,11)^o(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,l=(o(n,2)^o(n,13)^o(n,22))+(n&a^n&s^a&s)>>>0;y=c,c=u,u=f,f=d+i>>>0,d=s,s=a,a=n,n=i+l>>>0}for(let[e,t]of[n,a,s,d,f,u,c,y].entries())l[e]=l[e]+t>>>0}return l.map(e=>e.toString(16).padStart(8,"0")).join("")}(l);if(l.length!==r.byteLength||o!==r.sha256)throw Error("DEVUP_LARGE_VALUE_CHANGED");let s=Math.max(0,Math.floor(Number(r.offset)||0)),d=Math.min(65536,Math.max(1,Math.floor(Number(r.maxChunkBytes)||8192)));if(s>=l.length)throw Error("DEVUP_LARGE_VALUE_RANGE_INVALID");let f=Math.min(l.length,s+d);return{kind:"devupLargeValueFragment",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,offset:s,nextOffset:f,byteLength:l.length,sha256:o,dataBase64:function(e){let t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",r="";for(let n=0;n>2],r+=t[(3&i)<<4|a>>4],r+=n+1>6]:"=",r+=n+20;){let e=t[t.length-1],i=e.node,a="children"in i?i.children:[];if(!e.visited){e.visited=!0;for(let e=a.length-1;e>=0;e-=1)t.push({node:a[e],visited:!1});continue}t.pop();let l=0,o=[];for(let e of a)o.push(e.id),l+=1+(r.get(e.id)||0);r.set(i.id,l),n.push({id:i.id,type:i.type,name:i.name,childrenIds:o,descendantCount:l})}}(t),{fileKey:figma.fileKey||"",version:null,rootId:t.id,nodes:n}},pageCatalog:async function(e){let t=figma.root.children;return{fileKey:figma.fileKey||"",version:null,rootIds:t.map(e=>e.id),nodes:t.map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:null,childrenIds:[]},extra:{},fieldErrors:{}})),diagnostics:[]}},search:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=t;for(;r&&"PAGE"!==r.type;)r=r.parent;if(!r||"PAGE"!==r.type)throw Error("DEVUP_PAGE_NOT_FOUND");await figma.setCurrentPageAsync(r);let n=e.search,i=new Set((n.nodeTypes.length?n.nodeTypes:["PAGE","SECTION","FRAME","COMPONENT_SET","COMPONENT"]).map(e=>e.toUpperCase()));function a(e){return e.normalize("NFC").toLocaleLowerCase().replace(/\s/gu,"")}let l=t===r?r.findAll(()=>!0):"findAll"in t?t.findAll(()=>!0):[],o=[t,...l].filter(e=>i.has(e.type)&&"string"==typeof e.name).map(e=>({node:e,score:function(e){if(e===n.query)return 400;if("exact"===n.matchKind)return null;let t=a(e),r=a(n.query);if(t===r)return 300;if(t.startsWith(r))return 200;if(t.includes(r))return 100;if("fuzzy"!==n.matchKind)return null;let i=function(e,t){let r=Array.from({length:t.length+1},(e,t)=>t);for(let n=0;nnull!==e.score).sort((e,t)=>t.score-e.score||e.node.name.localeCompare(t.node.name)||e.node.id.localeCompare(t.node.id)).slice(0,n.limit),s=new Map([[r.id,r]]);for(let e of[t,...o.map(e=>e.node)]){let t=e;for(;t&&"DOCUMENT"!==t.type;)s.set(t.id,t),t=t.parent}return{fileKey:figma.fileKey||"",version:null,rootIds:[r.id],nodes:[...s.values()].map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:"children"in e?e.children.map(e=>e.id):[]},extra:{},fieldErrors:{}})),diagnostics:[]}},sectionIndex:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");if("SECTION"!==t.type){let e=t.parent,r=new Set;for(;e&&"SECTION"!==e.type&&!r.has(e.id);)r.add(e.id),e=e.parent;let n=e&&"SECTION"===e.type?e.id:null,i=n&&figma.fileKey?`https://www.figma.com/design/${figma.fileKey}?node-id=${n.replace(/:/g,"-")}`:null;throw Error("DEVUP_SECTION_REQUIRED "+JSON.stringify({pluginCode:"DEVUP_SECTION_REQUIRED",stage:"section-index",nodeId:t.id,nodeType:t.type,sectionId:n,nextAction:{tool:"devup_figma_export",how:n?`The url must point to a SECTION. This capture's SECTION is ${n}; select frames with frameIds.`:"The url must point to a SECTION. This node has no ancestor SECTION; choose the intended SECTION in Figma and copy its link.",arguments:i?"FRAME"===t.type&&t.parent===e?{url:i,frameIds:[t.id]}:{url:i}:null,requiredArguments:i?[]:["url"]}}))}let r=2048;function n(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function i(e,t){if("FRAME"!==e.type||!1===e.visible||!t)return!1;let r=t.width/Math.max(1,t.height);return t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&r>=.25&&r<=2.5}function a(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1[e.id,t.id])),s=[],d=0;for(let e=0;ee.id));for(let r of t.children){if(e.has(r.id))continue;let t=n(r);t&&!1!==r.visible&&s.push({node:r,box:t})}}s.sort((e,t)=>e.box.y-t.box.y||e.box.x-t.box.x||e.node.id.localeCompare(t.node.id));let f=l.length>d||s.length>100,u=s.slice(0,100),c=new Set(u.map(({node:e})=>e.id)),y=new Set(e.rootIds),h={},g=new Map([[t.id,null]]);for(let e=0;e{let r=e.parent;for(;r&&r.id!==t.id&&!c.has(r.id);)r=r.parent;return[e.id,r&&c.has(r.id)?r.id:t.id]})),m=e=>u.filter(({node:t})=>p.get(t.id)===e).map(({node:e})=>e.id),b=n(t);if(!b)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let x={id:t.id,type:t.type,fields:{name:t.name,parentId:t.parent&&"DOCUMENT"!==t.parent.type?t.parent.id:null,childrenIds:m(t.id),absoluteBoundingBox:b,visible:!1!==t.visible,projectionTruncated:f,nodeScreenIds:h},extra:{},fieldErrors:{}},E=u.length,I=u.map(({node:e,box:t})=>{let n=function(e){let t=[e],r=0,n=0;for(let e=0;er}}(e),l=Math.floor(r/E);E-=1;let o=function(e,t){let r=[e],n="",i=0,l=0,o=!1;if(t<=0)return{text:"",state:"budget-exhausted",spent:0};for(let e=0;e=120||l+e>t)return{text:n.trimEnd(),state:l+e>t?"budget-exhausted":"truncated",spent:l};n+=r,i+=1,l+=e}}if("children"in s){let e=Math.max(0,64-r.length);s.children.length>e&&(o=!0),r.push(...s.children.slice(0,e))}}}return{text:n,state:o?"truncated":n?"available":"no-text",spent:l}}(e,l);return r-=o.spent,{id:e.id,type:e.type,fields:{name:"string"==typeof e.name?e.name:"",parentId:p.get(e.id),childrenIds:m(e.id),absoluteBoundingBox:t,visible:!1!==e.visible,breadcrumb:function(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type;)"string"==typeof r.name&&r.name&&t.push(r.name),r=r.parent;return t.reverse()}(e),directChildCount:"children"in e?e.children.length:0,textPreview:o.text,textPreviewState:o.state,subtreeNodeCount:n.subtreeNodeCount,estimatedSerializedBytes:n.estimatedSerializedBytes,selectionReasons:[i(e,t)?"screen-like":"explicit-selection-only","inside-section"],estimateTruncated:n.truncated},extra:{},fieldErrors:{}}}),S={fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:[x,...I],diagnostics:[]},A=a(JSON.stringify(S));for(let e=I.length-1;e>=0&&A>19456;e-=1)I[e].fields.textPreview="",I[e].fields.textPreviewState="budget-exhausted",A=a(JSON.stringify(S));if(A>19456)throw Error("DEVUP_SECTION_INDEX_TOO_LARGE "+JSON.stringify({pluginCode:"DEVUP_SECTION_INDEX_TOO_LARGE",stage:"section-index",responseBytes:A,maxResponseBytes:19456}));return S},snapshot:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],n=new Set(r),i=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],a=e.snapshot,l=Math.max(0,Math.floor(Number(a.offset)||0)),o=Math.min(a.maxEnvelopeBytes?Math.max(8192,Math.floor(Number(a.maxEnvelopeBytes))):16e3,Math.max(4096,Math.floor(Number(a.maxPayloadBytes)||15e3))),s=Math.min(o-1024,Math.max(512,Math.floor(Number(a.maxFieldBytes)||4096))),d=new Set(["id","type","parent","children"]),f=new Set(["parentId","childrenIds","name","characters","styledTextSegments","boundVariables"]);function u(e,t,r){let n=function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(JSON.stringify(r));return n.length>0x1000000?{$truncated:"max-large-value-bytes",byteLength:n.length}:{$largeValue:{nodeId:e,field:t,byteLength:n.length,sha256:function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],l=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=l(r[e-2],17)^l(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,o,s,d,f,u,c,y]=a;for(let e=0;e<64;e+=1){let n=y+(l(f,6)^l(f,11)^l(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,a=(l(i,2)^l(i,13)^l(i,22))+(i&o^i&s^o&s)>>>0;y=c,c=u,u=f,f=d+n>>>0,d=s,s=o,o=i,i=n+a>>>0}for(let[e,t]of[i,o,s,d,f,u,c,y].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}(n),cursor:{nextOffset:0,maxChunkBytes:12288}}}}function c(e){return function(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+112)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let l=e(t[a],r,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(r),a=c(i);if(a<=s)return i;let l=u(e,t,i);return l.$truncated&&(n[t]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${a}>16777216`),l}let h=[],g=[t];for(;g.length;){let e=g.shift();h.push(e),"children"in e&&g.push(...e.children)}let p=[],m=o-1024,b=2;for(let e=l;eNumber(e.protected)-Number(t.protected)||t.byteLength-e.byteLength),n)){if(r<=t)break;let n=u(e.id,i.name,e[i.sectionName][i.name]);e[i.sectionName][i.name]=n,n.$truncated&&(e.fieldErrors[i.name]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${i.byteLength}>16777216`),r=c(e)}return e}(function(e){let t={},a={},l={};for(let i of(t.parentId=e.parent?e.parent.id:null,e.parent&&("PAGE"===e.parent.type||"SECTION"===e.parent.type||"COMPONENT_SET"===e.parent.type)&&(t.parentType=e.parent.type,"SECTION"===e.parent.type&&(t.parentName=e.parent.name)),t.childrenIds="children"in e?e.children.map(e=>e.id):[],!("overflowDirection"in e)&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t.overflowDirection=null),function(e){let t=new Set,n=e;for(;n&&n!==Object.prototype;){for(let e of Object.getOwnPropertyNames(n))t.add(e);n=Object.getPrototypeOf(n)}for(let n of r)try{n in e&&t.add(n)}catch(e){}return[...t].sort()}(e)))if(!(d.has(i)||i.startsWith("_")))try{let r=e[i];if("function"==typeof r)continue;let o=y(e.id,i,r,l);(n.has(i)?t:a)[i]=o}catch(e){l[i]=String(e&&e.message?e.message:e)}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{t.styledTextSegments=y(e.id,"styledTextSegments",e.getStyledTextSegments(i),l)}catch(e){l.styledTextSegments=String(e&&e.message?e.message:e)}return{id:e.id,type:e.type,fields:t,extra:a,fieldErrors:l}}(h[e]),m),a=c(t)+ +!!p.length;if(p.length&&b+a>m)break;p.push(t),b+=a}let x=Math.min(h.length,l+p.length);return p.push({id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:l,nextOffset:x,complete:x>=h.length,totalNodes:h.length},extra:{},fieldErrors:{}}),{fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:p,diagnostics:[]}},usedResources:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),l=e;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!(l.startsWith("_")||["parent","children","consumers"].includes(l)))try{let a=r(e[l],t,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:"unavailable"}}return t.delete(e),i}let n=new Map,i="ok";try{for(let e of(await figma.variables.getLocalVariablesAsync()))n.set(e.id,e)}catch(e){i="unavailable"}let a=await Promise.all(t.variableIds.map(async e=>{let t=n.get(e);if(t)return{value:r(t),collectionId:t.variableCollectionId};try{let t=await figma.variables.getVariableByIdAsync(e);return t?{value:r(t),collectionId:t.variableCollectionId}:{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupEmpty"}}}catch(t){return{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupThrew"}}}})),l=[...new Set(a.flatMap(e=>e.collectionId?[e.collectionId]:[]))].sort(),o=await Promise.all(l.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?[r(t)]:[]}catch(e){return[]}})),s=await Promise.all(t.styles.map(async e=>{try{let t=await figma.getStyleByIdAsync(e.id);if(!t)return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable"}};return{value:{...r(t),styleType:e.styleType,value:r("PAINT"===e.styleType?t.paints:"EFFECT"===e.styleType?t.effects:"GRID"===e.styleType?t.layoutGrids:t)}}}catch(t){return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable"}}}}));return{collections:o.flat(),variables:a.flatMap(e=>e.value?[e.value]:[]),styles:s.flatMap(e=>e.value?[e.value]:[]),usedVariableIds:t.variableIds,usedStyleIds:t.styles.map(e=>e.id),localVariableListing:i,localVariableCount:n.size,unresolved:[...a,...s].flatMap(e=>e.unresolved?[e.unresolved]:[])}},variableCatalog:async function(e){let[t,r,n,i,a]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),l=["PAINT","TEXT","EFFECT","GRID"];return{collections:t.map(e=>(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(r.has(t))return{$circular:!0};r.add(t);let i={},a=new Set(Object.keys(t)),l=t;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!l.startsWith("_"))try{let a=e(t[l],r,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i})(e)),variableIds:[...new Set(t.flatMap(e=>e.variableIds))].sort(),styles:[r,n,i,a].flatMap((e,t)=>e.map(e=>({id:e.id,styleType:l[t]}))).sort((e,t)=>e.id.localeCompare(t.id)),localComplete:!0,usedRemoteComplete:!1}},variables:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),l=e;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!(l.startsWith("_")||["parent","children","consumers"].includes(l)))try{let a=r(e[l],t,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:String(e&&e.message?e.message:e)}}return t.delete(e),i}let[n,i]=await Promise.all([Promise.all(t.variableIds.map(e=>figma.variables.getVariableByIdAsync(e))),Promise.all(t.styles.map(e=>figma.getStyleByIdAsync(e.id)))]),a=new Map(t.styles.map(e=>[e.id,e])),l=await Promise.all(i.filter(Boolean).map(async e=>{let t=a.get(e.id),n=t.styleType;if(Number.isInteger(t.consumerStart)&&Number.isInteger(t.consumerEnd)){let i=await e.getStyleConsumersAsync();return{id:e.id,styleType:n,$consumerStart:t.consumerStart,$consumerEntries:i.slice(t.consumerStart,t.consumerEnd).map(e=>[e.node.id,e.node.type,r(e.fields)])}}let i=await e.getStyleConsumersAsync();return{...r(e),styleType:n,$consumerCount:i.length,value:r("PAINT"===n?e.paints:"EFFECT"===n?e.effects:"GRID"===n?e.layoutGrids:e)}}));return{variables:n.filter(Boolean).map(e=>r(e)),styles:l}}};async function t(t){let r=e[t.script];if(!r)return{kind:"devup-result",requestId:t.requestId,error:`DEVUP_BRIDGE_UNKNOWN_SCRIPT: ${t.script} (아는 스크립트: ${Object.keys(e).join(", ")})`};try{var n;let e=await r({nodeId:(n=t.params).nodeId??"",rootIds:n.rootIds??[],snapshot:n.snapshot??{},search:n.search??{},explore:n.explore??{},resources:n.resources??{variableIds:[],styles:[]},largeValue:n.largeValue??{},asset:n.asset??{},theme:n.theme??{offset:0}});return{kind:"devup-result",requestId:t.requestId,data:e}}catch(e){return{kind:"devup-result",requestId:t.requestId,error:e instanceof Error?e.message:String(e)}}}let r=[],n=!1;async function i(){if(!n){n=!0;try{for(;r.length>0;){let e=r.shift(),n=await t(e).catch(t=>({kind:"devup-result",requestId:e.requestId,error:t instanceof Error?t.message:String(t)}));figma.ui.postMessage(n)}}finally{n=!1}}}figma.showUI(__html__,{width:320,height:220}),figma.ui.onmessage=e=>{if("object"==typeof e&&null!==e){if("devup-ready"===e.kind){let e={kind:"devup-status",fileKey:figma.fileKey??null,fileName:figma.root.name,port:1993};figma.ui.postMessage(e);return}"devup-job"===e.kind&&(r.push(e),i())}}})(); \ No newline at end of file +(()=>{"use strict";let e={assets:async function(e){let t=e.asset;function r(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],l=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=l(r[e-2],17)^l(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,o,s,d,f,u,c,y]=a;for(let e=0;e<64;e+=1){let n=y+(l(f,6)^l(f,11)^l(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,a=(l(i,2)^l(i,13)^l(i,22))+(i&o^i&s^o&s)>>>0;y=c,c=u,u=f,f=d+n>>>0,d=s,s=o,o=i,i=n+a>>>0}for(let[e,t]of[i,o,s,d,f,u,c,y].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}function n(e){return{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:t.scale,status:"failed",byteLength:null,sha256:null,errorCode:e}}let i="string"==typeof t.field&&t.field.startsWith("$original-image/fills/");try{if(i&&"bridge"!==t.transport)return n("DEVUP_ORIGINAL_IMAGE_REQUIRES_BRIDGE");let e=await figma.getNodeByIdAsync(t.nodeId);if(!e||!i&&"function"!=typeof e.exportAsync)return n("DEVUP_ASSET_UNSUPPORTED_BY_UPSTREAM");if(i||"string"==typeof t.field&&t.field.startsWith("fills/")){let r=Number(t.field.slice(i?22:6)),a="fills"in e&&Array.isArray(e.fills)?e.fills:[],l=Number.isInteger(r)?a[r]:null,o=l&&"IMAGE"===l.type?l.imageHash||l.imageRef:null;if(!l||o!==t.imageHash)return n("DEVUP_ASSET_SOURCE_CHANGED")}else if("node"!==t.field)return n("DEVUP_ASSET_FIELD_UNSUPPORTED");if(i){let e=figma.getImageByHash(t.imageHash);if(!e)return n("DEVUP_ORIGINAL_IMAGE_NOT_FOUND");let i=await e.getBytesAsync();if(0===i.length||i.length>8388608)return n("DEVUP_ASSET_RESPONSE_TOO_LARGE");let a=e=>e.every((e,t)=>i[t]===e),l=a([137,80,78,71,13,10,26,10])?"image/png":a([255,216,255])?"image/jpeg":a([71,73,70,56])?"image/gif":a([82,73,70,70])&&87===i[8]&&69===i[9]&&66===i[10]&&80===i[11]?"image/webp":null;if(!l)return n("DEVUP_ORIGINAL_IMAGE_CODEC_UNSUPPORTED");let o=await e.getSizeAsync();return{...n(null),status:"exported",kind:"devupOriginalImage",representation:"original-image-v1",format:null,scale:null,mimeType:l,width:o.width,height:o.height,byteLength:i.length,sha256:r(i),data:figma.base64Encode(i)}}let a=String(t.format||"").toUpperCase();if(!["PNG","JPG","SVG","PDF"].includes(a))return n("DEVUP_ASSET_FORMAT_UNSUPPORTED");let l=Math.min(4,Math.max(1,Math.floor(Number(t.scale)||1))),o="SVG"===a,s={format:o?"SVG_STRING":a};("PNG"===a||"JPG"===a)&&(s.constraint={type:"SCALE",value:l});let d=await e.exportAsync(s),f=o&&"string"==typeof d?d:null,u=null===f?d instanceof Uint8Array?d:new Uint8Array(d):function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(f);if(0===u.length||u.length>8388608)return n("DEVUP_ASSET_RESPONSE_TOO_LARGE");let c=r(u),y=figma.io&&"function"==typeof figma.io.write,h="PNG"===a&&u.length>786432;if(y&&(null!==f&&u.length>12288||h))return{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:l,status:"chunked",byteLength:u.length,sha256:c,cursor:{nextOffset:0,maxChunkBytes:12288},errorCode:null};return y&&figma.io.write(`devup-asset-${t.assetId.replace(/[^A-Za-z0-9_-]/g,"_")}.${String(t.format).toLowerCase()}`,u),{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:l,status:"exported",byteLength:u.length,sha256:c,mimeType:y?null===f?null:"image/svg+xml":({PNG:"image/png",JPG:"image/jpeg",SVG:"image/svg+xml",PDF:"application/pdf"})[a],...!y&&null===f?{data:figma.base64Encode(u)}:{},text:f,errorCode:null}}catch(e){return n(i?"DEVUP_ORIGINAL_IMAGE_READ_FAILED":"DEVUP_ASSET_EXPORT_FAILED")}},explore:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=e.explore,n=Math.max(20,Math.min(400,Number.isInteger(r.projectionLimit)?r.projectionLimit:200)),i=Math.max(0,Math.min(500,Number.isInteger(r.textPreviewLimit)?r.textPreviewLimit:160));function a(e){return"string"==typeof e?e.slice(0,240):""}let l=t,o="SECTION"===t.type?t:null;for(;l.parent&&"PAGE"!==l.parent.type;)l=l.parent,o||"SECTION"!==l.type||(o=l);let s="PAGE"===t.type?t:l.parent;if(!s||"PAGE"!==s.type)throw Error("DEVUP_PAGE_NOT_FOUND");function d(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function f(e){let t=d(e);return t&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&t.width/Math.max(1,t.height)>=.25&&t.width/Math.max(1,t.height)<=2.5}function u(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type&&t.length<12;){let e=a(r.name);e&&t.push(e),r=r.parent}return t.reverse()}await figma.setCurrentPageAsync(s);let c=!f(t)&&o?o:l,y=d(c)||d(t);if(!y)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let h="children"in s?s.children:[],g=h.map((e,t)=>({node:e,pageChildIndex:t,bounds:d(e)})).filter(e=>e.bounds).filter(e=>{var t;return e.node.id===c.id||e.bounds.y>=y.y-240&&e.bounds.y<=y.y+12e3&&(t=e.bounds,Math.min(t.x+t.width,y.x+y.width)-Math.max(t.x,y.x)>0)}).sort((e,t)=>e.bounds.y-t.bounds.y||e.bounds.x-t.bounds.x||e.node.id.localeCompare(t.node.id)),p="SECTION"===c.type?g.filter(e=>e.node.id===c.id):g.slice(0,n),m=new Map(p.map(e=>[e.node.id,e]));m.has(l.id)||m.set(l.id,{node:l,pageChildIndex:-1,bounds:d(l)}),m.has(c.id)||m.set(c.id,{node:c,pageChildIndex:-1,bounds:d(c)}),m.has(t.id)||m.set(t.id,{node:t,pageChildIndex:-1,bounds:d(t)});let b=t.parent;for(;b&&"PAGE"!==b.type&&(m.has(b.id)||m.set(b.id,{node:b,pageChildIndex:-1,bounds:d(b)}),b.id!==c.id);)b=b.parent;if("children"in c)for(let e of c.children.slice(0,n))m.has(e.id)||m.set(e.id,{node:e,pageChildIndex:-1,bounds:d(e)});let x="SECTION"===c.type&&"children"in c&&c.children.length>n;if("SECTION"===c.type&&"children"in c){let e=8*n,t=c.children.filter(e=>!f(e)).map(e=>({node:e,ancestors:[]})),r=0;for(;t.length&&r!m.has(e.id));if(m.size+t.length>n+2){x=!0;continue}for(let e of t)m.set(e.id,{node:e,pageChildIndex:-1,bounds:d(e)});continue}if("children"in e)for(let r of e.children)t.push({node:r,ancestors:[...i,e]})}x||=t.length>0}let E=[...m.values()].filter(e=>e.bounds).slice(0,n+2).map(({node:e,pageChildIndex:t,bounds:r})=>({id:e.id,type:e.type,fields:{name:a(e.name),parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:[],x:r.x,y:r.y,width:r.width,height:r.height,childCount:"children"in e?e.children.length:0,textPreview:"",pageChildIndex:t>=0?t:null,visible:!("visible"in e)||!1!==e.visible,breadcrumb:u(e)},extra:{},fieldErrors:{}})),I=E.reduce((e,t)=>{let r=t.fields;return e?{x:Math.min(e.x,r.x),y:Math.min(e.y,r.y),right:Math.max(e.right,r.x+r.width),bottom:Math.max(e.bottom,r.y+r.height)}:{x:r.x,y:r.y,right:r.x+r.width,bottom:r.y+r.height}},null)||{x:0,y:0,right:0,bottom:0},S="SECTION"!==c.type&&g.length>p.length||m.size>n+2||x,A={id:s.id,type:s.type,fields:{name:a(s.name),parentId:null,childrenIds:[],x:I.x,y:I.y,width:I.right-I.x,height:I.bottom-I.y,childCount:h.length,textPreview:"",projectionTruncated:S,visible:!0,breadcrumb:u(s),pageChildIndex:null},extra:{},fieldErrors:{}},N={fileKey:figma.fileKey||"",version:null,rootIds:[s.id],nodes:[A,...E.filter(e=>e.id!==s.id)],diagnostics:[]},w=new Set([s.id,l.id,c.id,t.id]);for(;JSON.stringify(N).length>14e3;){let e=N.nodes.length-1;for(;e>=0&&w.has(N.nodes[e].id);)e-=1;if(e<0)break;N.nodes.splice(e,1),A.fields.projectionTruncated=!0}if(JSON.stringify(N).length>14e3&&(N.nodes=N.nodes.filter(e=>w.has(e.id)).map(e=>({...e,fields:{...e.fields,name:a(e.fields.name).slice(0,80),textPreview:"",breadcrumb:e.fields.breadcrumb.slice(-4).map(e=>e.slice(0,80)),projectionTruncated:e.id===s.id||e.fields.projectionTruncated}}))),JSON.stringify(N).length>14e3)throw Error("DEVUP_EXPLORE_PROJECTION_TOO_LARGE");let O=14e3-JSON.stringify(N).length;if(i>0&&O>0){let e=N.nodes.filter(e=>m.get(e.id)&&e.id!==s.id),t=e.length;for(let r of e){let e=Math.floor(O/t);if(t-=1,e<=0)continue;let n="",a=0;for(let t of function(e){if(0===i)return"";let t=[],r=[e],n=0;for(;r.length&&n<80&&t.join(" ").lengthe)break;n+=t,a+=r}r.fields.textPreview=n,O-=a}}return N},fastSnapshot:async function(e){let t=e.rootIds;if(!Array.isArray(t)||0===t.length)throw Error("DEVUP_ROOTS_INVALID");let r=await Promise.all(t.map(e=>figma.getNodeByIdAsync(e)));if(r.some(e=>!e))throw Error("DEVUP_NODE_NOT_FOUND");if(1===r.length&&"SECTION"===r[0].type)throw Error("DEVUP_TARGET_IS_SECTION");let n=["mobile","tablet","desktop"],i=e=>n.indexOf(String(e.name||"").trim().toLowerCase());if(1===r.length&&i(r[0])>=0){let e=r[0].parent;if(e&&"SECTION"===e.type&&"children"in e){let t=e.children.filter(e=>e.id===r[0].id||i(e)>=0).filter(e=>!1!==e.visible);t.length>1&&(r.length=0,r.push(...t))}}if(1===t.length){let e=[],t=new Set(r.map(e=>e.id)),n=[],i=e=>{if("reactions"in e&&Array.isArray(e.reactions)){for(let r of e.reactions)if(r&&r.trigger&&"AFTER_TIMEOUT"===r.trigger.type)for(let e of r.actions||[])e&&"NODE"===e.type&&e.transition&&"SMART_ANIMATE"===e.transition.type&&"string"==typeof e.destinationId&&!t.has(e.destinationId)&&(t.add(e.destinationId),n.push(e.destinationId));if("children"in e)for(let t of e.children)i(t)}};for(let e of r)i(e);for(;n.length>0;){let t=n.shift(),r=await figma.getNodeByIdAsync(t);r&&"DOCUMENT"!==r.type&&"PAGE"!==r.type&&(e.push(r),i(r))}r.push(...e)}let a=e.nodeId,l=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],o=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],s=e.snapshot,d=Math.max(0,Math.floor(Number(s.offset)||0)),f=Math.max(8192,Math.floor(Number(s.maxEnvelopeBytes)||19456)),u=Math.min(s.maxEnvelopeBytes?f-1024:18e3,Math.max(4096,Math.floor(Number(s.maxPayloadBytes)||15e3))),c=new Set(["backgroundStyleId","effectStyleId","fillStyleId","gridStyleId","strokeStyleId","textStyleId"]),y=new Set(["maxWidth","maxHeight","absoluteRenderBounds"]),h=new Map([["rotation",0],["cornerRadius",0],["isAsset",!1],["isMask",!1],["clipsContent",!1],["blendMode","PASS_THROUGH"],["strokeAlign","INSIDE"],["textCase","ORIGINAL"],["textDecoration","NONE"],["textAlignHorizontal","LEFT"],["textAlignVertical","TOP"],["counterAxisAlignItems","MIN"],["primaryAxisAlignItems","MIN"],["gridColumnCount",0],["gridRowCount",0],["gridColumnGap",0],["gridRowGap",0],["gridColumnAnchorIndex",-1],["gridRowAnchorIndex",-1],["gridColumnSpan",1],["gridRowSpan",1]]),g=new Set(["start","end","characters","fontWeight","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]),p=new Set(["parent","children","consumers"]);function m(e,t=!1,r=new WeakSet,n=0){let i;if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>m(e,t,r,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(r.has(e))return{$circular:!0};if(r.add(e),t){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}i=[...t].sort().filter(e=>!e.startsWith("_")&&!p.has(e))}else i=Object.keys(e).sort();let a={};for(let l of i)try{let i=m(e[l],t,r,n+1);i&&"function"===i.$unsupported||(a[l]=i)}catch(e){a[l]=t?{$error:"unavailable"}:{$error:String(e&&e.message?e.message:e)}}return r.delete(e),a}let b=[],x=[...r],E=new Set;for(let e=0;e=b.length&&b.length>0)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");function I(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1({id:e,styleType:t})).sort((e,t)=>e.id.localeCompare(t.id)),a=await Promise.all([...n.map(async e=>{try{let t=await figma.variables.getVariableByIdAsync(e);return t?{kind:"variable",value:m(t,!0),collectionId:t.variableCollectionId}:{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable"}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable"}}}}),...i.map(async({id:e,styleType:t})=>{try{let r=await figma.getStyleByIdAsync(e);if(!r)return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable"}};return{kind:"style",value:{...m(r,!0),styleType:t,value:m("PAINT"===t?r.paints:"EFFECT"===t?r.effects:"GRID"===t?r.layoutGrids:r,!0)}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable"}}}})]),l=[...new Set(a.filter(e=>"variable"===e.kind&&e.collectionId).map(e=>e.collectionId))].sort(),o=(await Promise.all(l.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?m(t,!0):null}catch(e){return null}}))).filter(e=>null!==e),s=a.filter(e=>"variable"===e.kind).map(e=>e.value),d=a.filter(e=>"style"===e.kind).map(e=>e.value),f=a.filter(e=>"unresolved"===e.kind).map(e=>e.value);return{collections:o,variables:s,styles:d,usedRemoteVariables:s.filter(e=>!0===e.remote),usedVariableIds:n,usedStyleIds:i.map(e=>e.id),localComplete:!1,usedRemoteComplete:0===f.length,unresolved:f,$variableRefCount:n.length,$styleRefCount:i.length}}let A=u-1024,N=null;for(let e=0;e<5;e+=1){let{pageNodes:e,packedBytes:t}=function(e){let t=[],r=2;for(let n=d;ne.id):[];for(let i of(n.length>0&&(t.childrenIds=n),l)){let n;try{if(!(i in e)){"overflowDirection"===i&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t[i]=null);continue}if(n=e[i],"function"==typeof n)continue;let r=m(n);if("overflowDirection"===i&&null==n){t[i]=null;continue}(null===r?!y.has(i):Array.isArray(r)?0===r.length:"object"==typeof r?0===Object.keys(r).length:!!(""===r&&c.has(i))||h.has(i)&&h.get(i)===r)||(t[i]=r)}catch(e){r[i]=String(e&&e.message?e.message:e)}}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{let r=m(e.getStyledTextSegments(o));if(1===r.length){let e=r[0];for(let t of Object.keys(e))g.has(t)||delete e[t]}r.length>0&&(t.styledTextSegments=r)}catch(e){r.styledTextSegments=String(e&&e.message?e.message:e)}let i={id:e.id,type:e.type,fields:t};return Object.keys(r).length>0&&(i.fieldErrors=r),i}(b[n]),a=I(JSON.stringify(i))+ +!!t.length;if(t.length&&r+a>e)break;t.push(i),r+=a}return{pageNodes:t,packedBytes:r}}(A);if(0===e.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let n=function(e,t){let n=Math.min(b.length,d+e.length),{$variableRefCount:i,$styleRefCount:l,...o}=t,s=[...e,{id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:d,nextOffset:n,complete:n>=b.length,totalNodes:b.length},extra:{},fieldErrors:{}}],f={kind:"devupFastSnapshotEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",rootId:a},snapshot:{fileKey:figma.fileKey||"",version:null,rootIds:r.map(e=>e.id),nodes:s,diagnostics:[]},resources:o,integrity:{nodeCount:s.length,variableRefCount:i,styleRefCount:l,utf8Bytes:0}},u=0;for(let e=0;e<8&&(u=I(JSON.stringify(f)),f.integrity.utf8Bytes!==u);e+=1)f.integrity.utf8Bytes=u;if(f.integrity.utf8Bytes!==I(JSON.stringify(f)))throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:f,bytes:u}}(e,await S(e));if(n.bytes<=f){N=n;break}if(1===e.length)throw Error("DEVUP_ENVELOPE_TOO_LARGE");A=Math.max(1,Math.min(A-1,t-(n.bytes-f)-256))}if(!N)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return N.envelope},fastTheme:async function(e){let t=Math.max(0,Math.floor(Number(e.theme.offset)||0));function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={};for(let a of function(e){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}return[...t].sort()}(e))if(!(a.startsWith("_")||["parent","children","consumers"].includes(a)))try{let l=r(e[a],t,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:"unavailable"}}return t.delete(e),i}let n=new Set,i=new Map;for(let e of[figma.root,...figma.root.findAll(()=>!0)])for(let t of["boundVariables","fills","strokes","effects","layoutGrids","textStyleId","fillStyleId","strokeStyleId","backgroundStyleId","effectStyleId","gridStyleId"])try{!function e(t,r="",a=new WeakSet,l=0){if(!(l>16)&&null!==t&&"object"==typeof t&&!a.has(t)){if(a.add(t),Array.isArray(t)){for(let n of t)e(n,r,a,l+1);return}for(let[o,s]of("VARIABLE_ALIAS"===t.type&&"string"==typeof t.id&&t.id&&"figma.mixed"!==t.id&&"MIXED"!==t.id&&n.add(t.id),Object.entries(t))){let t="textStyleId"===o?"TEXT":["fillStyleId","strokeStyleId","backgroundStyleId"].includes(o)?"PAINT":"effectStyleId"===o?"EFFECT":"gridStyleId"===o?"GRID":null;t&&"string"==typeof s&&s&&"figma.mixed"!==s&&"MIXED"!==s&&!i.has(s)&&i.set(s,t),e(s,o||r,a,l+1)}}}({[t]:e[t]})}catch(e){}let[a,l,o,s,d,f]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.variables.getLocalVariablesAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),u=new Set(l.map(e=>e.id)),c=new Set([...o,...s,...d,...f].map(e=>e.id)),y=[],h=[...n].filter(e=>!u.has(e)).sort().map(async e=>{try{return await figma.variables.getVariableByIdAsync(e)||null}catch(t){return y.push({id:e,kind:"variable",reason:"notFoundOrUnavailable"}),null}}),g=[...i.entries()].filter(([e])=>!c.has(e)).sort(([e],[t])=>e.localeCompare(t)).map(async([e,t])=>{try{let r=await figma.getStyleByIdAsync(e);return r?{style:r,styleType:t}:null}catch(t){return y.push({id:e,kind:"style",reason:"notFoundOrUnavailable"}),null}}),p=(await Promise.all(h)).filter(Boolean),m=(await Promise.all(g)).filter(Boolean);for(let e of[...n].filter(e=>!u.has(e)))p.some(t=>t.id===e)||y.some(t=>t.id===e)||y.push({id:e,kind:"variable",reason:"notFoundOrUnavailable"});for(let[e]of[...i.entries()].filter(([e])=>!c.has(e)))m.some(t=>t.style.id===e)||y.some(t=>t.id===e)||y.push({id:e,kind:"style",reason:"notFoundOrUnavailable"});let b=[...new Set(p.map(e=>e.variableCollectionId))].filter(e=>!a.some(t=>t.id===e)).sort(),x=(await Promise.all(b.map(async e=>{try{return await figma.variables.getVariableCollectionByIdAsync(e)}catch(e){return null}}))).filter(Boolean);function E(e,t){return{...r(e),styleType:t,value:r("PAINT"===t?e.paints:"EFFECT"===t?e.effects:"GRID"===t?e.layoutGrids:e)}}let I=[...o.map(e=>E(e,"PAINT")),...s.map(e=>E(e,"TEXT")),...d.map(e=>E(e,"EFFECT")),...f.map(e=>E(e,"GRID")),...m.map(({style:e,styleType:t})=>E(e,t))].sort((e,t)=>e.id.localeCompare(t.id)),S=[...l,...p].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id)),A=[...a,...x].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id));function N(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}y.sort((e,t)=>e.kind.localeCompare(t.kind)||e.id.localeCompare(t.id));let w=[...A.map(e=>({kind:"collection",value:e})),...S.map(e=>({kind:"variable",value:e})),...I.map(e=>({kind:"style",value:e})),...[...n].sort().map(e=>({kind:"usedVariableId",value:e})),...[...i.keys()].sort().map(e=>({kind:"usedStyleId",value:e})),...y.map(e=>({kind:"unresolved",value:e}))];if(t>w.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let O=w.map(e=>N(JSON.stringify(e.value)).length+1),T=18432,v=null;for(let e=0;e<6;e+=1){let{pageItems:e,packed:r}=function(e){let r=[],n=0;for(let i=t;i0)||!(n+O[i]>e));i+=1)r.push(w[i]),n+=O[i];return{pageItems:r,packed:n}}(T),n=function(e,r){let n=t=>e.filter(e=>e.kind===t).map(e=>e.value),i=n("collection"),a=n("variable"),l=n("style"),o=n("unresolved"),s={kind:"devupFastThemeEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",version:null},resources:{collections:i,variables:a,styles:l,usedRemoteVariables:[],usedVariableIds:n("usedVariableId"),usedStyleIds:n("usedStyleId"),localComplete:!0,usedRemoteComplete:0===y.length,unresolved:o},page:{offset:t,nextOffset:r,complete:r>=w.length,totalItems:w.length},integrity:{collectionCount:i.length,variableCount:a.length,styleCount:l.length,unresolvedCount:o.length,utf8Bytes:0}},d=new Uint8Array;for(let e=0;e<8&&(d=N(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length);e+=1)s.integrity.utf8Bytes=d.length;if(d=N(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length)throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:s,bytes:d.length}}(e,t+e.length);if(n.bytes<=19456){v=n;break}if(e.length<=1)break;T=Math.max(1,Math.min(T-1,r-(n.bytes-19456)-256))}if(null===v||v.bytes>8388608)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return v.envelope},largeValue:async function(e){let t,r=e.largeValue,n=await figma.getNodeByIdAsync(r.nodeId);if(!n)throw Error("DEVUP_NODE_NOT_FOUND");function i(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}let a=null;try{if("$export:svg"===r.field){if("function"!=typeof n.exportAsync)throw Error("unsupported");let e=await n.exportAsync({format:"SVG_STRING"});if("string"!=typeof e)throw Error("unsupported");a=i(e)}else if("string"==typeof r.field&&r.field.startsWith("$export:png")){if("function"!=typeof n.exportAsync)throw Error("unsupported");let e=Math.min(4,Math.max(1,Math.floor(Number(r.field.split("@")[1])||1))),t=await n.exportAsync({format:"PNG",constraint:{type:"SCALE",value:e}});a=t instanceof Uint8Array?t:new Uint8Array(t)}else if("styledTextSegments"===r.field&&"TEXT"===n.type&&"function"==typeof n.getStyledTextSegments)t=n.getStyledTextSegments(["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]);else if(r.field in n)t=n[r.field];else throw Error("unsupported")}catch(e){return{kind:"devupLargeValueUnsupported",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,byteLength:r.byteLength,sha256:r.sha256,errorCode:"DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM"}}let l=null===a?i(JSON.stringify(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let l=e(t[a],r,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(t))):a,o=function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=e.length,n=64*Math.ceil((r+9)/64),i=new Uint8Array(n);i.set(e),i[r]=128;let a=8*r;for(let e=0;e<8;e+=1)i[n-1-e]=255&Math.floor(a/2**(8*e));let l=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],o=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=o(r[e-2],17)^o(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[n,a,s,d,f,u,c,y]=l;for(let e=0;e<64;e+=1){let i=y+(o(f,6)^o(f,11)^o(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,l=(o(n,2)^o(n,13)^o(n,22))+(n&a^n&s^a&s)>>>0;y=c,c=u,u=f,f=d+i>>>0,d=s,s=a,a=n,n=i+l>>>0}for(let[e,t]of[n,a,s,d,f,u,c,y].entries())l[e]=l[e]+t>>>0}return l.map(e=>e.toString(16).padStart(8,"0")).join("")}(l);if(l.length!==r.byteLength||o!==r.sha256)throw Error("DEVUP_LARGE_VALUE_CHANGED");let s=Math.max(0,Math.floor(Number(r.offset)||0)),d=Math.min(65536,Math.max(1,Math.floor(Number(r.maxChunkBytes)||8192)));if(s>=l.length)throw Error("DEVUP_LARGE_VALUE_RANGE_INVALID");let f=Math.min(l.length,s+d);return{kind:"devupLargeValueFragment",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,offset:s,nextOffset:f,byteLength:l.length,sha256:o,dataBase64:function(e){let t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",r="";for(let n=0;n>2],r+=t[(3&i)<<4|a>>4],r+=n+1>6]:"=",r+=n+20;){let e=t[t.length-1],i=e.node,a="children"in i?i.children:[];if(!e.visited){e.visited=!0;for(let e=a.length-1;e>=0;e-=1)t.push({node:a[e],visited:!1});continue}t.pop();let l=0,o=[];for(let e of a)o.push(e.id),l+=1+(r.get(e.id)||0);r.set(i.id,l),n.push({id:i.id,type:i.type,name:i.name,childrenIds:o,descendantCount:l})}}(t),{fileKey:figma.fileKey||"",version:null,rootId:t.id,nodes:n}},pageCatalog:async function(e){let t=figma.root.children;return{fileKey:figma.fileKey||"",version:null,rootIds:t.map(e=>e.id),nodes:t.map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:null,childrenIds:[]},extra:{},fieldErrors:{}})),diagnostics:[]}},search:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=t;for(;r&&"PAGE"!==r.type;)r=r.parent;if(!r||"PAGE"!==r.type)throw Error("DEVUP_PAGE_NOT_FOUND");await figma.setCurrentPageAsync(r);let n=e.search,i=new Set((n.nodeTypes.length?n.nodeTypes:["PAGE","SECTION","FRAME","COMPONENT_SET","COMPONENT"]).map(e=>e.toUpperCase()));function a(e){return e.normalize("NFC").toLocaleLowerCase().replace(/\s/gu,"")}let l=t===r?r.findAll(()=>!0):"findAll"in t?t.findAll(()=>!0):[],o=[t,...l].filter(e=>i.has(e.type)&&"string"==typeof e.name).map(e=>({node:e,score:function(e){if(e===n.query)return 400;if("exact"===n.matchKind)return null;let t=a(e),r=a(n.query);if(t===r)return 300;if(t.startsWith(r))return 200;if(t.includes(r))return 100;if("fuzzy"!==n.matchKind)return null;let i=function(e,t){let r=Array.from({length:t.length+1},(e,t)=>t);for(let n=0;nnull!==e.score).sort((e,t)=>t.score-e.score||e.node.name.localeCompare(t.node.name)||e.node.id.localeCompare(t.node.id)).slice(0,n.limit),s=new Map([[r.id,r]]);for(let e of[t,...o.map(e=>e.node)]){let t=e;for(;t&&"DOCUMENT"!==t.type;)s.set(t.id,t),t=t.parent}return{fileKey:figma.fileKey||"",version:null,rootIds:[r.id],nodes:[...s.values()].map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:"children"in e?e.children.map(e=>e.id):[]},extra:{},fieldErrors:{}})),diagnostics:[]}},sectionIndex:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");if("SECTION"!==t.type){let e=t.parent,r=new Set;for(;e&&"SECTION"!==e.type&&!r.has(e.id);)r.add(e.id),e=e.parent;let n=e&&"SECTION"===e.type?e.id:null,i=n&&figma.fileKey?`https://www.figma.com/design/${figma.fileKey}?node-id=${n.replace(/:/g,"-")}`:null;throw Error("DEVUP_SECTION_REQUIRED "+JSON.stringify({pluginCode:"DEVUP_SECTION_REQUIRED",stage:"section-index",nodeId:t.id,nodeType:t.type,sectionId:n,nextAction:{tool:"devup_figma_export",how:n?`The url must point to a SECTION. This capture's SECTION is ${n}; select frames with frameIds.`:"The url must point to a SECTION. This node has no ancestor SECTION; choose the intended SECTION in Figma and copy its link.",arguments:i?"FRAME"===t.type&&t.parent===e?{url:i,frameIds:[t.id]}:{url:i}:null,requiredArguments:i?[]:["url"]}}))}let r=2048;function n(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function i(e,t){if("FRAME"!==e.type||!1===e.visible||!t)return!1;let r=t.width/Math.max(1,t.height);return t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&r>=.25&&r<=2.5}function a(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1[e.id,t.id])),s=[],d=0;for(let e=0;ee.id));for(let r of t.children){if(e.has(r.id))continue;let t=n(r);t&&!1!==r.visible&&s.push({node:r,box:t})}}s.sort((e,t)=>e.box.y-t.box.y||e.box.x-t.box.x||e.node.id.localeCompare(t.node.id));let f=l.length>d||s.length>100,u=s.slice(0,100),c=new Set(u.map(({node:e})=>e.id)),y=new Set(e.rootIds),h={},g=new Map([[t.id,null]]);for(let e=0;e{let r=e.parent;for(;r&&r.id!==t.id&&!c.has(r.id);)r=r.parent;return[e.id,r&&c.has(r.id)?r.id:t.id]})),m=e=>u.filter(({node:t})=>p.get(t.id)===e).map(({node:e})=>e.id),b=n(t);if(!b)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let x={id:t.id,type:t.type,fields:{name:t.name,parentId:t.parent&&"DOCUMENT"!==t.parent.type?t.parent.id:null,childrenIds:m(t.id),absoluteBoundingBox:b,visible:!1!==t.visible,projectionTruncated:f,nodeScreenIds:h},extra:{},fieldErrors:{}},E=u.length,I=u.map(({node:e,box:t})=>{let n=function(e){let t=[e],r=0,n=0;for(let e=0;er}}(e),l=Math.floor(r/E);E-=1;let o=function(e,t){let r=[e],n="",i=0,l=0,o=!1;if(t<=0)return{text:"",state:"budget-exhausted",spent:0};for(let e=0;e=120||l+e>t)return{text:n.trimEnd(),state:l+e>t?"budget-exhausted":"truncated",spent:l};n+=r,i+=1,l+=e}}if("children"in s){let e=Math.max(0,64-r.length);s.children.length>e&&(o=!0),r.push(...s.children.slice(0,e))}}}return{text:n,state:o?"truncated":n?"available":"no-text",spent:l}}(e,l);return r-=o.spent,{id:e.id,type:e.type,fields:{name:"string"==typeof e.name?e.name:"",parentId:p.get(e.id),childrenIds:m(e.id),absoluteBoundingBox:t,visible:!1!==e.visible,breadcrumb:function(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type;)"string"==typeof r.name&&r.name&&t.push(r.name),r=r.parent;return t.reverse()}(e),directChildCount:"children"in e?e.children.length:0,textPreview:o.text,textPreviewState:o.state,subtreeNodeCount:n.subtreeNodeCount,estimatedSerializedBytes:n.estimatedSerializedBytes,selectionReasons:[i(e,t)?"screen-like":"explicit-selection-only","inside-section"],estimateTruncated:n.truncated},extra:{},fieldErrors:{}}}),S={fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:[x,...I],diagnostics:[]},A=a(JSON.stringify(S));for(let e=I.length-1;e>=0&&A>19456;e-=1)I[e].fields.textPreview="",I[e].fields.textPreviewState="budget-exhausted",A=a(JSON.stringify(S));let N=()=>S.nodes.length-1,w=I.length;for(;A>19456&&N()>1;){let e=S.nodes.pop();x.fields.projectionTruncated=!0,x.fields.childrenIds=x.fields.childrenIds.filter(t=>t!==e.id),A=a(JSON.stringify(S))}if(A>19456)throw Error("DEVUP_SECTION_INDEX_TOO_LARGE "+JSON.stringify({pluginCode:"DEVUP_SECTION_INDEX_TOO_LARGE",stage:"section-index",responseBytes:A,maxResponseBytes:19456,discoveredCandidates:w,retainedCandidates:N()}));return S},snapshot:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],n=new Set(r),i=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],a=e.snapshot,l=Math.max(0,Math.floor(Number(a.offset)||0)),o=Math.min(a.maxEnvelopeBytes?Math.max(8192,Math.floor(Number(a.maxEnvelopeBytes))):16e3,Math.max(4096,Math.floor(Number(a.maxPayloadBytes)||15e3))),s=Math.min(o-1024,Math.max(512,Math.floor(Number(a.maxFieldBytes)||4096))),d=new Set(["id","type","parent","children"]),f=new Set(["parentId","childrenIds","name","characters","styledTextSegments","boundVariables"]);function u(e,t,r){let n=function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(JSON.stringify(r));return n.length>0x1000000?{$truncated:"max-large-value-bytes",byteLength:n.length}:{$largeValue:{nodeId:e,field:t,byteLength:n.length,sha256:function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],l=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=l(r[e-2],17)^l(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,o,s,d,f,u,c,y]=a;for(let e=0;e<64;e+=1){let n=y+(l(f,6)^l(f,11)^l(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,a=(l(i,2)^l(i,13)^l(i,22))+(i&o^i&s^o&s)>>>0;y=c,c=u,u=f,f=d+n>>>0,d=s,s=o,o=i,i=n+a>>>0}for(let[e,t]of[i,o,s,d,f,u,c,y].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}(n),cursor:{nextOffset:0,maxChunkBytes:12288}}}}function c(e){return function(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+112)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let l=e(t[a],r,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(r),a=c(i);if(a<=s)return i;let l=u(e,t,i);return l.$truncated&&(n[t]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${a}>16777216`),l}let h=[],g=[t];for(;g.length;){let e=g.shift();h.push(e),"children"in e&&g.push(...e.children)}let p=[],m=o-1024,b=2;for(let e=l;eNumber(e.protected)-Number(t.protected)||t.byteLength-e.byteLength),n)){if(r<=t)break;let n=u(e.id,i.name,e[i.sectionName][i.name]);e[i.sectionName][i.name]=n,n.$truncated&&(e.fieldErrors[i.name]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${i.byteLength}>16777216`),r=c(e)}return e}(function(e){let t={},a={},l={};for(let i of(t.parentId=e.parent?e.parent.id:null,e.parent&&("PAGE"===e.parent.type||"SECTION"===e.parent.type||"COMPONENT_SET"===e.parent.type)&&(t.parentType=e.parent.type,"SECTION"===e.parent.type&&(t.parentName=e.parent.name)),t.childrenIds="children"in e?e.children.map(e=>e.id):[],!("overflowDirection"in e)&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t.overflowDirection=null),function(e){let t=new Set,n=e;for(;n&&n!==Object.prototype;){for(let e of Object.getOwnPropertyNames(n))t.add(e);n=Object.getPrototypeOf(n)}for(let n of r)try{n in e&&t.add(n)}catch(e){}return[...t].sort()}(e)))if(!(d.has(i)||i.startsWith("_")))try{let r=e[i];if("function"==typeof r)continue;let o=y(e.id,i,r,l);(n.has(i)?t:a)[i]=o}catch(e){l[i]=String(e&&e.message?e.message:e)}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{t.styledTextSegments=y(e.id,"styledTextSegments",e.getStyledTextSegments(i),l)}catch(e){l.styledTextSegments=String(e&&e.message?e.message:e)}return{id:e.id,type:e.type,fields:t,extra:a,fieldErrors:l}}(h[e]),m),a=c(t)+ +!!p.length;if(p.length&&b+a>m)break;p.push(t),b+=a}let x=Math.min(h.length,l+p.length);return p.push({id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:l,nextOffset:x,complete:x>=h.length,totalNodes:h.length},extra:{},fieldErrors:{}}),{fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:p,diagnostics:[]}},usedResources:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),l=e;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!(l.startsWith("_")||["parent","children","consumers"].includes(l)))try{let a=r(e[l],t,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:"unavailable"}}return t.delete(e),i}let n=new Map,i="ok";try{for(let e of(await figma.variables.getLocalVariablesAsync()))n.set(e.id,e)}catch(e){i="unavailable"}let a=await Promise.all(t.variableIds.map(async e=>{let t=n.get(e);if(t)return{value:r(t),collectionId:t.variableCollectionId};try{let t=await figma.variables.getVariableByIdAsync(e);return t?{value:r(t),collectionId:t.variableCollectionId}:{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupEmpty"}}}catch(t){return{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupThrew"}}}})),l=[...new Set(a.flatMap(e=>e.collectionId?[e.collectionId]:[]))].sort(),o=await Promise.all(l.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?[r(t)]:[]}catch(e){return[]}})),s=await Promise.all(t.styles.map(async e=>{try{let t=await figma.getStyleByIdAsync(e.id);if(!t)return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable"}};return{value:{...r(t),styleType:e.styleType,value:r("PAINT"===e.styleType?t.paints:"EFFECT"===e.styleType?t.effects:"GRID"===e.styleType?t.layoutGrids:t)}}}catch(t){return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable"}}}}));return{collections:o.flat(),variables:a.flatMap(e=>e.value?[e.value]:[]),styles:s.flatMap(e=>e.value?[e.value]:[]),usedVariableIds:t.variableIds,usedStyleIds:t.styles.map(e=>e.id),localVariableListing:i,localVariableCount:n.size,unresolved:[...a,...s].flatMap(e=>e.unresolved?[e.unresolved]:[])}},variableCatalog:async function(e){let[t,r,n,i,a]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),l=["PAINT","TEXT","EFFECT","GRID"];return{collections:t.map(e=>(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(r.has(t))return{$circular:!0};r.add(t);let i={},a=new Set(Object.keys(t)),l=t;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!l.startsWith("_"))try{let a=e(t[l],r,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i})(e)),variableIds:[...new Set(t.flatMap(e=>e.variableIds))].sort(),styles:[r,n,i,a].flatMap((e,t)=>e.map(e=>({id:e.id,styleType:l[t]}))).sort((e,t)=>e.id.localeCompare(t.id)),localComplete:!0,usedRemoteComplete:!1}},variables:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),l=e;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!(l.startsWith("_")||["parent","children","consumers"].includes(l)))try{let a=r(e[l],t,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:String(e&&e.message?e.message:e)}}return t.delete(e),i}let[n,i]=await Promise.all([Promise.all(t.variableIds.map(e=>figma.variables.getVariableByIdAsync(e))),Promise.all(t.styles.map(e=>figma.getStyleByIdAsync(e.id)))]),a=new Map(t.styles.map(e=>[e.id,e])),l=await Promise.all(i.filter(Boolean).map(async e=>{let t=a.get(e.id),n=t.styleType;if(Number.isInteger(t.consumerStart)&&Number.isInteger(t.consumerEnd)){let i=await e.getStyleConsumersAsync();return{id:e.id,styleType:n,$consumerStart:t.consumerStart,$consumerEntries:i.slice(t.consumerStart,t.consumerEnd).map(e=>[e.node.id,e.node.type,r(e.fields)])}}let i=await e.getStyleConsumersAsync();return{...r(e),styleType:n,$consumerCount:i.length,value:r("PAINT"===n?e.paints:"EFFECT"===n?e.effects:"GRID"===n?e.layoutGrids:e)}}));return{variables:n.filter(Boolean).map(e=>r(e)),styles:l}}};async function t(t){let r=e[t.script];if(!r)return{kind:"devup-result",requestId:t.requestId,error:`DEVUP_BRIDGE_UNKNOWN_SCRIPT: ${t.script} (아는 스크립트: ${Object.keys(e).join(", ")})`};try{var n;let e=await r({nodeId:(n=t.params).nodeId??"",rootIds:n.rootIds??[],snapshot:n.snapshot??{},search:n.search??{},explore:n.explore??{},resources:n.resources??{variableIds:[],styles:[]},largeValue:n.largeValue??{},asset:n.asset??{},theme:n.theme??{offset:0}});return{kind:"devup-result",requestId:t.requestId,data:e}}catch(e){return{kind:"devup-result",requestId:t.requestId,error:e instanceof Error?e.message:String(e)}}}let r=[],n=!1;async function i(){if(!n){n=!0;try{for(;r.length>0;){let e=r.shift(),n=await t(e).catch(t=>({kind:"devup-result",requestId:e.requestId,error:t instanceof Error?t.message:String(t)}));figma.ui.postMessage(n)}}finally{n=!1}}}figma.showUI(__html__,{width:320,height:220}),figma.ui.onmessage=e=>{if("object"==typeof e&&null!==e){if("devup-ready"===e.kind){let e={kind:"devup-status",fileKey:figma.fileKey??null,fileName:figma.root.name,port:1993};figma.ui.postMessage(e);return}"devup-job"===e.kind&&(r.push(e),i())}}})(); \ No newline at end of file