From 458a515cbed7fbcf127ce72348ff755c5308ce13 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 10 Sep 2026 15:23:54 -0700 Subject: [PATCH 01/21] feat: add deterministic visual-change detection for PRs --- .github/workflows/design-review.yml | 94 +++++ bun.lock | 63 ++- design-diff.config.json | 85 ++++ package.json | 18 +- scripts/check-script-test-coverage.ts | 14 +- scripts/design-diff/README.md | 204 ++++++++++ scripts/design-diff/analyze.ts | 301 +++++++++++++++ scripts/design-diff/ast.ts | 115 ++++++ scripts/design-diff/cli.ts | 65 ++++ scripts/design-diff/compare.ts | 114 ++++++ scripts/design-diff/extract/assets.ts | 18 + scripts/design-diff/extract/css.ts | 74 ++++ scripts/design-diff/extract/documents.ts | 132 +++++++ scripts/design-diff/extract/index.ts | 4 + scripts/design-diff/extract/tsx.ts | 293 ++++++++++++++ scripts/design-diff/git.ts | 121 ++++++ scripts/design-diff/index.ts | 3 + scripts/design-diff/movement.ts | 103 +++++ scripts/design-diff/policy.ts | 86 +++++ scripts/design-diff/resolve.ts | 364 ++++++++++++++++++ scripts/design-diff/source.ts | 249 ++++++++++++ scripts/design-diff/tailwind.ts | 213 ++++++++++ scripts/design-diff/tests/analyze.test.ts | 145 +++++++ scripts/design-diff/tests/extract.test.ts | 77 ++++ .../tests/fixtures/visual-cases.json | 62 +++ scripts/design-diff/tests/git.test.ts | 71 ++++ scripts/design-diff/tests/helpers.ts | 55 +++ scripts/design-diff/tests/movement.test.ts | 47 +++ scripts/design-diff/tests/resolve.test.ts | 147 +++++++ scripts/design-diff/tests/security.test.ts | 90 +++++ scripts/design-diff/tests/tailwind.test.ts | 74 ++++ scripts/design-diff/tsconfig.json | 10 + scripts/design-diff/types.ts | 90 +++++ vitest.scripts.config.ts | 2 +- 34 files changed, 3575 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/design-review.yml create mode 100644 design-diff.config.json create mode 100644 scripts/design-diff/README.md create mode 100644 scripts/design-diff/analyze.ts create mode 100644 scripts/design-diff/ast.ts create mode 100644 scripts/design-diff/cli.ts create mode 100644 scripts/design-diff/compare.ts create mode 100644 scripts/design-diff/extract/assets.ts create mode 100644 scripts/design-diff/extract/css.ts create mode 100644 scripts/design-diff/extract/documents.ts create mode 100644 scripts/design-diff/extract/index.ts create mode 100644 scripts/design-diff/extract/tsx.ts create mode 100644 scripts/design-diff/git.ts create mode 100644 scripts/design-diff/index.ts create mode 100644 scripts/design-diff/movement.ts create mode 100644 scripts/design-diff/policy.ts create mode 100644 scripts/design-diff/resolve.ts create mode 100644 scripts/design-diff/source.ts create mode 100644 scripts/design-diff/tailwind.ts create mode 100644 scripts/design-diff/tests/analyze.test.ts create mode 100644 scripts/design-diff/tests/extract.test.ts create mode 100644 scripts/design-diff/tests/fixtures/visual-cases.json create mode 100644 scripts/design-diff/tests/git.test.ts create mode 100644 scripts/design-diff/tests/helpers.ts create mode 100644 scripts/design-diff/tests/movement.test.ts create mode 100644 scripts/design-diff/tests/resolve.test.ts create mode 100644 scripts/design-diff/tests/security.test.ts create mode 100644 scripts/design-diff/tests/tailwind.test.ts create mode 100644 scripts/design-diff/tsconfig.json create mode 100644 scripts/design-diff/types.ts diff --git a/.github/workflows/design-review.yml b/.github/workflows/design-review.yml new file mode 100644 index 00000000000..6ab2ab7454b --- /dev/null +++ b/.github/workflows/design-review.yml @@ -0,0 +1,94 @@ +name: Design diff (advisory) + +on: + pull_request_target: + types: [opened, reopened, synchronize, edited, ready_for_review] + branches: [staging] + workflow_dispatch: + inputs: + pull_request: + description: Open PR number targeting staging + required: true + type: number + +permissions: + contents: read + pull-requests: read + +concurrency: + group: design-diff-${{ github.event.pull_request.number || inputs.pull_request }} + cancel-in-progress: true + +jobs: + analyze: + if: github.event_name == 'pull_request_target' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + steps: + - name: Resolve immutable trusted engine and PR revisions + id: revisions + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + REQUESTED_PR: ${{ inputs.pull_request }} + with: + script: | + const { owner, repo } = context.repo; + const number = context.payload.pull_request?.number ?? Number(process.env.REQUESTED_PR); + if (!Number.isSafeInteger(number) || number <= 0) throw new Error('Invalid PR number'); + const pr = context.payload.pull_request ?? (await github.rest.pulls.get({ owner, repo, pull_number: number })).data; + if (pr.state !== 'open' || pr.base.ref !== 'staging' || pr.base.repo.full_name !== `${owner}/${repo}`) { + throw new Error('Expected an open PR targeting this repository staging branch'); + } + const repository = (await github.rest.repos.get({ owner, repo })).data; + const engine = (await github.rest.repos.getCommit({ owner, repo, ref: repository.default_branch })).data.sha; + for (const sha of [engine, pr.base.sha, pr.head.sha]) { + if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error('Invalid commit identity'); + } + core.setOutput('engine', engine); + core.setOutput('base', pr.base.sha); + core.setOutput('head', pr.head.sha); + core.setOutput('pr', String(number)); + + - name: Checkout trusted engine only + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ steps.revisions.outputs.engine }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.1 + + - name: Install trusted dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Fetch PR source as Git objects + env: + BASE_SHA: ${{ steps.revisions.outputs.base }} + HEAD_SHA: ${{ steps.revisions.outputs.head }} + GH_TOKEN: ${{ github.token }} + run: | + AUTH_HEADER="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')" + git -c "http.extraheader=AUTHORIZATION: basic $AUTH_HEADER" fetch --no-tags origin "$BASE_SHA" "$HEAD_SHA" >/dev/null 2>&1 + test "$(git rev-parse --verify "$BASE_SHA^{commit}")" = "$BASE_SHA" + test "$(git rev-parse --verify "$HEAD_SHA^{commit}")" = "$HEAD_SHA" + + - name: Analyze source + env: + BASE_SHA: ${{ steps.revisions.outputs.base }} + HEAD_SHA: ${{ steps.revisions.outputs.head }} + DESIGN_DIFF_PR: ${{ steps.revisions.outputs.pr }} + DESIGN_DIFF_ENGINE_SHA: ${{ steps.revisions.outputs.engine }} + REPORT_PATH: ${{ runner.temp }}/design-diff-${{ steps.revisions.outputs.pr }}-${{ steps.revisions.outputs.head }}.json + run: bun run design:diff --base "$BASE_SHA" --head "$HEAD_SHA" --output "$REPORT_PATH" > /dev/null 2>&1 + + - name: Retain JSON report + if: ${{ always() && steps.revisions.outcome == 'success' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: design-diff-${{ steps.revisions.outputs.pr }}-${{ steps.revisions.outputs.head }} + path: ${{ runner.temp }}/design-diff-${{ steps.revisions.outputs.pr }}-${{ steps.revisions.outputs.head }}.json + retention-days: 7 + if-no-files-found: error diff --git a/bun.lock b/bun.lock index 3b63e4d5571..df794023289 100644 --- a/bun.lock +++ b/bun.lock @@ -6,9 +6,13 @@ "name": "simstudio", "devDependencies": { "@babel/parser": "7.29.2", + "@babel/traverse": "7.29.0", + "@babel/types": "7.29.7", "@biomejs/biome": "2.0.6", "@octokit/rest": "^21.0.0", "@sim/utils": "workspace:*", + "@types/babel__traverse": "7.28.0", + "@types/node": "24.2.1", "@types/opentype.js": "1.3.10", "@typescript/native": "npm:typescript@^7.0.2", "@typescript/typescript6": "^6.0.2", @@ -21,12 +25,19 @@ "gray-matter": "4.0.3", "husky": "9.1.7", "json-schema-to-typescript": "15.0.4", + "jsonc-parser": "3.3.1", "lint-staged": "16.0.0", "opentype.js": "1.3.4", + "parse5": "7.3.0", + "postcss": "8.5.26", "react": "19.2.4", + "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.1", + "remark-mdx": "3.1.1", "remark-parse": "11.0.0", "sharp": "0.35.4", + "tailwind-merge": "3.6.0", + "tailwindcss": "4.3.3", "turbo": "2.9.14", "unified": "11.0.5", "unist-util-visit": "5.1.0", @@ -1982,8 +1993,6 @@ "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], - "@smithy/hash-node": ["@smithy/hash-node@4.4.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-MkyiJfdnDlBdmq26Cxskw2dtX6V/EgTjCriPc7Gq0084hncjIFVJ26IwHpauXJT2w79B4umF0erKi4epBR/WDA=="], - "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@smithy/middleware-compression": ["@smithy/middleware-compression@4.6.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "fflate": "0.8.3", "tslib": "^2.6.2" } }, "sha512-Q9d+luiRjyHT6kCL/9NyGpdZJgodh4vtvfHC6H8SqoVKrV2k9RoyI9/IloVfdCYks3/2DIi7BsYYLcda5KZS0A=="], @@ -2158,6 +2167,8 @@ "@types/archiver": ["@types/archiver@8.0.0", "", { "dependencies": { "@types/node": "*", "@types/readdir-glob": "*" } }, "sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A=="], + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], "@types/buffer-from": ["@types/buffer-from@1.1.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-2lq4YC9uLUMGHkl2IDtX4tCXSo2+hwMpOJcY1qiIk1kybc31rIlPyM1HCVJhkPFIo75a/pOVxqyvwuf5TpCG/w=="], @@ -3004,7 +3015,7 @@ "ensure-posix-path": ["ensure-posix-path@1.1.1", "", {}, "sha512-VWU0/zXzVbeJNXvME/5EmLuEj2TauvoaTz6aFYK1Z92JCBlDlZ3Gu0tuGR42kpW1754ywTs+QB0g5TP0oj9Zaw=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -3038,7 +3049,7 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], @@ -3130,6 +3141,8 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -3160,6 +3173,8 @@ "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], @@ -3452,7 +3467,7 @@ "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], - "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -3480,6 +3495,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "jsonpath-plus": ["jsonpath-plus@10.4.0", "", { "dependencies": { "@jsep-plugin/assignment": "^1.3.0", "@jsep-plugin/regex": "^1.0.4", "jsep": "^1.4.0" }, "bin": { "jsonpath": "bin/jsonpath-cli.js", "jsonpath-plus": "bin/jsonpath-cli.js" } }, "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA=="], @@ -3606,7 +3623,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="], + "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -3638,6 +3655,8 @@ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], @@ -3686,6 +3705,8 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], @@ -4198,6 +4219,8 @@ "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], + "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], @@ -4798,8 +4821,6 @@ "@azure/arm-containerinstance/@azure/abort-controller": ["@azure/abort-controller@1.1.0", "", { "dependencies": { "tslib": "^2.2.0" } }, "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw=="], - "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], "@babel/template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], @@ -4980,6 +5001,8 @@ "@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="], + "@react-email/render/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "@react-email/tailwind/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "@redis/client/cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], @@ -4994,8 +5017,6 @@ "@smithy/eventstream-codec/@smithy/core": ["@smithy/core@3.25.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A=="], - "@smithy/hash-node/@smithy/core": ["@smithy/core@3.25.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A=="], - "@smithy/middleware-compression/fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "@smithy/signature-v4/@smithy/core": ["@smithy/core@3.25.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A=="], @@ -5136,6 +5157,8 @@ "artillery/socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "async-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -5166,6 +5189,10 @@ "chrome-launcher/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "chrome-launcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "clean-stack/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "cli-table3/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -5206,6 +5233,8 @@ "docx/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "dot-prop/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], @@ -5272,8 +5301,6 @@ "fumadocs-openapi/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], - "fumadocs-ui/@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA=="], "fumadocs-ui/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="], @@ -5288,8 +5315,6 @@ "fumadocs-ui/@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], - "gaxios/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], @@ -5348,15 +5373,13 @@ "log-update/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "magicast/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], "make-dir/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], "mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -5378,8 +5401,6 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "pdf-lib/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], @@ -5654,8 +5675,6 @@ "@smithy/eventstream-codec/@smithy/core/@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="], - "@smithy/hash-node/@smithy/core/@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="], - "@smithy/util-utf8/@smithy/core/@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="], "@trigger.dev/core/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], @@ -6014,6 +6033,8 @@ "gtoken/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "html-to-text/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "imapflow/pino/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], "imapflow/pino/thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], diff --git a/design-diff.config.json b/design-diff.config.json new file mode 100644 index 00000000000..6c1ec5ed38b --- /dev/null +++ b/design-diff.config.json @@ -0,0 +1,85 @@ +{ + "sourceRoots": ["apps/", "packages/"], + "exclude": [ + "(?:^|/)(?:node_modules|__tests__|__fixtures__|fixtures|test-results|\\.source|dist|build)/", + "\\.(?:test|spec)\\.[cm]?[jt]sx?$", + "(?:^|/)next-env\\.d\\.ts$" + ], + "renderedMarkdown": ["apps/docs/content/", "apps/sim/content/"], + "aliases": [ + { + "from": "apps/sim/", + "prefix": "@/", + "target": "apps/sim/" + }, + { + "from": "apps/docs/", + "prefix": "@/", + "target": "apps/docs/" + } + ], + "themes": [ + { + "roots": ["apps/sim/", "packages/emcn/", "packages/workflow-renderer/"], + "path": "apps/sim/app/_styles/globals.css" + }, + { + "roots": ["apps/docs/", "packages/emcn/", "packages/workflow-renderer/"], + "path": "apps/docs/app/global.css" + } + ], + "classFunctions": ["cn", "clsx", "classNames", "twMerge"], + "variantFunctions": ["cva"], + "nativeAppearance": [ + "backgroundColor", + "titleBarStyle", + "titleBarOverlay", + "trafficLightPosition", + "vibrancy", + "visualEffectState", + "transparent", + "opacity", + "frame", + "roundedCorners", + "backgroundMaterial", + "width", + "height", + "minWidth", + "minHeight", + "maxWidth", + "maxHeight", + "resizable", + "fullscreen", + "autoHideMenuBar", + "icon" + ], + "infrastructure": [ + "(?:^|/)(?:tailwind|postcss|next|vite|electron-vite|source)\\.config\\.", + "(?:^|/)mdx-components\\.", + "(?:^|/)lib/postcss/", + "(?:^|/)lib/cn\\.ts$", + "(?:^|/)tsconfig[^/]*\\.json$" + ], + "renderingDependencies": "^(?:react(?:-dom)?|next|tailwindcss|tailwind-merge|clsx|class-variance-authority|postcss|electron|framer-motion|motion|tw-animate-css|@tailwindcss/|@radix-ui/|@react-email/|fumadocs|@mdx-js/|remark-|rehype-|lucide)", + "limits": { + "fileBytes": 2097152, + "totalBytes": 268435456, + "resolutionDepth": 24, + "resolutionSteps": 5000 + }, + "nativeRendering": [ + "apps/desktop/src/main/terminal-themes.ts", + "apps/desktop/src/main/context-menu.ts", + "apps/desktop/src/main/tray.ts" + ], + "classModules": [ + "clsx", + "classnames", + "tailwind-merge", + "@sim/emcn/lib/cn", + "@/lib/utils", + "@/lib/cn" + ], + "variantModules": ["class-variance-authority"], + "mergeFontSizes": ["micro", "caption", "small", "md"] +} diff --git a/package.json b/package.json index 080c1582317..350419d5782 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,9 @@ "test:workflow-sync": "bun --no-env-file scripts/test-workflow-sync.ts", "type-check": "turbo run type-check", "release": "bun run scripts/create-single-release.ts", - "test:scripts": "vitest run --config vitest.scripts.config.ts" + "test:scripts": "vitest run --config vitest.scripts.config.ts", + "design:diff": "bun --no-env-file scripts/design-diff/cli.ts", + "check:design-diff-types": "tsc --noEmit --project scripts/design-diff/tsconfig.json" }, "overrides": { "react": "19.2.4", @@ -150,9 +152,13 @@ }, "devDependencies": { "@babel/parser": "7.29.2", + "@babel/traverse": "7.29.0", + "@babel/types": "7.29.7", "@biomejs/biome": "2.0.6", "@octokit/rest": "^21.0.0", "@sim/utils": "workspace:*", + "@types/babel__traverse": "7.28.0", + "@types/node": "24.2.1", "@types/opentype.js": "1.3.10", "@typescript/native": "npm:typescript@^7.0.2", "@typescript/typescript6": "^6.0.2", @@ -165,12 +171,19 @@ "gray-matter": "4.0.3", "husky": "9.1.7", "json-schema-to-typescript": "15.0.4", + "jsonc-parser": "3.3.1", "lint-staged": "16.0.0", "opentype.js": "1.3.4", + "parse5": "7.3.0", + "postcss": "8.5.26", "react": "19.2.4", + "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.1", + "remark-mdx": "3.1.1", "remark-parse": "11.0.0", "sharp": "0.35.4", + "tailwind-merge": "3.6.0", + "tailwindcss": "4.3.3", "turbo": "2.9.14", "unified": "11.0.5", "unist-util-visit": "5.1.0", @@ -189,5 +202,8 @@ "patchedDependencies": { "@better-auth/oauth-provider@1.6.27": "patches/@better-auth%2Foauth-provider@1.6.27.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch" + }, + "imports": { + "#design-diff/*": "./scripts/design-diff/*.ts" } } diff --git a/scripts/check-script-test-coverage.ts b/scripts/check-script-test-coverage.ts index 87dc148c458..57cee5798fb 100644 --- a/scripts/check-script-test-coverage.ts +++ b/scripts/check-script-test-coverage.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * Asserts every `scripts/*.test.ts` file is collected by the root Vitest config. + * Asserts every root script test and nested design-diff test is collected by the root Vitest config. * * The root `test` script once chained a hand-maintained list of `test:*` entries, and a * hand-maintained list silently drifts from the files on disk: a test added without a matching @@ -57,10 +57,14 @@ const collected = new Set( ) ) -const onDisk = readdirSync(path.join(ROOT, 'scripts')) - .filter((file) => file.endsWith('.test.ts')) - .map((file) => `scripts/${file}`) - .sort() +const onDisk = [ + ...readdirSync(path.join(ROOT, 'scripts')) + .filter((file) => file.endsWith('.test.ts')) + .map((file) => `scripts/${file}`), + ...readdirSync(path.join(ROOT, 'scripts/design-diff/tests'), { recursive: true }) + .filter((file): file is string => typeof file === 'string' && file.endsWith('.test.ts')) + .map((file) => `scripts/design-diff/tests/${file.split(path.sep).join('/')}`), +].sort() const orphaned = onDisk.filter((file) => !collected.has(file)) if (orphaned.length > 0) { diff --git a/scripts/design-diff/README.md b/scripts/design-diff/README.md new file mode 100644 index 00000000000..6c46e2604c1 --- /dev/null +++ b/scripts/design-diff/README.md @@ -0,0 +1,204 @@ +# Design diff + +An advisory, deterministic source analyzer for PRs targeting `staging`. It flags visual +changes and requests review when a potentially visual effect cannot be resolved statically. +It does not run an application, a browser, AI, proposed configuration or proposed plugins. + +```sh +bun run design:diff --base origin/staging --head HEAD --output /tmp/design-diff.json +``` + +Use Bun **1.4.1** and a complete Git history. Both revisions are resolved to immutable +commits; the actual comparison is `merge-base(base, head)..head`. Local uncommitted files +are not analyzed. Omitting `--output` writes JSON to stdout. With `--output`, the report is +written through a temporary file and atomically renamed. + +| Result | JSON | Exit status | +| --- | --- | --- | +| Completed, no visual findings | `status: completed`, `flagged: false` | 0 | +| Completed, `flag` or `review` findings | `status: completed`, `flagged: true` | 0 | +| Missing revision/history, unreadable objects, invalid invocation or resource failure | `status: failed`, `flagged: null` | Nonzero | + +Completed analyses can contain `exempt` movement evidence. Findings have stable IDs, +before/after values, properties, conditions, source locations, symbols, consumers, +dependencies and limitations. Unsupported expressions use syntax fingerprints rather than +duplicating entire function bodies. Reports also include schema, engine and policy versions, +base/head/merge-base SHAs, and workflow PR/head/engine identity. No timestamp enters the +deterministic payload. Finding IDs are stable for the same input pair and engine version. + +## Architecture + +```mermaid +flowchart LR + Revisions[Base and head revisions] --> Git[Immutable Git objects] + Git --> Graph[Dependency graphs in both revisions] + Graph --> Extract[Parse affected visual sources] + Extract --> Resolve[Bounded static resolution] + Resolve --> Compare[Compare values and conditions] + Compare --> Policy[Visual policy and movement proof] + Policy --> JSON[JSON report] +``` + +```text +design-diff.config.json Repository scope and recognized conventions +.github/workflows/design-review.yml Trusted cloud execution, artifact only +scripts/design-diff/ + index.ts Small public engine API + cli.ts Arguments, output and operational status + analyze.ts Revision comparison and affected consumers + git.ts Git object reads, merge-base and renames + source.ts Source snapshots, imports and aliases + ast.ts Babel parsing and syntax normalization + resolve.ts Bounded expression and import resolution + tailwind.ts Pinned compiler and trusted merge convention + compare.ts Stable matching and findings + policy.ts Visual categories and limitations + movement.ts Narrow static movement proof + types.ts Versioned report contract + extract/{tsx,css,documents,assets}.ts Syntax-specific extraction + tests/ Unit/integration suites and JSON fixture text + tsconfig.json Isolated engine type check +``` + +This is repository automation, not a published package. Internal imports use the root +`#design-diff/*` mapping. The revision-dependent command is deliberately outside the +generic audit runner; the zero-argument engine type check is included in audits. + +## Coverage and decisions + +Scope includes source under `apps/` and `packages/`: product and landing UI, emails, +documentation, desktop renderers, EMCN, shared workflow rendering, themes and visual assets. +Rendered Markdown is scoped explicitly to application content directories. Tests and fixture +directories are excluded. Fixture source is kept in JSON or test strings, never production +TSX/CSS files that an application build or Tailwind source scan could consume. + +| Category | Examples | Decision | +| --- | --- | --- | +| Colour | Foreground, background, fill, gradients | Flag | +| Dimensions | Width, height, padding, min/max size | Flag | +| Typography | Font, size, weight, line height, tracking | Flag | +| Shape/effects | Radius, border, shadow, opacity, filters | Flag | +| Layout | Wrapping, flex/grid sizing, stretching | Flag | +| Visibility | Hidden state, overflow, clipping, layering | Flag | +| Content | Visible copy, JSX/HTML/MDX structure, images, SVG, fonts | Flag | +| Motion | Keyframes, transitions, animation props | Flag or review when runtime-dependent | +| Infrastructure | Renderer dependencies, lockfile, CSS processors, module mappings | Review | +| Movement | Coordinates, translation, margins, gaps, alignment | Review unless the static proof succeeds | +| Nonvisual/equivalent | Comments, erased types, supported formatting and constant extraction | No finding | + +Babel parses JS/TS/JSX; PostCSS parses CSS; parse5 parses HTML; remark parses +Markdown/MDX/frontmatter/GFM. CSS selector, conditional and declaration order are retained. +JSX whitespace follows React's line handling. Class composition and JSX spread/attribute +order remain significant. Direct event handlers are not treated as appearance props; this +does not establish equivalence of arbitrary interactive behavior. + +The resolver supports immutable constants, object properties, arrays, primitive template +strings, simple arithmetic, conditional branches, static imports/re-exports, namespace +imports, workspace exports and project `paths` aliases. It records CVA bases, variants, +defaults, compound variants and selections; runtime selections remain symbolic. Recognized +`cn`/`clsx` helpers are interpreted as data. The trusted EMCN `cn` merge convention includes +the repository's custom font-size groups. A helper with an unrecognized origin is not trusted +because its name happens to be `cn` or `clsx`. + +Tailwind **4.3.3** and **tailwind-merge 3.6.0** are direct pinned dependencies. The compiler +reads declarative theme/custom-variant/utility CSS from each revision, starting with its own +pinned default theme. It preserves alternatives for CSS custom properties instead of assuming +which selector/media condition wins at runtime. Theme changes revisit unchanged consumers in +the configured applications; shared EMCN/renderer classes are considered against both themes. +The pinned `__unstable__loadDesignSystem` API is intentionally isolated in `tailwind.ts` and +covered by tests; upgrading Tailwind requires validating this adapter. + +Application JavaScript configs and plugins are never evaluated. External stylesheet packages +are not expanded. The engine identifies unsupported classes and changed rendering +infrastructure for review. Compiler output is static core-utility evidence, not a claim that +application plugins or every postprocessor have been reproduced. + +Desktop support extracts recognized `BrowserWindow` appearance options, native appearance +setter calls and `nativeTheme.themeSource` assignments. Configured native +menu/tray/terminal-theme modules use a review fallback, including their changed dependencies, +because embedded JXA and native operating-system rendering are not executed. + +## Movement proof and remaining limits + +The initial exemption is deliberately narrow: one statically sized `rect` or `circle`, as +the only child of a fixed SVG canvas, changes numeric coordinates while remaining strictly +inside its unchanged viewport. Its geometry, fill and canvas stay unchanged. Styling hooks, +effects, dynamic conditions, nested JSX canvases and potentially overriding repository CSS +disable the exemption. A shape crossing the bounds is reviewed. Other positioning changes +are reviewed because ancestors, wrapping, stretching, clipping or overlapping content may +change the result. There is no blanket exemption for translation, margins, gaps or alignment. + +This engine is conservative, not a runtime equivalence prover: + +- Runtime data, arbitrary functions, mutable bindings, dependency cycles, parser failures, + unknown props and unsupported rendering syntax produce review evidence when affected. +- Source-order matching after substantial markup edits can pair different elements. Such + changes remain flagged; findings are evidence for review, not an exact DOM correspondence. +- Dynamic module/asset paths, inherited/conditional export maps outside the supported forms, + generated source and arbitrary imperative renderers cannot be fully followed. Directly + detected DOM/canvas operations and configured native rendering use review fallbacks. +- MDX expressions and embedded HTML scripts are reviewed, without running MDX components or + scripts. Plain HTML whitespace is preserved because CSS can make it meaningful. +- Broad dependency updates and shared runtime expressions can create false positives. All + lockfile changes are reviewed, including changes to tooling-only dependencies. Inactive + variants, unused assets and an apparently inert removed class can also be flagged. +- Static import propagation is an over-approximation. Repeated unchanged unresolved + expressions with the same symbol/changed dependencies are represented once per file. + Large PRs can still produce substantial JSON reports. +- The default limits are 2 MiB per source file, 256 MiB per source snapshot, 24 resolution + levels and 5,000 evaluation steps per expression. Per-file/parser/expression limits produce + review findings; snapshot/Git failures are operational failures, never clean results. + +## Cloud execution and activation + +`design-review.yml` listens for PR opened, reopened, synchronized, edited and ready-for-review +events targeting `staging`, including drafts, without path filters. A manual dispatch from +the default branch accepts an open staging PR number. Newer runs cancel older runs for the +same PR. The job uses the existing Blacksmith/GitHub-hosted runner selection and a 15-minute +timeout, read-only repository permissions, pinned Actions and Bun 1.4.1. + +The job resolves the repository's default branch to an immutable SHA, checks out only that +trusted engine/configuration/lockfile, and installs with `--ignore-scripts`. Event base/head +commits are fetched as Git objects. Proposed application code is neither checked out nor +installed. The read-only GitHub token is used for repository metadata and fetches; the +analysis step receives no application secrets. + +The sole findings output is a JSON artifact named `design-diff--` with **7-day +retention**. No comments, labels, review annotations or findings summaries are created. +Later consumers must require `status: completed` and compare report PR/head identity with +current PR metadata before using the result. A failed/cancelled run or missing artifact is +not a clean analysis. This workflow is advisory; no required-check or branch-protection +configuration is changed. + +**Activation requires this workflow and engine to reach `main`, the default branch.** The +dedicated workflow's absence on the initial draft PR is not a passing cloud result. +This follows GitHub's documented +[`pull_request_target` execution context](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target). +Existing PR test/audit CI can validate the new suites before activation. + +## Validation + +```sh +bun run test:scripts +bun run check:script-tests +bun run check:design-diff-types +bun run check:api-validation +``` + +Root script-test discovery includes every suite in `tests/`. The fixtures exercise visual +categories, noops, movement, shared imports/themes, source order, documents/native rendering, +Git divergence/renames/deletions/binaries/unusual names/missing history, bounded evaluation, +deterministic output, CLI failure status and non-execution of proposed code/plugins. + +The following historical diffs were also inspected manually and compared locally. These +are whole commits, including ancillary changes, rather than only their headline files. + +| Commit | Manual expectation | Engine outcome | +| --- | --- | --- | +| `b890e242e0` | Flag: ChipSwitch adds `w-fit`; also changes shared modal/settings source | Flagged; static and review evidence | +| `915833bfc8` | Review: handler removal itself is not a style change, but the commit also upgrades Radix dependencies/lockfile | Flagged for dependency/runtime review | +| `3878bd48a1` | Flag: docs screenshots gain explicit dimensions/max-width through an MDX Image component | Flagged; content and MDX expression review | +| `1dd85eb688` | Flag: desktop shell/tab UI and native terminal light/dark palettes change across a large PR | Flagged; visual definitions and native/runtime review | + +These comparisons validate source-policy behavior, not rendered pixels or recall over all +historical PRs. Screenshot capture, AI interpretation and Slack delivery are separate stages. diff --git a/scripts/design-diff/analyze.ts b/scripts/design-diff/analyze.ts new file mode 100644 index 00000000000..a7a79cd7be7 --- /dev/null +++ b/scripts/design-diff/analyze.ts @@ -0,0 +1,301 @@ +import { canonical, fingerprint, semanticSource } from '#design-diff/ast' +import { compareDefinitions, finding } from '#design-diff/compare' +import { extractAsset } from '#design-diff/extract/assets' +import { cssValue, extractCss } from '#design-diff/extract/css' +import { extractDocument } from '#design-diff/extract/documents' +import { extractTsx } from '#design-diff/extract/tsx' +import { GitReader } from '#design-diff/git' +import { limitations } from '#design-diff/policy' +import { Resolver } from '#design-diff/resolve' +import { + assetPattern, + infrastructure, + SourceTree, + scoped, + scriptPattern, +} from '#design-diff/source' +import { TailwindNormalizer } from '#design-diff/tailwind' +import type { Config, Definition, Finding, Report } from '#design-diff/types' + +export function emptyReport(): Report { + return { + schemaVersion: '1.0.0', + engineVersion: '0.1.0', + policyVersion: '1.0.0', + commits: null, + status: 'failed', + flagged: null, + findings: [], + limitations, + } +} + +function review(file: string, value: string, reason: string): Definition { + return { + key: 'review', + kind: 'review', + property: /^(?:Rendering|Resolved dependency)/.test(reason) ? 'infrastructure' : 'unresolved', + value, + location: { file, line: 1, column: 1 }, + symbol: 'module', + conditions: [], + dependencies: [file], + unresolved: [reason], + } +} + +function equivalent(a: string | undefined, b: string | undefined, file: string): boolean { + if (a === b) return true + if (a === undefined || b === undefined) return false + try { + if (scriptPattern.test(file)) return semanticSource(a, file) === semanticSource(b, file) + if (file.endsWith('.css')) return cssValue(a) === cssValue(b) + } catch { + return false + } + return false +} + +export async function analyze( + cwd: string, + base: string, + head: string, + config: Config +): Promise { + const report = emptyReport() + const reader = new GitReader(cwd) + report.commits = reader.compare(base, head) + const changes = reader.changes(report.commits.mergeBase, report.commits.head) + const before = new SourceTree(reader, report.commits.mergeBase, config) + const after = new SourceTree(reader, report.commits.head, config) + const changed = new Set() + for (const change of changes) { + const file = change.after ?? (change.before as string) + if ( + !scoped(file, config) && + !infrastructure(file, config) && + file !== 'package.json' && + file !== 'bun.lock' + ) + continue + const a = change.before && before.entries.get(change.before) + const b = change.after && after.entries.get(change.after) + if ( + a && + b && + a.oid === b.oid && + a.mode === b.mode && + !(assetPattern.test(file) && a.path !== b.path) + ) + continue + if ( + a && + b && + equivalent(before.texts.get(a.path), after.texts.get(b.path), file) && + !assetPattern.test(file) && + before.texts.has(a.path) + ) + continue + if (change.before) changed.add(change.before) + if (change.after) changed.add(change.after) + } + const findings: Finding[] = [] + if (changed.size) { + before.buildGraph() + after.buildGraph() + const affected = new Set([...before.affected(changed), ...after.affected(changed)]) + for (const theme of config.themes) { + if (!affected.has(theme.path)) continue + for (const file of new Set([...before.texts.keys(), ...after.texts.keys()])) { + if ( + theme.roots.some((root) => file.startsWith(root)) && + /\.(?:[jt]sx|css|html?|mdx?)$/.test(file) + ) + affected.add(file) + } + } + const previousResolver = new Resolver(before) + const nextResolver = new Resolver(after) + const previousTailwind = new TailwindNormalizer(before) + const nextTailwind = new TailwindNormalizer(after) + const extract = async ( + tree: SourceTree, + resolver: Resolver, + tailwind: TailwindNormalizer, + file: string + ): Promise => { + const entry = tree.entries.get(file) + if (!entry) return [] + if (tree.failures.has(file)) + return [review(file, entry.oid, 'Parser failure, unsupported symlink or source size limit')] + if ( + assetPattern.test(file) || + (/\/public\//.test(file) && !scriptPattern.test(file) && !file.endsWith('.css')) + ) + return extractAsset(entry) + const source = tree.texts.get(file) + if (source === undefined) return [] + const normalizeAll = async (definitions: Definition[]) => { + for (const definition of definitions) + if (definition.unresolved.length || definition.kind === 'review') + definition.dependencies = [ + ...new Set([...definition.dependencies, ...(tree.dependencies.get(file) ?? [])]), + ].sort() + return Promise.all(definitions.map((definition) => tailwind.normalize(definition))) + } + try { + if (scriptPattern.test(file)) { + const defs = extractTsx(resolver, file) + if (config.nativeRendering.includes(file)) + defs.push( + review( + file, + fingerprint(semanticSource(source, file)), + 'Native menus, palettes and embedded rendering need review' + ) + ) + for (const definition of defs) { + if ( + definition.movement && + [...tree.texts].some( + ([name, text]) => name.endsWith('.css') && /\b(?:svg|rect|circle)\b|\*/.test(text) + ) + ) + definition.movement = undefined + if (definition.unresolved.length || definition.kind === 'review') + definition.dependencies = [ + ...new Set([...definition.dependencies, ...(tree.dependencies.get(file) ?? [])]), + ].sort() + } + const normalized: Definition[] = [] + for (const definition of defs) normalized.push(await tailwind.normalize(definition)) + return normalized + } + if (file.endsWith('.css')) return normalizeAll(extractCss(source, file)) + if ( + /\.html?$/.test(file) || + (/\.mdx?$/.test(file) && config.renderedMarkdown.some((root) => file.startsWith(root))) + ) + return normalizeAll(extractDocument(source, file)) + if (/\.(?:scss|sass|less|vue|svelte)$/.test(file)) + return [review(file, entry.oid, 'Unsupported rendering syntax')] + } catch { + return [review(file, entry.oid, 'Visual source extraction failed')] + } + return [] + } + const renames = new Map( + changes + .filter((change) => change.status.startsWith('R')) + .map((change) => [change.after as string, change.before as string]) + ) + for (const file of [...affected].sort()) { + if (!scoped(file, config) && !infrastructure(file, config)) continue + if ([...renames.values()].includes(file) && !after.entries.has(file)) continue + const oldFile = renames.get(file) ?? file + const a = await extract(before, previousResolver, previousTailwind, oldFile) + const b = await extract(after, nextResolver, nextTailwind, file) + findings.push(...compareDefinitions(a, b, affected)) + if ( + changed.has(file) && + config.infrastructure.some((pattern) => new RegExp(pattern).test(file)) + ) { + findings.push( + finding( + before.entries.has(oldFile) + ? review( + oldFile, + before.entries.get(oldFile)?.oid ?? '', + 'Rendering infrastructure is not executed' + ) + : undefined, + after.entries.has(file) + ? review( + file, + after.entries.get(file)?.oid ?? '', + 'Rendering infrastructure is not executed' + ) + : undefined, + 'Rendering infrastructure changed' + ) + ) + } + if ( + changed.has(file) && + a.length === 0 && + b.length === 0 && + /\.(?:vue|svelte|scss|sass|less)$/.test(file) + ) + findings.push( + finding( + undefined, + review(file, after.entries.get(file)?.oid ?? '', 'Unsupported rendering mechanism') + ) + ) + } + for (const file of changed) { + if (file !== 'bun.lock' && !file.endsWith('/package.json') && file !== 'package.json') + continue + if (file === 'bun.lock') { + findings.push( + finding( + undefined, + review( + file, + after.entries.get(file)?.oid ?? '', + 'Resolved dependency changes can affect rendering' + ) + ) + ) + continue + } + const project = (source?: string) => { + if (!source) return null + try { + const manifest = JSON.parse(source) + const dependencies: Record = {} + for (const section of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'overrides', + ]) + for (const [name, version] of Object.entries(manifest[section] ?? {})) + if (new RegExp(config.renderingDependencies).test(name)) + dependencies[`${section}:${name}`] = version + return canonical({ + dependencies, + exports: manifest.exports, + imports: manifest.imports, + main: manifest.main, + browser: manifest.browser, + sideEffects: manifest.sideEffects, + }) + } catch { + return 'Unparseable manifest' + } + } + const a = project(before.texts.get(file)) + const b = project(after.texts.get(file)) + if (JSON.stringify(a) !== JSON.stringify(b)) + findings.push( + finding( + { ...review(file, '', 'Rendering dependencies or module mappings changed'), value: a }, + { ...review(file, '', 'Rendering dependencies or module mappings changed'), value: b } + ) + ) + } + } + report.status = 'completed' + report.findings = [...new Map(findings.map((item) => [item.id, item])).values()].sort((a, b) => { + const left = a.after?.location ?? a.before?.location + const right = b.after?.location ?? b.before?.location + return ( + (left?.file ?? '').localeCompare(right?.file ?? '', 'en') || + (left?.line ?? 0) - (right?.line ?? 0) || + a.id.localeCompare(b.id, 'en') + ) + }) + report.flagged = report.findings.some((item) => item.decision !== 'exempt') + return report +} diff --git a/scripts/design-diff/ast.ts b/scripts/design-diff/ast.ts new file mode 100644 index 00000000000..ef314e1e685 --- /dev/null +++ b/scripts/design-diff/ast.ts @@ -0,0 +1,115 @@ +import { createHash } from 'node:crypto' +import { parse } from '@babel/parser' +import traverseModule, { type NodePath } from '@babel/traverse' +import * as t from '@babel/types' +import type { Data, Location } from '#design-diff/types' + +/** Handles Babel's CommonJS interop consistently in Bun and Vitest. */ +export const traverse: typeof traverseModule = + typeof traverseModule === 'function' + ? traverseModule + : (traverseModule as unknown as { default: typeof traverseModule }).default + +export function parseSource(source: string, file: string) { + return parse(source, { + sourceType: 'unambiguous', + sourceFilename: file, + plugins: ['jsx', 'typescript', 'decorators-legacy'], + errorRecovery: false, + attachComment: false, + }) +} + +/** Removes syntax trivia and erased types, retaining runtime ordering and literal whitespace. */ +export function canonical(value: unknown): Data { + if (value === null || value === undefined) return null + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') + return value + if (Array.isArray(value)) return value.map(canonical) + if (typeof value !== 'object') return null + const node = value as Record + if ( + [ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TypeCastExpression', + ].includes(String(node.type)) + ) + return canonical(node.expression) + const result: Record = Object.create(null) + for (const key of Object.keys(node).sort()) { + if ( + [ + 'start', + 'end', + 'loc', + 'extra', + 'comments', + 'leadingComments', + 'trailingComments', + 'innerComments', + 'typeAnnotation', + 'typeParameters', + 'typeArguments', + 'returnType', + 'declare', + 'accessibility', + 'readonly', + 'abstract', + 'definite', + 'implements', + ].includes(key) + ) + continue + result[key] = canonical(node[key]) + } + return result +} + +export function location(file: string, node?: t.Node | null): Location { + return { file, line: node?.loc?.start.line ?? 1, column: (node?.loc?.start.column ?? 0) + 1 } +} + +export function propertyName(node: t.Node | null | undefined): string { + if (t.isIdentifier(node) || t.isJSXIdentifier(node)) return node.name + if (t.isStringLiteral(node) || t.isNumericLiteral(node)) return String(node.value) + if (t.isJSXMemberExpression(node)) + return `${propertyName(node.object)}.${propertyName(node.property)}` + if (t.isJSXNamespacedName(node)) + return `${propertyName(node.namespace)}:${propertyName(node.name)}` + return '?' +} + +export function symbolName(path: NodePath): string { + let current: NodePath | null = path + while (current) { + if (current.isFunctionDeclaration() && current.node.id) return current.node.id.name + if (current.isVariableDeclarator()) return propertyName(current.node.id) + current = current.parentPath + } + return 'module' +} + +export function semanticSource(source: string, file: string): string { + const ast = parseSource(source, file) + traverse(ast, { + enter(path) { + if ( + path.isTSTypeAliasDeclaration() || + path.isTSInterfaceDeclaration() || + path.isTSDeclareFunction() || + (path.isImportDeclaration() && path.node.importKind === 'type') + ) + path.remove() + }, + }) + return JSON.stringify(canonical(ast.program)) +} + +/** Compact evidence for unsupported syntax without duplicating entire function bodies. */ +export function fingerprint(value: unknown): string { + return createHash('sha256') + .update(JSON.stringify(canonical(value))) + .digest('hex') +} diff --git a/scripts/design-diff/cli.ts b/scripts/design-diff/cli.ts new file mode 100644 index 00000000000..a5240dba06c --- /dev/null +++ b/scripts/design-diff/cli.ts @@ -0,0 +1,65 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' +import { analyze, emptyReport } from '#design-diff/analyze' +import { GitReader } from '#design-diff/git' +import type { Config, Report } from '#design-diff/types' + +let output: string | undefined +let report: Report = emptyReport() +try { + const { values } = parseArgs({ + options: { base: { type: 'string' }, head: { type: 'string' }, output: { type: 'string' } }, + strict: true, + }) + output = values.output + if (!values.base || !values.head) throw new Error('Both --base and --head are required') + const config = JSON.parse( + readFileSync(fileURLToPath(new URL('../../design-diff.config.json', import.meta.url)), 'utf8') + ) as Config + report.commits = new GitReader(process.cwd()).compare(values.base, values.head) + report = await analyze(process.cwd(), report.commits.base, report.commits.head, config) +} catch { + report = { + ...emptyReport(), + commits: report.commits, + error: + 'Analysis failed: check arguments, trusted configuration, repository history and resource limits', + } + process.exitCode = 1 +} +const pr = process.env.DESIGN_DIFF_PR +const engine = process.env.DESIGN_DIFF_ENGINE_SHA +const reportedHead = report.commits?.head ?? process.env.HEAD_SHA +if (pr || engine) { + if ( + pr && + /^[1-9]\d*$/.test(pr) && + Number.isSafeInteger(Number(pr)) && + engine && + /^[a-f0-9]{40}$/.test(engine) && + reportedHead && + /^[a-f0-9]{40}$/.test(reportedHead) + ) { + report.context = { pullRequest: Number(pr), headSha: reportedHead, engineSha: engine } + } else { + report.status = 'failed' + report.flagged = null + report.error = 'Invalid workflow context' + process.exitCode = 1 + } +} + +try { + const json = `${JSON.stringify(report, null, 2)}\n` + if (output) { + mkdirSync(path.dirname(output), { recursive: true }) + const temporary = `${output}.${process.pid}.tmp` + writeFileSync(temporary, json, { mode: 0o600 }) + renameSync(temporary, output) + } else process.stdout.write(json) +} catch { + process.stderr.write('Design diff could not write its report.\n') + process.exitCode = 1 +} diff --git a/scripts/design-diff/compare.ts b/scripts/design-diff/compare.ts new file mode 100644 index 00000000000..263a654025d --- /dev/null +++ b/scripts/design-diff/compare.ts @@ -0,0 +1,114 @@ +import { createHash } from 'node:crypto' +import { pureMovement } from '#design-diff/movement' +import { changedCategory } from '#design-diff/policy' +import type { Definition, Finding } from '#design-diff/types' + +function signature(definition: Definition) { + return JSON.stringify([definition.value, definition.conditions]) +} + +export function finding( + before: Definition | undefined, + after: Definition | undefined, + reason?: string +): Finding { + const definition = after ?? before + if (!definition) throw new Error('Finding needs evidence') + const unresolved = [ + ...new Set([...(before?.unresolved ?? []), ...(after?.unresolved ?? [])]), + ].sort() + const movement = before && after && pureMovement(before, after) + const category = changedCategory(before, after) + const decision = movement + ? 'exempt' + : unresolved.length || + definition.kind === 'review' || + ['movement', 'unresolved'].includes(category) + ? 'review' + : 'flag' + const result: Omit = { + decision, + category: movement ? 'movement' : category, + reason: + reason ?? + (movement + ? 'Static geometry establishes movement within unchanged bounds' + : decision === 'review' + ? 'Potential visual effect needs review; static evidence is incomplete' + : 'Visual definition changed'), + before: before + ? { + value: before.value, + location: before.location, + conditions: before.conditions, + property: before.property, + } + : null, + after: after + ? { + value: after.value, + location: after.location, + conditions: after.conditions, + property: after.property, + } + : null, + symbol: definition.symbol, + consumers: [ + ...new Set( + [before?.location.file, after?.location.file].filter((file): file is string => !!file) + ), + ].sort(), + dependencies: [ + ...new Set([ + ...(before?.dependencies ?? []), + ...(after?.dependencies ?? []), + definition.location.file, + ]), + ].sort(), + limitations: unresolved, + } + return { + id: createHash('sha256').update(JSON.stringify(result)).digest('hex').slice(0, 24), + ...result, + } +} + +export function compareDefinitions( + before: Definition[], + after: Definition[], + changed: Set +): Finding[] { + const previous = new Map(before.map((definition) => [definition.key, definition])) + const next = new Map(after.map((definition) => [definition.key, definition])) + const result: Finding[] = [] + const reviewedDependencies = new Set() + for (const key of [...new Set([...previous.keys(), ...next.keys()])].sort()) { + const a = previous.get(key) + const b = next.get(key) + if (a && b && signature(a) === signature(b)) { + const dependencyChanged = [...a.dependencies, ...b.dependencies].some( + (file) => changed.has(file) && file !== a.location.file && file !== b.location.file + ) + if ( + !(dependencyChanged && (a.unresolved.length || b.unresolved.length || a.kind === 'review')) + ) + continue + const group = JSON.stringify([ + b.symbol, + [...new Set([...a.dependencies, ...b.dependencies])] + .filter((file) => changed.has(file)) + .sort(), + ]) + if (reviewedDependencies.has(group)) continue + reviewedDependencies.add(group) + result.push( + finding( + { ...a, kind: 'review' }, + { ...b, kind: 'review' }, + 'A dependency of an unresolved visual expression changed' + ) + ) + } else result.push(finding(a, b)) + } + return result +} diff --git a/scripts/design-diff/extract/assets.ts b/scripts/design-diff/extract/assets.ts new file mode 100644 index 00000000000..0af3762ee55 --- /dev/null +++ b/scripts/design-diff/extract/assets.ts @@ -0,0 +1,18 @@ +import type { Entry } from '#design-diff/git' +import type { Definition } from '#design-diff/types' + +export function extractAsset(entry: Entry): Definition[] { + return [ + { + key: 'asset', + kind: 'asset', + property: 'asset', + value: { resource: entry.path, blob: entry.oid, bytes: entry.size }, + location: { file: entry.path, line: 1, column: 1 }, + symbol: 'asset', + conditions: [], + dependencies: [entry.path], + unresolved: [], + }, + ] +} diff --git a/scripts/design-diff/extract/css.ts b/scripts/design-diff/extract/css.ts new file mode 100644 index 00000000000..1802f5aa164 --- /dev/null +++ b/scripts/design-diff/extract/css.ts @@ -0,0 +1,74 @@ +import postcss, { type ChildNode, type Node } from 'postcss' +import type { Definition } from '#design-diff/types' + +function context(node: ChildNode): string[] { + const result: string[] = [] + let parent: Node | undefined = node.parent + while (parent && parent.type !== 'root' && parent.type !== 'document') { + result.unshift( + 'selector' in parent + ? String(parent.selector) + : 'name' in parent && 'params' in parent + ? `@${parent.name} ${parent.params}` + : parent.type + ) + parent = parent.parent + } + return result +} + +export function extractCss(source: string, file: string): Definition[] { + const result: Definition[] = [] + const root = postcss.parse(source, { from: file }) + let order = 0 + root.walk((node) => { + if (node.type === 'comment') return + const chain = context(node) + const selector = chain.join(' > ') + const base = { + location: { + file, + line: node.source?.start?.line ?? 1, + column: node.source?.start?.column ?? 1, + }, + symbol: selector || 'stylesheet', + conditions: chain, + dependencies: [file], + unresolved: [], + } + if (node.type === 'decl') { + result.push({ + ...base, + key: `css:${selector}:${node.prop}:${order}`, + kind: 'css', + property: node.prop, + value: { value: node.value, important: node.important, order: order++ }, + }) + } else if (node.type === 'atrule' && !node.nodes) { + result.push({ + ...base, + key: `at:${order}`, + kind: ['import', 'plugin', 'config', 'apply', 'source'].includes(node.name) + ? 'review' + : 'css', + property: `@${node.name}`, + value: { params: node.params, order: order++ }, + }) + } + }) + return result +} + +/** Stable CSS representation, retaining selector, conditional and cascade order. */ +export function cssValue(source: string): string { + const root = postcss.parse(source) + root.walkComments((comment) => { + comment.remove() + }) + const value: unknown[] = [] + root.walk((node) => { + if (node.type === 'decl') value.push([context(node), node.prop, node.value, node.important]) + if (node.type === 'atrule' && !node.nodes) value.push([context(node), node.name, node.params]) + }) + return JSON.stringify(value) +} diff --git a/scripts/design-diff/extract/documents.ts b/scripts/design-diff/extract/documents.ts new file mode 100644 index 00000000000..c674b0f7604 --- /dev/null +++ b/scripts/design-diff/extract/documents.ts @@ -0,0 +1,132 @@ +import { type DefaultTreeAdapterMap, parse as parseHtml } from 'parse5' +import remarkFrontmatter from 'remark-frontmatter' +import remarkGfm from 'remark-gfm' +import remarkMdx from 'remark-mdx' +import remarkParse from 'remark-parse' +import { unified } from 'unified' +import { canonical, semanticSource } from '#design-diff/ast' +import { cssValue } from '#design-diff/extract/css' +import type { Data, Definition } from '#design-diff/types' + +const markdown = unified().use(remarkParse).use(remarkGfm).use(remarkMdx).use(remarkFrontmatter) + +export function extractDocument(source: string, file: string): Definition[] { + const result: Definition[] = [] + const emit = ( + kind: Definition['kind'], + value: Data, + line = 1, + column = 1, + property: string = kind + ) => { + result.push({ + key: `${kind}:${result.length}`, + kind, + property, + value, + location: { file, line, column }, + symbol: 'document', + conditions: [], + dependencies: [file], + unresolved: kind === 'review' ? ['Embedded rendering expression requires review'] : [], + }) + } + if (/\.html?$/.test(file)) { + const errors: string[] = [] + const root = parseHtml(source, { + sourceCodeLocationInfo: true, + onParseError: (error) => { + if (error.code !== 'missing-doctype') errors.push(error.code) + }, + }) + const walk = (node: DefaultTreeAdapterMap['node']) => { + const loc = node.sourceCodeLocation + if ('tagName' in node) { + if (node.tagName === 'script') { + emit( + 'review', + { + attributes: node.attrs.map((attr) => [attr.name, attr.value]), + body: canonical( + node.childNodes.map((n) => { + if (!('value' in n)) return null + try { + return semanticSource(n.value, `${file}.js`) + } catch { + return n.value + } + }) + ), + }, + loc?.startLine, + loc?.startCol + ) + return + } + if (node.tagName === 'style') { + emit( + 'css', + cssValue(node.childNodes.map((n) => ('value' in n ? n.value : '')).join('')), + loc?.startLine, + loc?.startCol + ) + return + } + emit( + 'markup', + { + tag: node.tagName, + attrs: node.attrs + .filter((attr) => !/^on/.test(attr.name) && attr.name !== 'class') + .map((attr) => [ + attr.name, + attr.name === 'style' ? cssValue(`a{${attr.value}}`) : attr.value, + ]), + }, + loc?.startLine, + loc?.startCol + ) + for (const attr of node.attrs) + if (attr.name === 'class') + emit('class', attr.value, loc?.startLine, loc?.startCol, 'class') + if ('content' in node) walk(node.content as DefaultTreeAdapterMap['documentFragment']) + } + if (node.nodeName === '#text' && 'value' in node && node.value) + emit('content', node.value, loc?.startLine, loc?.startCol) + if ('childNodes' in node) for (const child of node.childNodes) walk(child) + } + walk(root) + if (errors.length) emit('review', errors) + } else { + const root = markdown.parse(source) + for (const node of root.children) { + const data = canonical(node) as Record + const stripPositions = (value: Data): Data => { + if (Array.isArray(value)) return value.map(stripPositions) + if (value && typeof value === 'object') { + const result: Record = {} + for (const [key, child] of Object.entries(value)) + if (!['position', 'data'].includes(key)) result[key] = stripPositions(child) + return result + } + return value + } + const value = stripPositions(data) + if ( + /^mdx.*Expression$/.test(node.type) && + 'value' in node && + /^\s*\/\*[\s\S]*\*\/\s*$/.test(String(node.value)) + ) + continue + emit( + node.type === 'mdxjsEsm' || JSON.stringify(value).includes('Expression') + ? 'review' + : 'content', + value, + node.position?.start.line, + node.position?.start.column + ) + } + } + return result +} diff --git a/scripts/design-diff/extract/index.ts b/scripts/design-diff/extract/index.ts new file mode 100644 index 00000000000..d643d87c4a6 --- /dev/null +++ b/scripts/design-diff/extract/index.ts @@ -0,0 +1,4 @@ +export { extractAsset } from '#design-diff/extract/assets' +export { cssValue, extractCss } from '#design-diff/extract/css' +export { extractDocument } from '#design-diff/extract/documents' +export { extractTsx, jsxText } from '#design-diff/extract/tsx' diff --git a/scripts/design-diff/extract/tsx.ts b/scripts/design-diff/extract/tsx.ts new file mode 100644 index 00000000000..d0933aeb200 --- /dev/null +++ b/scripts/design-diff/extract/tsx.ts @@ -0,0 +1,293 @@ +import type { NodePath } from '@babel/traverse' +import * as t from '@babel/types' +import { fingerprint, location, propertyName, symbolName, traverse } from '#design-diff/ast' +import { svgMovement } from '#design-diff/movement' +import { child, children, object, type Resolver } from '#design-diff/resolve' +import type { Data, Definition, Evidence } from '#design-diff/types' + +const nonvisualAttributes = /^(?:key|ref|on[A-Z].*)$/ +const knownAttributes = + /^(?:className|class|style|src|srcSet|sizes|alt|title|placeholder|value|defaultValue|checked|defaultChecked|disabled|hidden|open|type|width|height|size|rows|cols|fill|stroke.*|viewBox|d|points|x|y|x1|y1|x2|y2|cx|cy|r|rx|ry|transform|opacity|color|animate|initial|exit|transition|while.*|layout.*|dangerouslySetInnerHTML|children)$/ + +/** React's line-wise JSX text whitespace semantics, including explicit single-line spaces. */ +export function jsxText(text: string): string { + const lines = text.replace(/\r\n?/g, '\n').split('\n') + let last = 0 + lines.forEach((line, i) => { + if (/[^ \t]/.test(line)) last = i + }) + return lines + .map((line, i) => { + let value = line.replace(/\t/g, ' ') + if (i !== 0) value = value.replace(/^ +/, '') + if (i !== lines.length - 1) value = value.replace(/ +$/, '') + return value ? value + (i !== last ? ' ' : '') : '' + }) + .join('') +} + +export function extractTsx(resolver: Resolver, file: string): Definition[] { + const definitions: Definition[] = [] + const counts = new Map() + const guards = new WeakMap() + const emit = (path: NodePath, kind: Definition['kind'], property: string, evidence: Evidence) => { + const symbol = symbolName(path) + const prefix = `${symbol}:${kind}:${property}` + const count = counts.get(prefix) ?? 0 + counts.set(prefix, count + 1) + const conditions: Data[] = [] + const owner = path.getFunctionParent() + if (owner) { + if (!guards.has(owner.node)) { + const entries: { value: Data; evidence: Evidence }[] = [] + owner.traverse({ + Function(p) { + p.skip() + }, + IfStatement(p) { + let returns = false + p.traverse({ + Function(nested) { + nested.skip() + }, + ReturnStatement() { + returns = true + }, + }) + if (returns) { + const test = resolver.evaluate(child(p, 'test'), file) + entries.push({ + value: { guard: test.value, alternate: !!p.node.alternate }, + evidence: test, + }) + } + }, + }) + guards.set(owner.node, entries) + } + for (const guard of guards.get(owner.node) ?? []) { + conditions.push(guard.value) + evidence.dependencies.push(...guard.evidence.dependencies) + evidence.unresolved.push(...guard.evidence.unresolved) + } + } + for (let p = path.parentPath; p; p = p.parentPath) { + if (p.isConditionalExpression() || p.isIfStatement()) { + const test = resolver.evaluate(child(p, 'test'), file) + conditions.push({ + test: test.value, + branch: + (path.parentPath === p ? path : path.findParent((parent) => parent.parentPath === p)) + ?.key === 'alternate' + ? 'else' + : 'then', + }) + evidence.dependencies.push(...test.dependencies) + evidence.unresolved.push(...test.unresolved) + } + if (p.isLogicalExpression()) { + const test = resolver.evaluate(child(p, 'left'), file) + conditions.push({ operator: p.node.operator, test: test.value }) + evidence.dependencies.push(...test.dependencies) + evidence.unresolved.push(...test.unresolved) + } + if (p.isSwitchCase() && p.parentPath.isSwitchStatement()) { + const discriminant = resolver.evaluate(child(p.parentPath, 'discriminant'), file) + const test = resolver.evaluate(child(p, 'test'), file) + conditions.push({ switch: discriminant.value, case: test.value }) + evidence.dependencies.push(...discriminant.dependencies, ...test.dependencies) + evidence.unresolved.push(...discriminant.unresolved, ...test.unresolved) + } + if ( + p.isForStatement() || + p.isForOfStatement() || + p.isForInStatement() || + p.isWhileStatement() || + p.isDoWhileStatement() + ) { + const iterable = resolver.evaluate( + child(p, p.isForOfStatement() || p.isForInStatement() ? 'right' : 'test'), + file + ) + conditions.push({ loop: p.node.type, test: iterable.value, syntax: fingerprint(p.node) }) + evidence.dependencies.push(...iterable.dependencies) + evidence.unresolved.push('Loop rendering requires review', ...iterable.unresolved) + } + } + definitions.push({ + ...evidence, + dependencies: [...new Set(evidence.dependencies)].sort(), + unresolved: [...new Set(evidence.unresolved)].sort(), + key: `${prefix}:${count}`, + kind, + property, + symbol, + location: location(file, path.node), + conditions, + movement: svgMovement(path), + }) + } + const literal = (value: Data): Evidence => ({ value, dependencies: [file], unresolved: [] }) + const ast = resolver.module(file).ast + traverse(ast, { + JSXElement(path) { + const opening = path.node.openingElement + const name = propertyName(opening.name) + const childShapes = path.node.children.flatMap((node) => { + if (t.isJSXText(node)) return jsxText(node.value) ? ['text'] : [] + if (t.isJSXExpressionContainer(node) && t.isJSXEmptyExpression(node.expression)) return [] + return [t.isJSXElement(node) ? propertyName(node.openingElement.name) : node.type] + }) + const attributeOrder = opening.attributes + .filter( + (attr) => !t.isJSXAttribute(attr) || !nonvisualAttributes.test(propertyName(attr.name)) + ) + .map((attr) => (t.isJSXAttribute(attr) ? propertyName(attr.name) : '...spread')) + const evidence = literal({ tag: name, children: childShapes, attributeOrder }) + if (/^[A-Z]/.test(name)) { + const binding = path.scope.getBinding(name.split('.')[0]) + if ( + binding && + (binding.path.isImportSpecifier() || binding.path.isImportDefaultSpecifier()) + ) { + const parent = binding.path.parentPath + if (parent.isImportDeclaration()) + evidence.value = { + tag: name, + children: childShapes, + attributeOrder, + from: parent.node.source.value, + imported: binding.path.isImportSpecifier() + ? propertyName(binding.path.node.imported) + : 'default', + } + } + } + emit(path, 'markup', name, evidence) + }, + JSXAttribute(path) { + const name = propertyName(path.node.name) + if (nonvisualAttributes.test(name)) return + const value = child(path, 'value') + const evidence = value.node ? resolver.evaluate(value, file) : literal(true) + if ( + name === 'style' && + object(evidence.value) && + !Object.keys(evidence.value).some((key) => key.startsWith('$')) + ) { + for (const [precedence, [property, data]] of Object.entries(evidence.value).entries()) { + emit(path, 'style', property, { ...evidence, value: data }) + definitions[definitions.length - 1].conditions.push({ declarationPrecedence: precedence }) + } + } else { + if (!knownAttributes.test(name)) + evidence.unresolved.push('Custom prop or selector attribute may affect rendering') + emit(path, name === 'className' || name === 'class' ? 'class' : 'attribute', name, evidence) + } + }, + JSXSpreadAttribute(path) { + emit(path, 'review', 'spread', resolver.evaluate(child(path, 'argument'), file)) + }, + JSXText(path) { + const value = jsxText(path.node.value) + if (value) emit(path, 'content', 'text', literal(value)) + }, + JSXExpressionContainer(path) { + if ( + path.parentPath.isJSXAttribute() || + t.isJSXEmptyExpression(path.node.expression) || + t.isJSXElement(path.node.expression) || + t.isJSXFragment(path.node.expression) + ) + return + emit(path, 'content', 'expression', resolver.evaluate(child(path, 'expression'), file)) + }, + CallExpression(path) { + const name = propertyName(path.node.callee) + if (resolver.tree.config.variantFunctions.includes(name)) + emit(path, 'class', 'variants', resolver.evaluate(path, file)) + const rendering = + /(?:createElement|createPortal|createTextNode|appendChild|insertAdjacentHTML|insertRule|deleteRule|replaceSync|setAttribute|setProperty|animate|addColorStop|fillRect|strokeRect|drawImage|fillText|strokeText|getContext)$/.test( + name === '?' && t.isMemberExpression(path.node.callee) + ? propertyName(path.node.callee.property) + : name + ) + if (rendering) emit(path, 'review', 'imperative-rendering', resolver.evaluate(path, file)) + if (file.startsWith('apps/desktop/') && t.isMemberExpression(path.node.callee)) { + const method = propertyName(path.node.callee.property) + if ( + /^set(?:BackgroundColor|TitleBarOverlay|Opacity|Vibrancy|BackgroundMaterial|Size|Bounds|MinimumSize|MaximumSize|FullScreen|SimpleFullScreen|AutoHideMenuBar|MenuBarVisibility|Shape|Icon|Image|Position)$/.test( + method + ) + ) { + for (const argument of children(path, 'arguments')) + emit(path, 'native', method, resolver.evaluate(argument, file)) + } + } + }, + AssignmentExpression(path) { + const lhs = path.node.left + if ( + file.startsWith('apps/desktop/') && + t.isMemberExpression(lhs) && + propertyName(lhs.property) === 'themeSource' + ) + emit(path, 'native', 'themeSource', resolver.evaluate(child(path, 'right'), file)) + if ( + t.isMemberExpression(lhs) && + /^(?:innerHTML|outerHTML|textContent|className|cssText|fillStyle|strokeStyle|font)$/.test( + propertyName(lhs.property) + ) + ) + emit(path, 'review', 'imperative-rendering', resolver.evaluate(child(path, 'right'), file)) + if ( + t.isMemberExpression(lhs) && + t.isMemberExpression(lhs.object) && + propertyName(lhs.object.property) === 'style' + ) + emit( + path, + 'style', + propertyName(lhs.property), + resolver.evaluate(child(path, 'right'), file) + ) + }, + NewExpression(path) { + if ( + !file.startsWith('apps/desktop/') || + !t.isIdentifier(path.node.callee, { name: 'BrowserWindow' }) + ) + return + const args = children(path, 'arguments') + if (!args[0]) return + const evidence = resolver.evaluate(args[0], file) + if (object(evidence.value)) { + for (const [name, value] of Object.entries(evidence.value)) { + if (resolver.tree.config.nativeAppearance.includes(name)) + emit(path, 'native', name, { ...evidence, value }) + } + if (evidence.unresolved.length) emit(path, 'review', 'native-options', evidence) + } else emit(path, 'review', 'native-options', evidence) + }, + TaggedTemplateExpression(path) { + emit(path, 'review', 'tagged-template', { + ...literal(fingerprint(path.node)), + unresolved: ['Tagged templates are not executed'], + }) + }, + }) + if (definitions.some((definition) => definition.property === 'imperative-rendering')) { + definitions.push({ + key: 'imperative-context', + kind: 'review', + property: 'imperative-context', + value: fingerprint(ast.program), + location: location(file, ast.program), + symbol: 'module', + conditions: [], + dependencies: [file], + unresolved: ['Imperative rendering may depend on surrounding source'], + }) + } + return definitions +} diff --git a/scripts/design-diff/git.ts b/scripts/design-diff/git.ts new file mode 100644 index 00000000000..2379261e7c4 --- /dev/null +++ b/scripts/design-diff/git.ts @@ -0,0 +1,121 @@ +import { execFileSync } from 'node:child_process' + +export interface Entry { + path: string + oid: string + mode: string + size: number +} +export interface Change { + before?: string + after?: string + status: string +} + +/** Git arguments are passed directly, without a shell or worktree filters. */ +export class GitReader { + constructor(readonly cwd: string) {} + + run(args: string[], input?: string): Buffer { + try { + return execFileSync('git', ['--no-pager', ...args], { + cwd: this.cwd, + input, + maxBuffer: 300 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_NO_REPLACE_OBJECTS: '1' }, + }) + } catch { + throw new Error(`Git operation failed: ${args[0]}`) + } + } + + commit(ref: string): string { + const result = this.run(['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]) + .toString() + .trim() + if (!/^[a-f0-9]{40,64}$/.test(result)) throw new Error('Invalid resolved commit') + return result + } + + compare(baseRef: string, headRef: string) { + if (this.run(['rev-parse', '--is-shallow-repository']).toString().trim() === 'true') { + throw new Error('Complete Git history is required') + } + const base = this.commit(baseRef) + const head = this.commit(headRef) + const bases = this.run(['merge-base', '--all', base, head]).toString().trim().split('\n') + if (bases.length !== 1 || !/^[a-f0-9]{40,64}$/.test(bases[0])) { + throw new Error('A unique merge-base is required; fetch complete history') + } + return { base, head, mergeBase: bases[0] } + } + + tree(commit: string): Map { + const entries = new Map() + for (const record of this.run(['ls-tree', '-rlz', commit]).toString().split('\0')) { + if (!record) continue + const tab = record.indexOf('\t') + const [mode, type, oid, size] = record.slice(0, tab).trim().split(/\s+/) + if (type !== 'blob') continue + const path = record.slice(tab + 1) + entries.set(path, { path, mode, oid, size: Number(size) }) + } + return entries + } + + changes(base: string, head: string): Change[] { + const records = this.run([ + 'diff', + '--no-ext-diff', + '--no-textconv', + '--name-status', + '-z', + '-M', + base, + head, + '--', + ]) + .toString() + .split('\0') + const changes: Change[] = [] + for (let i = 0; i < records.length - 1; ) { + const status = records[i++] + const path = records[i++] + if (status.startsWith('R') || status.startsWith('C')) { + changes.push({ status, before: path, after: records[i++] }) + } else { + changes.push({ + status, + before: status === 'A' ? undefined : path, + after: status === 'D' ? undefined : path, + }) + } + } + return changes + } + + /** Reads blobs by object ID. Batch framing is byte-based, including binary blobs. */ + blobs(entries: Entry[]): Map { + const result = new Map() + for (let start = 0; start < entries.length; start += 500) { + const batch = entries.slice(start, start + 500) + const output = this.run( + ['cat-file', '--batch'], + `${batch.map((entry) => entry.oid).join('\n')}\n` + ) + let offset = 0 + for (const entry of batch) { + const end = output.indexOf(10, offset) + const [oid, type, bytes] = output.subarray(offset, end).toString().split(' ') + const size = Number(bytes) + if (oid !== entry.oid || type !== 'blob' || size !== entry.size) + throw new Error('Unreadable Git blob') + offset = end + 1 + result.set(entry.path, output.subarray(offset, offset + size).toString('utf8')) + offset += size + 1 + } + } + return result + } +} diff --git a/scripts/design-diff/index.ts b/scripts/design-diff/index.ts new file mode 100644 index 00000000000..325eb0630cf --- /dev/null +++ b/scripts/design-diff/index.ts @@ -0,0 +1,3 @@ +export { analyze } from '#design-diff/analyze' +export { GitReader } from '#design-diff/git' +export type { Config, Finding, Report } from '#design-diff/types' diff --git a/scripts/design-diff/movement.ts b/scripts/design-diff/movement.ts new file mode 100644 index 00000000000..4fd9c1ac755 --- /dev/null +++ b/scripts/design-diff/movement.ts @@ -0,0 +1,103 @@ +import type { NodePath } from '@babel/traverse' +import * as t from '@babel/types' +import { propertyName } from '#design-diff/ast' +import type { Definition } from '#design-diff/types' + +/** Movement proof is intentionally limited to one untransformed primitive in a fixed SVG canvas. */ +export function svgMovement(path: NodePath): Definition['movement'] { + if (!path.isJSXAttribute() || !['x', 'y', 'cx', 'cy'].includes(propertyName(path.node.name))) + return undefined + const primitive = path.parentPath.parentPath + if ( + !primitive?.isJSXElement() || + !['rect', 'circle'].includes(propertyName(primitive.node.openingElement.name)) + ) + return undefined + const canvas = primitive.parentPath + if (!canvas?.isJSXElement() || propertyName(canvas.node.openingElement.name) !== 'svg') + return undefined + if (canvas.findParent((parent) => parent.isJSXElement() || parent.isJSXFragment())) + return undefined + if (canvas.node.children.filter((n) => !t.isJSXText(n) || n.value.trim()).length !== 1) + return undefined + const attrs = (node: t.JSXElement): Record | undefined => { + const result: Record = {} + for (const attr of node.openingElement.attributes) { + if (!t.isJSXAttribute(attr)) return undefined + const name = propertyName(attr.name) + if ( + ![ + 'width', + 'height', + 'viewBox', + 'x', + 'y', + 'cx', + 'cy', + 'r', + 'rx', + 'ry', + 'fill', + 'xmlns', + ].includes(name) + ) + return undefined + if (t.isStringLiteral(attr.value)) result[name] = attr.value.value + else if (t.isJSXExpressionContainer(attr.value) && t.isNumericLiteral(attr.value.expression)) + result[name] = String(attr.value.expression.value) + else return undefined + } + return result + } + const outer = attrs(canvas.node) + const inner = attrs(primitive.node) + if ( + !outer || + !inner || + !/^\d+(?:\.\d+)?$/.test(outer.width ?? '') || + !/^\d+(?:\.\d+)?$/.test(outer.height ?? '') + ) + return undefined + const viewport = (outer.viewBox ?? `0 0 ${outer.width} ${outer.height}`) + .trim() + .split(/[ ,]+/) + .map(Number) + if ( + viewport.length !== 4 || + !viewport.every(Number.isFinite) || + viewport[2] <= 0 || + viewport[3] <= 0 + ) + return undefined + const circle = propertyName(primitive.node.openingElement.name) === 'circle' + const x = Number(inner[circle ? 'cx' : 'x'] ?? 0) + const y = Number(inner[circle ? 'cy' : 'y'] ?? 0) + const w = Number(inner[circle ? 'r' : 'width']) + const h = Number(inner[circle ? 'r' : 'height']) + if (![x, y, w, h].every(Number.isFinite) || w <= 0 || h <= 0) return undefined + const bounds = circle ? [x - w, y - h, x + w, y + h] : [x, y, x + w, y + h] + const appearance = { ...inner, x: '', y: '', cx: '', cy: '', canvas: outer } + return { bounds, viewport, appearance } +} + +export function pureMovement(before: Definition, after: Definition): boolean { + const a = before.movement + const b = after.movement + if (!a || !b || before.unresolved.length || after.unresolved.length) return false + if (before.conditions.length || after.conditions.length) return false + if ( + JSON.stringify(a.appearance) !== JSON.stringify(b.appearance) || + JSON.stringify(before.conditions) !== JSON.stringify(after.conditions) + ) + return false + return [a, b].every( + ({ bounds: [left, top, right, bottom], viewport: [x, y, width, height] }) => + left > x && top > y && right < x + width && bottom < y + height + ) +} + +export function movementProperty(property: string): boolean { + return /^(?:x|y|cx|cy|top|right|bottom|left|inset.*|translate.*|transform|margin.*|gap|rowGap|columnGap|align.*|justify.*|position|trafficLightPosition)$/i.test( + property + ) +} diff --git a/scripts/design-diff/policy.ts b/scripts/design-diff/policy.ts new file mode 100644 index 00000000000..6aed9a71259 --- /dev/null +++ b/scripts/design-diff/policy.ts @@ -0,0 +1,86 @@ +import { movementProperty } from '#design-diff/movement' +import type { Category, Data, Definition } from '#design-diff/types' + +export function category(definition: Definition): Category { + const property = definition.property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) + if (definition.property === 'infrastructure') return 'infrastructure' + if (definition.kind === 'review') return 'unresolved' + if (definition.kind === 'asset' || definition.kind === 'content' || definition.kind === 'markup') + return 'content' + if (/^(?:src|src-set|alt|title|placeholder|d|points|view-box)$/.test(property)) return 'content' + if (movementProperty(definition.property)) return 'movement' + if (/color|background|fill|gradient|surface/.test(property)) return 'colour' + if (/font|text|line-height|letter|word-spacing/.test(property)) return 'typography' + if (/width|height|padding|size|aspect/.test(property)) return 'dimensions' + if (/radius|border|shadow|opacity|filter|blur|scale|clip-path/.test(property)) + return 'shape-effects' + if (/visibility|display|overflow|hidden|z-index|clip/.test(property)) return 'visibility' + if (/animation|transition|animate|initial|exit|while/.test(property)) return 'motion' + if (/flex|grid|wrap|columns|float|clear|contain/.test(property)) return 'layout' + if (definition.kind === 'class') return 'layout' + return 'unresolved' +} + +export const limitations = [ + 'Static source analysis does not establish pixel equality or complete runtime behavior.', + 'Dynamic data, unknown calls, custom props, plugins and unsupported rendering mechanisms require review when affected.', + 'Import propagation covers static imports/re-exports, supported aliases and literal asset paths; runtime-generated paths cannot be enumerated.', + 'Tailwind 4.3.3 normalizes core utilities and CSS theme declarations. Proposed JavaScript configuration, plugins and external CSS are not executed.', + 'Movement exemptions cover only a single static rect/circle moving strictly inside an unchanged fixed SVG viewport, with no styling hooks or effects.', + 'Unchanged unresolved expressions with the same symbol and changed dependencies are represented once per file.', + 'Source-order matching is conservative after structural edits. Reports identify possible visual changes, including inactive variants and unused assets.', +] + +/** Derives a class category from changed generated declarations when they agree. */ +export function changedCategory( + before: Definition | undefined, + after: Definition | undefined +): Category { + const definition = after ?? before + if (!definition) return 'unresolved' + if (definition.kind !== 'class') return category(definition) + const declarations = (value: Data | undefined) => { + const result = new Map() + const visit = (data: Data) => { + if (Array.isArray(data)) { + data.forEach(visit) + return + } + if (!data || typeof data !== 'object') return + for (const [key, child] of Object.entries(data)) { + if (key === 'css' && Array.isArray(child) && Array.isArray(data.order)) { + for (const css of child) { + if (typeof css !== 'string') continue + for (const declaration of JSON.parse(css) as [string[], string, string, boolean][]) { + if (!Array.isArray(declaration[0]) || typeof declaration[1] !== 'string') continue + const [conditions, property, value, important] = declaration + const signature = JSON.stringify([ + conditions.map((condition) => (condition.startsWith('.') ? '.utility' : condition)), + value, + important, + ]) + result.set(property, [...(result.get(property) ?? []), signature]) + } + } + } else visit(child) + } + } + if (value !== undefined) visit(value) + return result + } + let a: Map + let b: Map + try { + a = declarations(before?.value) + b = declarations(after?.value) + } catch { + return 'unresolved' + } + const categories = new Set() + for (const property of new Set([...a.keys(), ...b.keys()])) { + if (JSON.stringify(a.get(property)) !== JSON.stringify(b.get(property))) + categories.add(category({ ...definition, kind: 'style', property })) + } + if (categories.size === 1) return [...categories][0] + return category(definition) +} diff --git a/scripts/design-diff/resolve.ts b/scripts/design-diff/resolve.ts new file mode 100644 index 00000000000..9663ff1cc65 --- /dev/null +++ b/scripts/design-diff/resolve.ts @@ -0,0 +1,364 @@ +import type { NodePath } from '@babel/traverse' +import * as t from '@babel/types' +import { canonical, fingerprint, parseSource, propertyName, traverse } from '#design-diff/ast' +import type { SourceTree } from '#design-diff/source' +import type { Data, Evidence } from '#design-diff/types' + +export function child(path: NodePath, name: string): NodePath { + return path.get(name) as NodePath +} +export function children(path: NodePath, name: string): NodePath[] { + return path.get(name) as NodePath[] +} +export function object(value: Data): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +interface Module { + ast: t.File + exports: Map + stars: string[] +} + +/** A bounded interpreter for data expressions. It never invokes a source function. */ +export class Resolver { + private readonly modules = new Map() + private steps = 0 + private readonly active = new Set() + private dependencies = new Set() + private unresolved = new Set() + + constructor(readonly tree: SourceTree) {} + + module(file: string): Module { + const cached = this.modules.get(file) + if (cached) return cached + const source = this.tree.texts.get(file) + if (source === undefined) throw new Error('Source unavailable') + const ast = parseSource(source, file) + const exports = new Map() + const stars: string[] = [] + traverse(ast, { + ExportDefaultDeclaration(p) { + exports.set('default', child(p, 'declaration')) + }, + ExportNamedDeclaration(p) { + const declaration = child(p, 'declaration') + if (declaration.isVariableDeclaration()) { + for (const d of children(declaration, 'declarations')) + exports.set(propertyName((d.node as t.VariableDeclarator).id), child(d, 'init')) + } else if (declaration.isFunctionDeclaration() && declaration.node.id) + exports.set(declaration.node.id.name, declaration) + for (const specifier of children(p, 'specifiers')) { + if (specifier.isExportSpecifier()) + exports.set(propertyName(specifier.node.exported), specifier) + } + }, + ExportAllDeclaration(p) { + stars.push(p.node.source.value) + }, + }) + const result = { ast, exports, stars } + this.modules.set(file, result) + return result + } + + evaluate(path: NodePath, file: string): Evidence { + this.steps = 0 + this.active.clear() + this.dependencies = new Set([file]) + this.unresolved = new Set() + const value = this.value(path, file, 0) + return { + value, + dependencies: [...this.dependencies].sort(), + unresolved: [...this.unresolved].sort(), + } + } + + private unknown(path: NodePath, reason: string): Data { + this.unresolved.add(reason) + return { + $unresolved: reason, + syntax: path.node.type, + symbol: propertyName(path.node), + fingerprint: fingerprint(path.node), + } + } + + private exported(file: string, name: string, depth: number, visited = new Set()): Data { + const key = `${file}:${name}` + if (depth > this.tree.config.limits.resolutionDepth || visited.has(key)) { + this.unresolved.add('Dependency cycle or resolution depth limit') + return { $unresolved: key } + } + visited.add(key) + this.dependencies.add(file) + try { + if (file.endsWith('.json') && name === 'default') + return canonical(JSON.parse(this.tree.texts.get(file) ?? 'null')) + const module = this.module(file) + const exported = module.exports.get(name) + if (exported) { + if (exported.isExportSpecifier()) { + const parent = exported.parentPath + if (parent.isExportNamedDeclaration() && parent.node.source) { + const target = this.tree.resolve(file, parent.node.source.value) + if (target) + return this.exported(target, propertyName(exported.node.local), depth + 1, visited) + } + return this.value(child(exported, 'local'), file, depth + 1) + } + return this.value(exported, file, depth + 1) + } + const candidates = module.stars + .map((specifier) => this.tree.resolve(file, specifier)) + .filter((target): target is string => !!target && this.hasExport(target, name, new Set())) + if (candidates.length === 1) return this.exported(candidates[0], name, depth + 1, visited) + if (candidates.length > 1) this.unresolved.add('Ambiguous re-export') + } catch { + this.unresolved.add('Imported source could not be parsed') + } + return { $missing: key } + } + + private hasExport(file: string, name: string, visited: Set): boolean { + if (visited.has(file) || visited.size > this.tree.config.limits.resolutionDepth) return false + visited.add(file) + const module = this.module(file) + if (module.exports.has(name)) return true + return module.stars.some((specifier) => { + const target = this.tree.resolve(file, specifier) + return target ? this.hasExport(target, name, new Set(visited)) : false + }) + } + + private value(path: NodePath, file: string, depth: number): Data { + if (!path?.node) return null + if ( + ++this.steps > this.tree.config.limits.resolutionSteps || + depth > this.tree.config.limits.resolutionDepth + ) + return this.unknown(path, 'Resolution budget exceeded') + if (this.active.has(path.node)) return this.unknown(path, 'Dependency cycle') + this.active.add(path.node) + try { + return this.inner(path, file, depth) + } finally { + this.active.delete(path.node) + } + } + + private inner(path: NodePath, file: string, depth: number): Data { + const node = path.node + const read = (key: string) => this.value(child(path, key), file, depth + 1) + const readList = (key: string) => children(path, key).map((p) => this.value(p, file, depth + 1)) + if (t.isStringLiteral(node) || t.isNumericLiteral(node) || t.isBooleanLiteral(node)) + return node.value + if (t.isNullLiteral(node)) return null + if ( + t.isTSAsExpression(node) || + t.isTSSatisfiesExpression(node) || + t.isTSNonNullExpression(node) || + t.isParenthesizedExpression(node) + ) + return read('expression') + if (t.isIdentifier(node)) { + if (node.name === 'undefined') return null + const binding = path.scope.getBinding(node.name) + if (!binding) return this.unknown(path, 'Runtime binding') + if (!binding.constant) { + this.unresolved.add('Mutable binding') + return { + $mutable: fingerprint(binding.path.node), + writes: binding.constantViolations.map((violation) => fingerprint(violation.node)), + } + } + const bound = binding.path + if (bound.isVariableDeclarator() && t.isIdentifier(bound.node.id)) + return this.value(child(bound, 'init'), file, depth + 1) + if ( + bound.isImportSpecifier() || + bound.isImportDefaultSpecifier() || + bound.isImportNamespaceSpecifier() + ) { + const declaration = bound.parentPath + if (!declaration.isImportDeclaration()) return this.unknown(path, 'Unsupported import') + const target = this.tree.resolve(file, declaration.node.source.value) + if (!target) return this.unknown(path, `External import: ${declaration.node.source.value}`) + this.dependencies.add(target) + if (bound.isImportNamespaceSpecifier()) return { $namespace: target } + const result = this.exported( + target, + bound.isImportDefaultSpecifier() ? 'default' : propertyName(bound.node.imported), + depth + 1 + ) + if (object(result) && '$missing' in result) this.unresolved.add('Unresolved export') + return result + } + return this.unknown(bound, 'Runtime binding') + } + if (t.isObjectExpression(node)) { + const result: Record = Object.create(null) + for (const p of children(path, 'properties')) { + if (p.isSpreadElement()) { + const spread = this.value(child(p, 'argument'), file, depth + 1) + if (object(spread) && !('$unresolved' in spread)) Object.assign(result, spread) + else { + result.$spread = spread + this.unresolved.add('Unresolved object spread') + } + } else if (p.isObjectProperty()) { + const key = p.node.computed + ? this.value(child(p, 'key'), file, depth + 1) + : propertyName(p.node.key) + if (typeof key !== 'string' && typeof key !== 'number') + return this.unknown(path, 'Computed property') + result[String(key)] = this.value(child(p, 'value'), file, depth + 1) + } else return this.unknown(path, 'Object method') + } + return result + } + if (t.isArrayExpression(node)) return readList('elements') + if (t.isTemplateLiteral(node)) { + const values = readList('expressions') + if (values.every((v) => v === null || ['string', 'number', 'boolean'].includes(typeof v))) + return node.quasis + .map( + (q, i) => (q.value.cooked ?? q.value.raw) + (i < values.length ? String(values[i]) : '') + ) + .join('') + return { $template: node.quasis.map((q) => q.value.cooked ?? q.value.raw), values } + } + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + const base = read('object') + const key = node.computed ? read('property') : propertyName(node.property) + if (object(base) && typeof base.$namespace === 'string' && typeof key === 'string') + return this.exported(base.$namespace, key, depth + 1) + if ( + (object(base) || Array.isArray(base)) && + (typeof key === 'string' || typeof key === 'number') + ) { + if (Object.hasOwn(base, key)) return (base as Record)[String(key)] + } + this.unresolved.add('Unresolved property') + return { $member: base, key } + } + if (t.isUnaryExpression(node) && ['-', '+', '!'].includes(node.operator)) { + const value = read('argument') + if (typeof value === 'number' && node.operator !== '!') + return node.operator === '-' ? -value : value + if (typeof value === 'boolean' && node.operator === '!') return !value + } + if (t.isBinaryExpression(node) || t.isLogicalExpression(node)) { + const left = read('left') + const right = read('right') + if ( + node.operator === '+' && + (typeof left === 'string' || typeof left === 'number') && + (typeof right === 'string' || typeof right === 'number') + ) + return typeof left === 'string' || typeof right === 'string' + ? String(left) + String(right) + : left + right + if ( + typeof left === 'number' && + typeof right === 'number' && + ['-', '*', '/'].includes(node.operator) + ) { + const value = + node.operator === '-' ? left - right : node.operator === '*' ? left * right : left / right + if (Number.isFinite(value)) return value + } + if (node.operator === '&&' && typeof left === 'boolean') return left ? right : false + if (node.operator === '||' && typeof left === 'boolean') return left ? true : right + this.unresolved.add('Conditional or computed value') + return { $operator: node.operator, left, right } + } + if (t.isConditionalExpression(node)) { + const test = read('test') + if (typeof test === 'boolean') return test ? read('consequent') : read('alternate') + return { $condition: test, then: read('consequent'), else: read('alternate') } + } + if (t.isCallExpression(node)) { + const name = propertyName(node.callee) + const binding = t.isIdentifier(node.callee) ? path.scope.getBinding(name) : undefined + const imported = binding?.path.isImportSpecifier() || binding?.path.isImportDefaultSpecifier() + const declaration = imported ? binding?.path.parentPath : undefined + const specifier = declaration?.isImportDeclaration() ? declaration.node.source.value : '' + const helperFile = this.tree.resolve(file, specifier) + if (helperFile) this.dependencies.add(helperFile) + const classHelper = + this.tree.config.classModules.includes(specifier) || + helperFile === 'packages/emcn/src/lib/cn.ts' + const args = readList('arguments') + if (imported && classHelper && this.tree.config.classFunctions.includes(name)) { + const flatten = (data: Data): string | undefined => { + if (typeof data === 'string' || typeof data === 'number') return String(data) + if (data === false || data === null) return '' + if (Array.isArray(data)) { + const parts = data.map(flatten) + return parts.every((v) => v !== undefined) ? parts.filter(Boolean).join(' ') : undefined + } + if (object(data) && Object.values(data).every((v) => typeof v === 'boolean')) + return Object.keys(data) + .filter((key) => data[key]) + .join(' ') + return undefined + } + const classes = flatten(args) + return { $classes: classes ?? args, composition: name } + } + if ( + imported && + this.tree.config.variantModules.includes(specifier) && + this.tree.config.variantFunctions.includes(name) + ) + return { $cva: args } + const callee = read('callee') + if (object(callee) && Array.isArray(callee.$cva)) { + const [base, options] = callee.$cva + const selection = args[0] ?? {} + if ( + typeof base === 'string' && + object(options) && + object(selection) && + Object.values(selection).every( + (value) => value === null || ['string', 'boolean', 'number'].includes(typeof value) + ) + ) { + const variants = object(options.variants) ? options.variants : {} + const defaults = object(options.defaultVariants) ? options.defaultVariants : {} + const selected = { ...defaults, ...selection } + const classes: Data[] = [base] + for (const [variant, choices] of Object.entries(variants)) { + if (object(choices) && selected[variant] !== null) + classes.push(choices[String(selected[variant])] ?? null) + } + if (Array.isArray(options.compoundVariants)) { + for (const compound of options.compoundVariants) { + if (!object(compound)) continue + if ( + Object.entries(compound).every( + ([key, value]) => + ['class', 'className'].includes(key) || + (Array.isArray(value) ? value.includes(selected[key]) : value === selected[key]) + ) + ) + classes.push(compound.class ?? compound.className ?? null) + } + } + classes.push(selection.class ?? null, selection.className ?? null) + if (classes.every((value) => value === null || typeof value === 'string')) + return { $classes: classes.filter(Boolean).join(' '), composition: 'cva' } + } + this.unresolved.add('Runtime or unsupported CVA selection') + return { $variant: callee, selection: args } + } + this.unresolved.add('Function call is not executed') + return { $call: callee, arguments: args } + } + if (t.isJSXExpressionContainer(node)) return read('expression') + return this.unknown(path, 'Unsupported expression') + } +} diff --git a/scripts/design-diff/source.ts b/scripts/design-diff/source.ts new file mode 100644 index 00000000000..81c2fd8fcaa --- /dev/null +++ b/scripts/design-diff/source.ts @@ -0,0 +1,249 @@ +import path from 'node:path' +import * as t from '@babel/types' +import { parse as parseJson } from 'jsonc-parser' +import { parseSource, traverse } from '#design-diff/ast' +import type { Entry, GitReader } from '#design-diff/git' +import type { Config } from '#design-diff/types' + +export const scriptPattern = /\.[cm]?[jt]sx?$/ +export const assetPattern = + /\.(?:svg|png|jpe?g|gif|webp|avif|ico|bmp|apng|woff2?|ttf|otf|eot|mp4|webm|mov|pdf|tiff?|heic|lottie|glsl|wgsl|vert|frag)$/i +export const textPattern = /\.(?:[cm]?[jt]sx?|css|scss|sass|less|html?|mdx?|json|vue|svelte)$/ + +export function infrastructure(file: string, config: Config): boolean { + return ( + (!file.includes('/') || config.sourceRoots.some((root) => file.startsWith(root))) && + config.infrastructure.some((pattern) => new RegExp(pattern).test(file)) + ) +} + +export function scoped(file: string, config: Config): boolean { + return ( + config.sourceRoots.some((root) => file.startsWith(root)) && + !config.exclude.some((pattern) => new RegExp(pattern).test(file)) + ) +} + +export class SourceTree { + readonly entries: Map + readonly texts: Map + readonly dependencies = new Map>() + readonly failures = new Set() + private readonly packages = new Map }>() + private readonly resolutions = new Map() + + constructor( + reader: GitReader, + readonly commit: string, + readonly config: Config + ) { + this.entries = reader.tree(commit) + const entries = [...this.entries.values()].filter( + (entry) => + (scoped(entry.path, config) || + infrastructure(entry.path, config) || + entry.path === 'package.json') && + textPattern.test(entry.path) + ) + const readable = entries.filter((entry) => { + if (entry.size > config.limits.fileBytes || entry.mode === '120000') { + this.failures.add(entry.path) + return false + } + return true + }) + if (readable.reduce((size, entry) => size + entry.size, 0) > config.limits.totalBytes) { + throw new Error('Source snapshot exceeds the configured total byte limit') + } + this.texts = reader.blobs(readable) + for (const [file, source] of this.texts) { + if (!/^(?:apps|packages)\/[^/]+\/package\.json$/.test(file)) continue + try { + const manifest = JSON.parse(source) + if (typeof manifest.name === 'string') + this.packages.set(manifest.name, { root: path.posix.dirname(file), manifest }) + } catch { + this.failures.add(file) + } + } + } + + private candidate(base: string): string | undefined { + const normalized = path.posix.normalize(base) + if (normalized.startsWith('../') || normalized.startsWith('/')) return undefined + for (const suffix of [ + '', + '.ts', + '.tsx', + '.js', + '.jsx', + '.mts', + '.mjs', + '.json', + '.css', + '/index.ts', + '/index.tsx', + '/index.js', + ]) { + if (this.entries.has(normalized + suffix)) return normalized + suffix + } + if (/\.[cm]?js$/.test(normalized)) return this.candidate(normalized.replace(/\.[cm]?js$/, '')) + return undefined + } + + resolve(from: string, specifier: string): string | undefined { + const key = `${from}\0${specifier}` + if (this.resolutions.has(key)) return this.resolutions.get(key) + const resolved = this.resolveUncached(from, specifier) + this.resolutions.set(key, resolved) + return resolved + } + + private resolveUncached(from: string, specifier: string): string | undefined { + if (specifier.startsWith('.')) + return this.candidate(path.posix.join(path.posix.dirname(from), specifier)) + const root = from.split('/').slice(0, 2).join('/') + if (specifier.startsWith('/')) return this.candidate(`${root}/public${specifier}`) + const configText = this.texts.get(`${root}/tsconfig.json`) + if (configText) { + const compiler = parseJson(configText)?.compilerOptions + for (const [pattern, targets] of Object.entries(compiler?.paths ?? {})) { + const [prefix, suffix = ''] = pattern.split('*') + if ( + !(pattern.includes('*') + ? specifier.startsWith(prefix) && specifier.endsWith(suffix) + : specifier === pattern) + ) + continue + if (!Array.isArray(targets)) continue + for (const target of targets) { + if (typeof target !== 'string') continue + const resolved = this.candidate( + path.posix.join( + root, + compiler?.baseUrl ?? '.', + target.replace( + '*', + specifier.slice(prefix.length, suffix ? -suffix.length : undefined) + ) + ) + ) + if (resolved) return resolved + } + } + } + for (const alias of this.config.aliases) { + if (from.startsWith(alias.from) && specifier.startsWith(alias.prefix)) { + const resolved = this.candidate(alias.target + specifier.slice(alias.prefix.length)) + if (resolved) return resolved + } + } + for (const [name, pkg] of this.packages) { + if (specifier !== name && !specifier.startsWith(`${name}/`)) continue + const subpath = specifier === name ? '.' : `.${specifier.slice(name.length)}` + const exports = pkg.manifest.exports + const mappings = + typeof exports === 'string' + ? { '.': exports } + : (exports as Record | undefined) + for (const [pattern, target] of Object.entries(mappings ?? {})) { + const [prefix, suffix = ''] = pattern.split('*') + if ( + !(pattern.includes('*') + ? subpath.startsWith(prefix) && subpath.endsWith(suffix) + : subpath === pattern) + ) + continue + const value = + typeof target === 'string' + ? target + : target && typeof target === 'object' + ? ((target as Record).default ?? + (target as Record).import ?? + (target as Record).types) + : undefined + if (typeof value === 'string') { + const resolved = this.candidate( + path.posix.join( + pkg.root, + value.replace('*', subpath.slice(prefix.length, suffix ? -suffix.length : undefined)) + ) + ) + if (resolved) return resolved + } + } + return this.candidate( + path.posix.join( + pkg.root, + subpath === '.' + ? String(pkg.manifest.module ?? pkg.manifest.main ?? 'src/index') + : subpath + ) + ) + } + return undefined + } + + /** Builds an over-approximation of static imports and asset references in each revision. */ + buildGraph(): void { + for (const [file, source] of this.texts) { + const dependencies = new Set() + const add = (specifier: string) => { + const resolved = this.resolve(file, specifier) + if (resolved) dependencies.add(resolved) + } + if (scriptPattern.test(file)) { + try { + const ast = parseSource(source, file) + traverse(ast, { + ImportDeclaration(p) { + if (p.node.importKind !== 'type') add(p.node.source.value) + }, + ExportNamedDeclaration(p) { + if (p.node.source && p.node.exportKind !== 'type') add(p.node.source.value) + }, + ExportAllDeclaration(p) { + add(p.node.source.value) + }, + CallExpression(p) { + if ( + (t.isImport(p.node.callee) || t.isIdentifier(p.node.callee, { name: 'require' })) && + t.isStringLiteral(p.node.arguments[0]) + ) + add(p.node.arguments[0].value) + }, + StringLiteral(p) { + if (assetPattern.test(p.node.value)) add(p.node.value) + }, + }) + } catch { + this.failures.add(file) + } + } else { + for (const match of source.matchAll( + /(?:from\s*|import\s*|@import\s*|url\(\s*)["']([^"']+)["']/g + )) + add(match[1]) + } + this.dependencies.set(file, dependencies) + } + } + + affected(changed: Set): Set { + const reverse = new Map() + for (const [file, dependencies] of this.dependencies) { + for (const dependency of dependencies) + reverse.set(dependency, [...(reverse.get(dependency) ?? []), file]) + } + const result = new Set(changed) + const queue = [...changed] + for (let i = 0; i < queue.length; i++) { + for (const consumer of reverse.get(queue[i]) ?? []) { + if (result.has(consumer)) continue + result.add(consumer) + queue.push(consumer) + } + } + return result + } +} diff --git a/scripts/design-diff/tailwind.ts b/scripts/design-diff/tailwind.ts new file mode 100644 index 00000000000..5405f679b3d --- /dev/null +++ b/scripts/design-diff/tailwind.ts @@ -0,0 +1,213 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import postcss from 'postcss' +import { extendTailwindMerge } from 'tailwind-merge' +import { __unstable__loadDesignSystem } from 'tailwindcss' +import { cssValue, extractCss } from '#design-diff/extract/css' +import { object } from '#design-diff/resolve' +import type { SourceTree } from '#design-diff/source' +import type { Data, Definition } from '#design-diff/types' + +const require = createRequire(import.meta.url) +const defaultTheme = readFileSync( + path.join(path.dirname(require.resolve('tailwindcss/package.json')), 'theme.css'), + 'utf8' +) +type DesignSystem = Awaited> + +/** Only CSS data reaches the pinned compiler. No application module loader is provided. */ +export class TailwindNormalizer { + private readonly systems = new Map< + string, + Promise<{ + system: DesignSystem + variables: Definition[] + files: string[] + limitations: string[] + }> + >() + constructor(readonly tree: SourceTree) {} + + private theme(file: string) { + const cached = this.systems.get(file) + if (cached) return cached + const promise = (async () => { + const visited = new Set() + const variables: Definition[] = extractCss(defaultTheme, 'trusted:tailwind/theme.css') + const limitations: string[] = [] + const chunks = [defaultTheme] + const visit = (current: string) => { + if (visited.has(current)) return + visited.add(current) + const text = this.tree.texts.get(current) + if (text === undefined) { + limitations.push('Theme source unavailable') + return + } + const root = postcss.parse(text) + root.each((node) => { + if (node.type !== 'atrule') return + if (['theme', 'custom-variant', 'utility'].includes(node.name)) + chunks.push(node.toString()) + if (['plugin', 'config'].includes(node.name)) + limitations.push('Application JavaScript plugins/configuration are not executed') + if (node.name === 'import') { + const specifier = node.params.match(/^["']([^"']+)["']/)?.[1] + if (!specifier || specifier === 'tailwindcss') return + const target = this.tree.resolve(current, specifier) + if (target) visit(target) + else limitations.push(`External CSS import is not expanded: ${specifier}`) + } + }) + variables.push( + ...extractCss(text, current).filter((definition) => definition.property.startsWith('--')) + ) + } + if (file) visit(file) + const system = await __unstable__loadDesignSystem(chunks.join('\n'), { + loadModule: async () => { + throw new Error('Application modules are never loaded') + }, + loadStylesheet: async () => { + throw new Error('Nested stylesheet imports are not executed') + }, + }) + return { + system, + variables, + files: [...visited].sort(), + limitations: [...new Set(limitations)].sort(), + } + })() + this.systems.set(file, promise) + return promise + } + + async normalize(definition: Definition): Promise { + if (definition.kind !== 'class' && !JSON.stringify(definition.value).includes('var(--')) + return definition + const classes = (value: string) => value.split(/\s+/).filter(Boolean).join(' ') + if (definition.kind === 'class') { + if (typeof definition.value === 'string') + definition = { ...definition, value: classes(definition.value) } + else if (object(definition.value) && typeof definition.value.$classes === 'string') + definition = { + ...definition, + value: { ...definition.value, $classes: classes(definition.value.$classes) }, + } + } + const themes = this.tree.config.themes.filter((theme) => + theme.roots.some((root) => definition.location.file.startsWith(root)) + ) + const payloads: Data[] = [] + const unresolved = [...definition.unresolved] + const dependencies = new Set(definition.dependencies) + for (const theme of themes.length ? themes : [{ path: '', roots: [] }]) { + try { + const { + system, + variables, + files, + limitations: themeLimitations, + } = await this.theme(theme.path) + if (themeLimitations.includes('Theme source unavailable')) + unresolved.push('Theme source unavailable') + for (const file of files) dependencies.add(file) + const normalize = (data: Data): Data => { + if (typeof data === 'string') { + const order = data.split(/\s+/).filter(Boolean) + const css = + definition.kind === 'class' + ? system.candidatesToCss(order) + : [JSON.stringify(definition.value)] + const referenced = new Set() + const collect = (value: string, depth = 0, active = new Set()) => { + if (depth > 16) { + unresolved.push('CSS variable resolution depth exceeded') + return + } + for (const match of value.matchAll(/var\(\s*(--[\w-]+)/g)) { + if (active.has(match[1])) { + unresolved.push('CSS variable cycle') + continue + } + if (referenced.has(match[1])) continue + referenced.add(match[1]) + for (const variable of variables.filter((v) => v.property === match[1])) + collect(JSON.stringify(variable.value), depth + 1, new Set([...active, match[1]])) + } + } + css.forEach((value, i) => { + if (value === null) + unresolved.push(`Unsupported utility or custom class: ${order[i]}`) + else collect(value) + }) + return { + order, + css: css.map((value) => + value === null ? null : definition.kind === 'class' ? cssValue(value) : value + ), + variables: variables + .filter((variable) => referenced.has(variable.property)) + .map((variable) => ({ + property: variable.property, + value: variable.value, + conditions: variable.conditions, + })), + } + } + if (object(data) && '$classes' in data) { + const merge = extendTailwindMerge({ + extend: { classGroups: { 'font-size': [{ text: this.tree.config.mergeFontSizes }] } }, + }) + const effective = + typeof data.$classes === 'string' && + (data.composition === 'cn' || data.composition === 'twMerge') + ? merge(data.$classes) + : data.$classes + return { ...data, $classes: normalize(effective), inputOrder: data.$classes } + } + if (object(data) && Array.isArray(data.$cva)) { + const [base, options] = data.$cva + const variants = object(options) && object(options.variants) ? options.variants : {} + const normalized: Record = {} + for (const [name, choices] of Object.entries(variants)) { + const values: Record = {} + if (object(choices)) + for (const [choice, classes] of Object.entries(choices)) + values[choice] = normalize(classes) + normalized[name] = values + } + return { + ...data, + $cva: [ + normalize(base), + { ...(object(options) ? options : {}), variants: normalized }, + ], + } + } + if (object(data) && '$variant' in data) + return { ...data, $variant: normalize(data.$variant) } + if (data !== null && data !== false) unresolved.push('Unsupported class composition') + return data + } + payloads.push({ + theme: theme.path, + limitations: themeLimitations, + value: normalize( + definition.kind === 'class' ? definition.value : JSON.stringify(definition.value) + ), + }) + } catch { + unresolved.push('Theme or Tailwind compilation could not be resolved') + } + } + return { + ...definition, + value: { source: definition.value, normalized: payloads }, + dependencies: [...dependencies].sort(), + unresolved: [...new Set(unresolved)].sort(), + } + } +} diff --git a/scripts/design-diff/tests/analyze.test.ts b/scripts/design-diff/tests/analyze.test.ts new file mode 100644 index 00000000000..a928b3a58de --- /dev/null +++ b/scripts/design-diff/tests/analyze.test.ts @@ -0,0 +1,145 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { analyze } from '#design-diff/analyze' +import { compareFiles, config, FixtureRepo } from '#design-diff/tests/helpers' + +const cases: { name: string; before: string; after: string; category: string }[] = JSON.parse( + readFileSync(new URL('fixtures/visual-cases.json', import.meta.url), 'utf8') +) +const file = 'apps/sim/components/button.tsx' + +describe('visual policy integration', () => { + it.each(cases)('flags $name', async (fixture) => { + const report = await compareFiles({ [file]: fixture.before }, { [file]: fixture.after }) + expect(report.status).toBe('completed') + expect(report.flagged).toBe(true) + expect(report.findings.some((finding) => finding.category === fixture.category)).toBe(true) + expect( + report.findings.every( + (finding) => finding.before?.location.file === file || finding.after?.location.file === file + ) + ).toBe(true) + }) + + it.each([ + [ + 'comments', + 'export const A=()=>
Hi
', + '/** Changed comment */\nexport const A=()=>
Hi
', + ], + [ + 'formatting', + 'export const A=()=>
Hi
', + 'export const A = () => (\n
Hi
\n)', + ], + [ + 'types', + 'type T = string; export const A=()=>
Hi
', + 'type T = number; export const A=()=>
Hi
', + ], + [ + 'constant extraction', + 'export const A=()=>
', + 'const pad="p-4"; export const A=()=>
', + ], + [ + 'handler', + 'export const A=()=> ', + 'export const A=()=> ', + ], + ])('ignores supported %s noops', async (_name, before, after) => { + const report = await compareFiles( + { [file]: before }, + { [file]: after }, + { ...config, themes: [] } + ) + expect(report.findings).toEqual([]) + expect(report.flagged).toBe(false) + }) + + it('ignores class delimiter spacing', async () => { + const report = await compareFiles( + { [file]: 'export const A=()=>
' }, + { [file]: 'export const A=()=>
' }, + { ...config, themes: [] } + ) + expect(report.flagged).toBe(false) + }) + + it('preserves meaningful JSX whitespace', async () => { + const report = await compareFiles( + { [file]: 'export const A=()=>
A B
' }, + { [file]: 'export const A=()=>
AB
' } + ) + expect(report.flagged).toBe(true) + }) + + it('preserves JSX spread precedence', async () => { + const report = await compareFiles( + { [file]: 'export const A=(props)=>
' }, + { [file]: 'export const A=(props)=>
' } + ) + expect(report.flagged).toBe(true) + }) + + it('detects an early return that controls visibility', async () => { + const report = await compareFiles( + { [file]: 'export function A({enabled}){if(!enabled)return null;return
Hi
}' }, + { [file]: 'export function A({enabled}){if(enabled)return null;return
Hi
}' } + ) + expect(report.flagged).toBe(true) + }) + + it('tracks switch cases that select visible markup', async () => { + const report = await compareFiles( + { + [file]: + 'export function A({mode}){switch(mode){case "one":return
Hi
;default:return null}}', + }, + { + [file]: + 'export function A({mode}){switch(mode){case "two":return
Hi
;default:return null}}', + } + ) + expect(report.flagged).toBe(true) + }) + + it('reviews changed writes to a mutable style binding', async () => { + const report = await compareFiles( + { + [file]: + 'let colour="red";if(enabled)colour="blue";export const A=()=>
', + }, + { + [file]: + 'let colour="red";if(enabled)colour="green";export const A=()=>
', + } + ) + expect(report.flagged).toBe(true) + expect(report.findings.some((finding) => finding.decision === 'review')).toBe(true) + }) + + it('returns review for invalid syntax', async () => { + const report = await compareFiles( + { [file]: 'export const A=()=>
' }, + { [file]: 'export const A=()=> finding.decision === 'review')).toBe(true) + }) + + it('has deterministic ordering, IDs and commit metadata', async () => { + const repo = new FixtureRepo() + try { + const base = repo.commit({ [file]: cases[0].before }) + const head = repo.commit({ [file]: cases[0].after }) + const a = await analyze(repo.cwd, base, head, config) + const b = await analyze(repo.cwd, base, head, config) + expect(a).toEqual(b) + expect(a.commits).toEqual({ base, head, mergeBase: base }) + expect(a.findings[0].id).toMatch(/^[a-f0-9]{24}$/) + } finally { + repo.close() + } + }) +}) diff --git a/scripts/design-diff/tests/extract.test.ts b/scripts/design-diff/tests/extract.test.ts new file mode 100644 index 00000000000..84c8392ec57 --- /dev/null +++ b/scripts/design-diff/tests/extract.test.ts @@ -0,0 +1,77 @@ +import { expect, it } from 'vitest' +import { compareFiles } from '#design-diff/tests/helpers' + +it.each([ + ['apps/docs/content/a.mdx', '# Hello\n\nWelcome **friend**.', '# Hello\n\nWelcome **team**.'], + ['apps/docs/content/a.mdx', '', ''], + ['apps/docs/content/a.md', '---\ntitle: Hello\n---\n\nBody', '---\ntitle: Welcome\n---\n\nBody'], + [ + 'apps/desktop/src/renderer/index.html', + '

Hello

', + '

Hello

', + ], + [ + 'apps/sim/components/button.css', + '@media(min-width:600px){button{color:red!important}}', + '@media(min-width:800px){button{color:red!important}}', + ], + [ + 'apps/sim/components/button.css', + '@keyframes fade{from{opacity:0}to{opacity:1}}', + '@keyframes fade{from{opacity:0.5}to{opacity:1}}', + ], + [ + 'apps/desktop/src/main/window.ts', + 'new BrowserWindow({width:800,backgroundColor:"red"})', + 'new BrowserWindow({width:800,backgroundColor:"blue"})', + ], + [ + 'apps/sim/app/a.tsx', + 'export const A=()=>
One"}}/>', + 'export const A=()=>
Two"}}/>', + ], + ['apps/desktop/src/renderer/a.js', 'node.innerHTML="Hello"', 'node.innerHTML="Welcome"'], + ['tailwind.config.js', 'export default {plugins:[]}', 'export default {plugins:[customPlugin]}'], + [ + 'apps/desktop/src/main/window.ts', + 'win.setBackgroundColor("red")', + 'win.setBackgroundColor("blue")', + ], + [ + 'apps/desktop/src/main/window.ts', + 'nativeTheme.themeSource="light"', + 'nativeTheme.themeSource="dark"', + ], + [ + 'apps/desktop/src/main/terminal-themes.ts', + 'const script = "foreground:red"', + 'const script = "foreground:blue"', + ], +])('covers %s', async (file, before, after) => { + const report = await compareFiles({ [file]: before }, { [file]: after }) + expect(report.flagged).toBe(true) +}) + +it('preserves declaration and selector precedence', async () => { + const file = 'apps/sim/a.css' + const report = await compareFiles( + { [file]: '.a {color:red}.a {color:blue}' }, + { [file]: '.a {color:blue}.a {color:red}' } + ) + expect(report.flagged).toBe(true) +}) + +it('ignores CSS comments and formatting', async () => { + const file = 'apps/sim/a.css' + const report = await compareFiles( + { [file]: '.a{color:red}' }, + { [file]: '/** comment */\n.a {\n color: red;\n}' } + ) + expect(report.flagged).toBe(false) +}) + +it('keeps meaningful HTML preformatted whitespace', async () => { + const file = 'apps/desktop/a.html' + const report = await compareFiles({ [file]: '
 a b 
' }, { [file]: '
 a  b 
' }) + expect(report.flagged).toBe(true) +}) diff --git a/scripts/design-diff/tests/fixtures/visual-cases.json b/scripts/design-diff/tests/fixtures/visual-cases.json new file mode 100644 index 00000000000..040ecf827bb --- /dev/null +++ b/scripts/design-diff/tests/fixtures/visual-cases.json @@ -0,0 +1,62 @@ +[ + { + "name": "local colour", + "before": "export const Button = () => ", + "after": "export const Button = () => ", + "category": "colour" + }, + { + "name": "padding", + "before": "export const Button = () => ", + "after": "export const Button = () => ", + "category": "dimensions" + }, + { + "name": "width", + "before": "export const Button = () => ", + "after": "export const Button = () => ", + "category": "dimensions" + }, + { + "name": "font", + "before": "export const Button = () => ", + "after": "export const Button = () => ", + "category": "typography" + }, + { + "name": "shape", + "before": "export const Button = () => ", + "after": "export const Button = () => ", + "category": "shape-effects" + }, + { + "name": "wrapping", + "before": "export const Button = () => ", + "after": "export const Button = () => ", + "category": "layout" + }, + { + "name": "visibility", + "before": "export const Button = () => ", + "after": "export const Button = () => ", + "category": "visibility" + }, + { + "name": "visible copy", + "before": "export const Button = () => ", + "after": "export const Button = () => ", + "category": "content" + }, + { + "name": "motion", + "before": "export const Button = () => Go", + "after": "export const Button = () => Go", + "category": "motion" + }, + { + "name": "inline SVG", + "before": "export const Icon = () => ", + "after": "export const Icon = () => ", + "category": "content" + } +] diff --git a/scripts/design-diff/tests/git.test.ts b/scripts/design-diff/tests/git.test.ts new file mode 100644 index 00000000000..6f17dbdeb3b --- /dev/null +++ b/scripts/design-diff/tests/git.test.ts @@ -0,0 +1,71 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, it } from 'vitest' +import { analyze } from '#design-diff/analyze' +import { GitReader } from '#design-diff/git' +import { compareFiles, config, FixtureRepo } from '#design-diff/tests/helpers' + +it('compares the merge-base after staging diverges', async () => { + const repo = new FixtureRepo() + try { + const common = repo.commit({ 'apps/sim/a.tsx': 'export const A=()=>
Original
' }) + repo.git('checkout', '-b', 'feature') + const head = repo.commit({ 'README.md': 'A nonvisual change' }) + repo.git('checkout', 'staging') + const base = repo.commit({ 'apps/sim/a.tsx': 'export const A=()=>
Staging edit
' }) + const report = await analyze(repo.cwd, base, head, config) + expect(report.commits?.mergeBase).toBe(common) + expect(report.flagged).toBe(false) + } finally { + repo.close() + } +}) + +it('handles binary assets, deletions and filenames containing spaces, tabs and newlines', async () => { + const file = 'apps/sim/public/icon space\tline\n.png' + const changed = await compareFiles( + { [file]: Buffer.from([0, 1, 255]) }, + { [file]: Buffer.from([0, 2, 255]) } + ) + expect(changed.flagged).toBe(true) + expect(changed.findings[0].after?.location.file).toBe(file) + const removed = await compareFiles({ [file]: Buffer.from([0, 1, 255]) }, { [file]: null }) + expect(removed.findings[0].after).toBeNull() +}) + +it('handles renames and modifications without losing their old location', async () => { + const repo = new FixtureRepo() + try { + const base = repo.commit({ + 'apps/sim/old.tsx': 'export const A=()=> \n', + }) + repo.git('mv', 'apps/sim/old.tsx', 'apps/sim/new name.tsx') + const rename = repo.commit({}) + expect((await analyze(repo.cwd, base, rename, config)).flagged).toBe(false) + const head = repo.commit({ + 'apps/sim/new name.tsx': + 'export const A=()=> \n', + }) + expect((await analyze(repo.cwd, base, head, config)).flagged).toBe(true) + } finally { + repo.close() + } +}) + +it('fails explicitly for unreadable revisions and missing history', async () => { + const repo = new FixtureRepo() + const shallow = mkdtempSync(path.join(os.tmpdir(), 'design-diff-shallow-')) + try { + const base = repo.commit({ 'README.md': 'first' }) + repo.commit({ 'README.md': 'second' }) + expect(() => new GitReader(repo.cwd).compare('--help', 'HEAD')).toThrow() + await expect(analyze(repo.cwd, 'not-a-ref', 'HEAD', config)).rejects.toThrow() + execFileSync('git', ['clone', '--depth=1', `file://${repo.cwd}`, shallow], { stdio: 'pipe' }) + await expect(analyze(shallow, base, 'HEAD', config)).rejects.toThrow() + } finally { + repo.close() + rmSync(shallow, { recursive: true, force: true }) + } +}) diff --git a/scripts/design-diff/tests/helpers.ts b/scripts/design-diff/tests/helpers.ts new file mode 100644 index 00000000000..bf3b1a105cf --- /dev/null +++ b/scripts/design-diff/tests/helpers.ts @@ -0,0 +1,55 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { analyze } from '#design-diff/analyze' +import type { Config } from '#design-diff/types' + +export const config: Config = JSON.parse( + readFileSync(new URL('../../../design-diff.config.json', import.meta.url), 'utf8') +) +export type Files = Record + +export class FixtureRepo { + readonly cwd = mkdtempSync(path.join(os.tmpdir(), 'design-diff-')) + constructor() { + this.git('init', '--initial-branch=staging') + this.git('config', 'user.email', 'fixture@example.invalid') + this.git('config', 'user.name', 'Design diff fixture') + this.git('config', 'commit.gpgsign', 'false') + } + git(...args: string[]): string { + return execFileSync('git', args, { + cwd: this.cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim() + } + commit(files: Files): string { + for (const [file, contents] of Object.entries(files)) { + const target = path.join(this.cwd, file) + if (contents === null) rmSync(target, { force: true }) + else { + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, contents) + } + } + this.git('add', '--all') + this.git('commit', '--allow-empty', '-m', 'fixture') + return this.git('rev-parse', 'HEAD') + } + close() { + rmSync(this.cwd, { force: true, recursive: true }) + } +} + +export async function compareFiles(before: Files, after: Files, settings: Config = config) { + const repo = new FixtureRepo() + try { + const base = repo.commit(before) + const head = repo.commit(after) + return await analyze(repo.cwd, base, head, settings) + } finally { + repo.close() + } +} diff --git a/scripts/design-diff/tests/movement.test.ts b/scripts/design-diff/tests/movement.test.ts new file mode 100644 index 00000000000..4a7bf45357a --- /dev/null +++ b/scripts/design-diff/tests/movement.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import { compareFiles } from '#design-diff/tests/helpers' + +const file = 'apps/sim/icon.tsx' +const svg = (x: number, fill = 'red', extra = '') => + `export const A=()=> ` + +it('exempts a supported bounded movement', async () => { + const report = await compareFiles({ [file]: svg(20) }, { [file]: svg(30) }) + expect(report.flagged).toBe(false) + expect(report.findings.map((finding) => finding.decision)).toEqual(['exempt']) +}) + +it('flags mixed movement and appearance', async () => { + const report = await compareFiles({ [file]: svg(20) }, { [file]: svg(30, 'blue') }) + expect(report.flagged).toBe(true) + expect(report.findings.some((finding) => finding.category === 'colour')).toBe(true) +}) + +it('does not claim a movement proof when stylesheet rules can override the primitive', async () => { + const report = await compareFiles( + { [file]: svg(20), 'apps/sim/global.css': 'rect {width:90px}' }, + { [file]: svg(30) } + ) + expect(report.flagged).toBe(true) +}) + +it.each([ + ['clipping', svg(99)], + ['effects', svg(30, 'red', 'stroke="black"')], + ['context', svg(30, 'red', 'className="custom"')], +])('requires review for %s', async (_name, after) => { + const report = await compareFiles({ [file]: svg(20) }, { [file]: after }) + expect(report.flagged).toBe(true) +}) + +it.each(['marginLeft', 'gap', 'justifyContent', 'position', 'transform'])( + 'does not blanket-exempt %s', + async (property) => { + const report = await compareFiles( + { [file]: `export const A=()=>
` }, + { [file]: `export const A=()=>
` } + ) + expect(report.flagged).toBe(true) + expect(report.findings[0].decision).toBe('review') + } +) diff --git a/scripts/design-diff/tests/resolve.test.ts b/scripts/design-diff/tests/resolve.test.ts new file mode 100644 index 00000000000..43fcad79952 --- /dev/null +++ b/scripts/design-diff/tests/resolve.test.ts @@ -0,0 +1,147 @@ +import { expect, it } from 'vitest' +import { compareFiles, config } from '#design-diff/tests/helpers' + +const consumer = 'apps/sim/button.tsx' +const token = 'apps/sim/token.ts' + +it('resolves constants, object properties and template strings through aliases and re-exports', async () => { + const report = await compareFiles( + { + [token]: 'export const design = { padding: 4, colour: "red" } as const', + 'apps/sim/barrel.ts': 'export { design as theme } from "./token"', + [consumer]: `import {theme} from "@/barrel"; export const Button=()=> `, + }, + { [token]: 'export const design = { padding: 8, colour: "red" } as const' } + ) + expect(report.flagged).toBe(true) + expect( + report.findings + .filter((finding) => finding.after?.location.file === consumer) + .map((finding) => finding.category) + ).toEqual(['dimensions']) + expect(report.findings[0].dependencies).toContain(token) +}) + +it('reads workspace package exports without loading the package', async () => { + const token = 'packages/design/src/theme.ts' + const report = await compareFiles( + { + 'packages/design/package.json': + '{"name":"@sim/design","exports":{"./theme":"./src/theme.ts"}}', + [token]: 'export const padding = 4', + [consumer]: + 'import {padding} from "@sim/design/theme"; export const A=()=>
', + }, + { [token]: 'export const padding = 8' } + ) + expect( + report.findings.some( + (finding) => finding.after?.location.file === consumer && finding.decision === 'flag' + ) + ).toBe(true) +}) + +it('follows dependencies from both revisions when an import is replaced', async () => { + const report = await compareFiles( + { + [token]: 'export const padding=4', + 'apps/sim/other.ts': 'export const padding=8', + [consumer]: 'import {padding} from "./token"; export const A=()=>
', + }, + { [consumer]: 'import {padding} from "./other"; export const A=()=>
' } + ) + expect(report.flagged).toBe(true) + expect(report.findings[0].dependencies).toEqual( + expect.arrayContaining([token, 'apps/sim/other.ts']) + ) +}) + +it('bounds cycles and reviews unresolved changed consumers', async () => { + const report = await compareFiles( + { + [token]: 'import { padding as other } from "./other"; export const padding=other', + 'apps/sim/other.ts': 'import { padding as other } from "./token"; export const padding=other', + [consumer]: 'import {padding} from "./token"; export const A=()=>
', + }, + { [token]: 'import { padding as other } from "./other"; export const padding=other+1' } + ) + expect(report.flagged).toBe(true) + expect(report.findings.some((finding) => finding.decision === 'review')).toBe(true) +}) + +it('retains conditional branches and CVA variants/defaults', async () => { + const source = (size: string) => + `import {cva} from 'class-variance-authority'; const button=cva('rounded-md',{variants:{size:{sm:'p-2',lg:'p-4'}},defaultVariants:{size:'${size}'}}); export const A=()=>
` + const report = await compareFiles( + { [consumer]: source('sm') }, + { [consumer]: source('lg') }, + { ...config, themes: [] } + ) + expect(report.flagged).toBe(true) + expect(JSON.stringify(report.findings)).toContain('defaultVariants') +}) + +it('evaluates static CVA defaults and compound variants at an unchanged consumer', async () => { + const source = (size: string) => + `import {cva} from 'class-variance-authority'; const button=cva('rounded-md',{variants:{size:{sm:'p-2',lg:'p-4'}},defaultVariants:{size:'${size}'},compoundVariants:[{size:'lg',class:'font-bold'}]}); export const A=()=>
` + const report = await compareFiles( + { [consumer]: source('sm') }, + { [consumer]: source('lg') }, + { ...config, themes: [] } + ) + const finding = report.findings.find((finding) => finding.after?.property === 'className') + expect(finding?.decision).toBe('flag') + expect(JSON.stringify(finding?.after?.value)).toContain('rounded-md p-4 font-bold') +}) + +it('does not collapse class composition order', async () => { + const source = (classes: string) => + `import {clsx} from 'clsx'; export const A=()=>
` + const report = await compareFiles( + { [consumer]: source('"p-2","p-4"') }, + { [consumer]: source('"p-4","p-2"') }, + { ...config, themes: [] } + ) + expect(report.flagged).toBe(true) +}) + +it('reviews unsupported class helpers instead of executing or trusting their names', async () => { + const source = (value: string) => + `import {clsx} from 'untrusted-helper'; export const A=()=>
` + const report = await compareFiles( + { [consumer]: source('p-2') }, + { [consumer]: source('p-4') }, + { ...config, themes: [] } + ) + expect(report.findings.some((finding) => finding.decision === 'review')).toBe(true) +}) + +it('propagates changed imports into unresolved MDX expressions', async () => { + const document = 'apps/docs/content/a.mdx' + const report = await compareFiles( + { + 'apps/docs/token.ts': 'export const title = "First"', + [document]: 'import {title} from "../token"\n\n# Hello\n\n{title}', + }, + { 'apps/docs/token.ts': 'export const title = "Second"' } + ) + expect(report.findings.some((finding) => finding.after?.location.file === document)).toBe(true) +}) + +it('reviews a token change behind an unexecuted helper through transitive imports', async () => { + const report = await compareFiles( + { + [token]: 'export const padding=4', + 'apps/sim/helper.ts': + 'import {padding} from "./token"; export function getPadding(){return padding}', + [consumer]: + 'import {getPadding} from "./helper"; export const A=()=>
', + }, + { [token]: 'export const padding=8' } + ) + expect( + report.findings.some( + (finding) => finding.after?.location.file === consumer && finding.decision === 'review' + ) + ).toBe(true) +}) diff --git a/scripts/design-diff/tests/security.test.ts b/scripts/design-diff/tests/security.test.ts new file mode 100644 index 00000000000..5137642451b --- /dev/null +++ b/scripts/design-diff/tests/security.test.ts @@ -0,0 +1,90 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { analyze } from '#design-diff/analyze' +import { config, FixtureRepo } from '#design-diff/tests/helpers' + +it('never executes proposed source or JavaScript plugins', async () => { + const repo = new FixtureRepo() + const sentinel = path.join(repo.cwd, 'executed') + const payload = `require('node:fs').writeFileSync(${JSON.stringify(sentinel)},'bad')` + try { + const base = repo.commit({ 'apps/sim/a.tsx': 'export const A=()=>
' }) + const head = repo.commit({ + 'apps/sim/a.tsx': `${payload};export const A=()=>
{${payload};return 'p-4'})()}/>`, + 'apps/sim/postcss.config.cjs': `${payload};module.exports={}`, + 'apps/sim/app/_styles/globals.css': + '@plugin "../../postcss.config.cjs"; @theme {--spacing:4px;}', + }) + const report = await analyze(repo.cwd, base, head, config) + expect(report.flagged).toBe(true) + expect( + report.findings.some((finding) => finding.reason === 'Rendering infrastructure changed') + ).toBe(true) + expect(existsSync(sentinel)).toBe(false) + } finally { + repo.close() + } +}) + +it('writes failed JSON and exits nonzero for an operational failure', () => { + const repo = new FixtureRepo() + try { + repo.commit({ 'README.md': 'fixture' }) + const output = path.join(repo.cwd, 'result.json') + const cli = fileURLToPath(new URL('../cli.ts', import.meta.url)) + const run = spawnSync( + 'bun', + ['--no-env-file', cli, '--base', 'missing', '--head', 'HEAD', '--output', output], + { cwd: repo.cwd, encoding: 'utf8' } + ) + expect(run.status).toBe(1) + expect(run.stdout).toBe('') + expect(JSON.parse(readFileSync(output, 'utf8'))).toMatchObject({ + status: 'failed', + flagged: null, + }) + } finally { + repo.close() + } +}) + +it('exits successfully for completed flagged analysis and stays quiet with --output', () => { + const repo = new FixtureRepo() + try { + const base = repo.commit({ 'apps/sim/a.tsx': 'export const A=()=>
First
' }) + const head = repo.commit({ 'apps/sim/a.tsx': 'export const A=()=>
Second
' }) + const output = path.join(repo.cwd, 'result.json') + const cli = fileURLToPath(new URL('../cli.ts', import.meta.url)) + const stdout = execFileSync( + 'bun', + ['--no-env-file', cli, '--base', base, '--head', head, '--output', output], + { cwd: repo.cwd, encoding: 'utf8' } + ) + expect(stdout).toBe('') + expect(JSON.parse(readFileSync(output, 'utf8'))).toMatchObject({ + status: 'completed', + flagged: true, + }) + } finally { + repo.close() + } +}) + +it('reviews an affected file beyond the source-size limit', async () => { + const repo = new FixtureRepo() + try { + const base = repo.commit({ 'apps/sim/a.tsx': 'export const A=()=>
' }) + const head = repo.commit({ 'apps/sim/a.tsx': 'export const A=()=>
Changed
' }) + const report = await analyze(repo.cwd, base, head, { + ...config, + limits: { ...config.limits, fileBytes: 10 }, + }) + expect(report.flagged).toBe(true) + expect(report.findings[0].decision).toBe('review') + } finally { + repo.close() + } +}) diff --git a/scripts/design-diff/tests/tailwind.test.ts b/scripts/design-diff/tests/tailwind.test.ts new file mode 100644 index 00000000000..0236a5b448c --- /dev/null +++ b/scripts/design-diff/tests/tailwind.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from 'vitest' +import { compareFiles, config } from '#design-diff/tests/helpers' + +const file = 'apps/sim/button.tsx' +const theme = 'apps/sim/app/_styles/globals.css' + +it('normalizes pinned core utilities and per-app theme definitions', async () => { + const report = await compareFiles( + { + [theme]: '@theme {--spacing: 4px;--color-brand:red}', + [file]: 'export const A=()=>
', + }, + { [file]: 'export const A=()=>
' } + ) + const finding = report.findings.find((finding) => finding.after?.location.file === file) + expect(finding?.decision).toBe('flag') + expect(finding?.category).toBe('dimensions') + expect(JSON.stringify(finding?.after?.value)).toContain('padding') + expect(JSON.stringify(finding?.after?.value)).toContain('--color-brand') +}) + +it('reviews unsupported custom utilities', async () => { + const report = await compareFiles( + { [file]: 'export const A=()=>
' }, + { [file]: 'export const A=()=>
' }, + { ...config, themes: [] } + ) + expect(report.findings[0].decision).toBe('review') + expect(report.findings[0].limitations.join(' ')).toContain('Unsupported utility') +}) + +it('preserves dark/responsive/state variants', async () => { + const report = await compareFiles( + { [file]: 'export const A=()=>
' }, + { [file]: 'export const A=()=>
' }, + { ...config, themes: [] } + ) + expect(report.flagged).toBe(true) + expect(JSON.stringify(report.findings)).toContain('hover') +}) + +it('flags unchanged consumers of changed global theme variables', async () => { + const report = await compareFiles( + { + [theme]: '@theme inline {--color-brand:var(--brand)} :root {--brand:red}', + [file]: 'export const A=()=>
', + }, + { [theme]: '@theme inline {--color-brand:var(--brand)} :root {--brand:blue}' } + ) + expect(report.findings.some((finding) => finding.after?.location.file === file)).toBe(true) +}) + +it('resolves CSS variable evidence in unchanged inline styles', async () => { + const report = await compareFiles( + { + [theme]: ':root {--brand:red}', + [file]: 'export const A=()=>
', + }, + { [theme]: ':root {--brand:blue}' } + ) + expect(report.findings.some((finding) => finding.after?.location.file === file)).toBe(true) +}) + +it('applies the trusted cn merge convention without discarding input order', async () => { + const source = (classes: string) => + `import {cn} from '@sim/emcn/lib/cn'; export const A=()=>
` + const report = await compareFiles( + { [file]: source('p-2 p-4') }, + { [file]: source('p-4 p-2') }, + { ...config, themes: [] } + ) + expect(report.flagged).toBe(true) + expect(JSON.stringify(report.findings)).toContain('inputOrder') +}) diff --git a/scripts/design-diff/tsconfig.json b/scripts/design-diff/tsconfig.json new file mode 100644 index 00000000000..78247f5a900 --- /dev/null +++ b/scripts/design-diff/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/scripts/design-diff/types.ts b/scripts/design-diff/types.ts new file mode 100644 index 00000000000..534e55f4b7c --- /dev/null +++ b/scripts/design-diff/types.ts @@ -0,0 +1,90 @@ +export type Data = null | boolean | number | string | Data[] | { [key: string]: Data } +export type Decision = 'flag' | 'review' | 'exempt' +export type Category = + | 'colour' + | 'dimensions' + | 'typography' + | 'shape-effects' + | 'layout' + | 'visibility' + | 'content' + | 'motion' + | 'movement' + | 'infrastructure' + | 'unresolved' + | 'nonvisual' + +export interface Location { + file: string + line: number + column: number +} +export interface Evidence { + value: Data + unresolved: string[] + dependencies: string[] +} +export interface Definition extends Evidence { + key: string + kind: + | 'class' + | 'style' + | 'markup' + | 'content' + | 'attribute' + | 'css' + | 'native' + | 'asset' + | 'review' + property: string + location: Location + symbol: string + conditions: Data[] + movement?: { bounds: number[]; viewport: number[]; appearance: Data } +} +export interface Finding { + id: string + decision: Decision + category: Category + reason: string + before: { value: Data; location: Location; conditions: Data[]; property: string } | null + after: { value: Data; location: Location; conditions: Data[]; property: string } | null + symbol: string + consumers: string[] + dependencies: string[] + limitations: string[] +} +export interface Config { + sourceRoots: string[] + exclude: string[] + renderedMarkdown: string[] + aliases: { from: string; prefix: string; target: string }[] + themes: { roots: string[]; path: string }[] + classModules: string[] + variantModules: string[] + mergeFontSizes: string[] + nativeRendering: string[] + classFunctions: string[] + variantFunctions: string[] + nativeAppearance: string[] + infrastructure: string[] + renderingDependencies: string + limits: { + fileBytes: number + totalBytes: number + resolutionDepth: number + resolutionSteps: number + } +} +export interface Report { + schemaVersion: '1.0.0' + engineVersion: '0.1.0' + policyVersion: '1.0.0' + commits: { base: string; head: string; mergeBase: string } | null + status: 'completed' | 'failed' + flagged: boolean | null + findings: Finding[] + limitations: string[] + error?: string + context?: { pullRequest: number; headSha: string; engineSha: string } +} diff --git a/vitest.scripts.config.ts b/vitest.scripts.config.ts index 80d14604d7b..40bfa108146 100644 --- a/vitest.scripts.config.ts +++ b/vitest.scripts.config.ts @@ -13,6 +13,6 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'node', - include: ['scripts/*.test.ts'], + include: ['scripts/*.test.ts', 'scripts/design-diff/tests/**/*.test.ts'], }, }) From b92ff215fef2716c07b31996533ca43e51e667b2 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 10 Sep 2026 15:40:17 -0700 Subject: [PATCH 02/21] fix: resolve design-diff theme and EMCN conventions --- design-diff.config.json | 1 + scripts/design-diff/README.md | 26 ++++++++++++++++++++++ scripts/design-diff/tailwind.ts | 2 +- scripts/design-diff/tests/resolve.test.ts | 20 +++++++++++++++++ scripts/design-diff/tests/tailwind.test.ts | 24 ++++++++++++++++++++ 5 files changed, 72 insertions(+), 1 deletion(-) diff --git a/design-diff.config.json b/design-diff.config.json index 6c1ec5ed38b..78a8716907a 100644 --- a/design-diff.config.json +++ b/design-diff.config.json @@ -76,6 +76,7 @@ "clsx", "classnames", "tailwind-merge", + "@sim/emcn", "@sim/emcn/lib/cn", "@/lib/utils", "@/lib/cn" diff --git a/scripts/design-diff/README.md b/scripts/design-diff/README.md index 6c46e2604c1..6f002ac32cc 100644 --- a/scripts/design-diff/README.md +++ b/scripts/design-diff/README.md @@ -202,3 +202,29 @@ are whole commits, including ancillary changes, rather than only their headline These comparisons validate source-policy behavior, not rendered pixels or recall over all historical PRs. Screenshot capture, AI interpretation and Slack delivery are separate stages. + +### Incremental smoke test on PR #7742 + +Five temporary commits were created directly on `458a515cbed7fbcf127ce72348ff755c5308ce13`, +each changing one existing source file. Comparing that commit with each temporary head +isolated the test edit from the implementation PR's own dependency changes. No temporary +UI edits were checked out, pushed or included in the PR. + +| Incremental edit | Observed result after fixes | +| --- | --- | +| EMCN Button `rounded-[5px]` to `rounded-none` | Flagged; a `shape-effects` finding identifies `buttonVariants` | +| Send button token `bg-[#383838]` to `bg-[#E11D48]` | Flagged; `colour` review evidence reaches the unchanged `SendButton` consumer | +| Send button token `p-0` to `p-2` | Flagged; `dimensions` review evidence reaches the unchanged consumer | +| TSDoc wording only in EMCN Button | Clean; zero findings | +| Add `translate-x-2` to the send button token | Flagged for review; movement is not proven harmless in this runtime context | + +The experiment exposed and fixed dropped semicolons between CSS custom-variant statements +and missing recognition of the repository's `cn` import from `@sim/emcn`. Regression tests +cover both. All five overall flagging decisions matched expectations. One stricter category +assertion did not: generated translation declarations currently receive the broader `layout` +category, rather than `movement`; the conservative review decision is retained. + +Report noise remains substantial: the shared shape edit generated 2 static flags and 2,205 +review findings; each local token edit generated 93 review findings. These counts describe +potential effects, not independently verified visual regressions. Conditional consumers +still require review. This validates local engine behavior, not cloud workflow activation. diff --git a/scripts/design-diff/tailwind.ts b/scripts/design-diff/tailwind.ts index 5405f679b3d..9c3f8761864 100644 --- a/scripts/design-diff/tailwind.ts +++ b/scripts/design-diff/tailwind.ts @@ -49,7 +49,7 @@ export class TailwindNormalizer { root.each((node) => { if (node.type !== 'atrule') return if (['theme', 'custom-variant', 'utility'].includes(node.name)) - chunks.push(node.toString()) + chunks.push(`${node.toString()}${node.nodes ? '' : ';'}`) if (['plugin', 'config'].includes(node.name)) limitations.push('Application JavaScript plugins/configuration are not executed') if (node.name === 'import') { diff --git a/scripts/design-diff/tests/resolve.test.ts b/scripts/design-diff/tests/resolve.test.ts index 43fcad79952..6f7db17490e 100644 --- a/scripts/design-diff/tests/resolve.test.ts +++ b/scripts/design-diff/tests/resolve.test.ts @@ -105,6 +105,26 @@ it('does not collapse class composition order', async () => { expect(report.flagged).toBe(true) }) +it.each([ + ['bg-[#383838]', 'bg-[#E11D48]', 'colour'], + ['p-0', 'p-2', 'dimensions'], +])('resolves imported %s through the EMCN root cn export', async (before, after, category) => { + const report = await compareFiles( + { + [token]: `export const classes = '${before}'`, + [consumer]: + 'import {cn} from "@sim/emcn"; import {classes} from "./token"; export const A=()=> ', + }, + { + [token]: + 'export const Button=()=>
', + [unrelated]: + 'import {Icon} from "@sim/emcn";export const A=()=> ', +} + +it('groups a shared change once and counts only references to its defining module', async () => { + const report = await compareFiles( + fixture, + { [button]: fixture[button].replace('rounded-md', 'rounded-none') }, + settings + ) + expect(report.findings).toHaveLength(1) + const finding = report.findings[0] + expect(finding.decision).toBe('flag') + expect(finding.source.after?.file).toBe(button) + expect(finding.changes).toHaveLength(1) + expect(finding.category).toBe('shape-effects') + expect(finding.impact.before.referenceCount).toBe(2) + expect(finding.impact.after.referenceCount).toBe(2) + expect(finding.impact.after.fileCount).toBe(1) + expect(finding.consumers).toEqual([consumer]) + expect(finding.example?.change.after?.location.file).toBe(consumer) + expect(finding.limitations.join(' ')).toContain('not confirmed visual changes') + expect(JSON.stringify(report.findings)).not.toContain(unrelated) +}) + +it('retains multiple direct changes under one source without dropping their categories', async () => { + const report = await compareFiles( + { [consumer]: 'export const A=()=> ', + }, + { [data]: after }, + settings + ) + expect(report.flagged).toBe(false) +}) + +it('isolates nested properties and unrelated imported environment settings', async () => { + const source = (count: number) => + `import {createEnv} from '@t3-oss/env-nextjs';export const env=createEnv({server:{BATCH:rule(${count})},client:{NEXT_PUBLIC_DISABLED:rule(false)}});export const design={button:{colour:'red'},count:${count}}` + const report = await compareFiles( + { + [data]: source(4), + [view]: + 'import {env,design} from "./data";export const Page=()=>
', + }, + { [data]: component('') }, + settings + ) + expect(report.flagged).toBe(false) +}) + +it('recognizes an aliased class helper by its imported binding', async () => { + const source = (colour: string) => + `import {clsx as classes} from "clsx";export const Page=()=>