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(
+ ![
+ 'apps/sim/app/a.tsx',
+ 'apps/desktop/src/renderer/a.js',
+ 'tailwind.config.js',
+ 'apps/desktop/src/main/terminal-themes.ts',
+ ].includes(file)
+ )
+})
+
+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('exempts content-only HTML whitespace', async () => {
+ const file = 'apps/desktop/a.html'
+ const report = await compareFiles({ [file]: '
a b
' }, { [file]: '
a b
' })
+ expect(report.flagged).toBe(false)
+})
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..092f805781b
--- /dev/null
+++ b/scripts/design-diff/tests/git.test.ts
@@ -0,0 +1,96 @@
+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(false)
+ expect(changed.status).toBe('completed')
+ const removed = await compareFiles({ [file]: Buffer.from([0, 1, 255]) }, { [file]: null })
+ expect(removed.flagged).toBe(false)
+ const styled = 'apps/sim/component space\tline\n.tsx'
+ const appearance = await compareFiles(
+ { [styled]: 'export const A=()=>
' },
+ { [styled]: null }
+ )
+ expect(appearance.flagged).toBe(true)
+ expect(appearance.findings[0].before?.location.file).toBe(styled)
+ expect(appearance.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 })
+ }
+})
+
+it('rejects missing source blobs even when Git returns successful tree metadata', async () => {
+ const repo = new FixtureRepo()
+ try {
+ const file = 'apps/sim/control.tsx'
+ const base = repo.commit({ [file]: 'export const Control=()=>
' })
+ const head = repo.commit({ 'README.md': 'Nonvisual edit' })
+ const blob = repo.git('rev-parse', `${base}:${file}`)
+ rmSync(path.join(repo.cwd, '.git/objects', blob.slice(0, 2), blob.slice(2)))
+ expect(() => new GitReader(repo.cwd).tree(base)).toThrow('Unreadable Git blob metadata')
+ await expect(analyze(repo.cwd, base, head, config)).rejects.toThrow(
+ 'Unreadable Git blob metadata'
+ )
+ } finally {
+ repo.close()
+ }
+})
diff --git a/scripts/design-diff/tests/group.test.ts b/scripts/design-diff/tests/group.test.ts
new file mode 100644
index 00000000000..63b84671735
--- /dev/null
+++ b/scripts/design-diff/tests/group.test.ts
@@ -0,0 +1,151 @@
+import { expect, it } from 'vitest'
+import { allChanges, compareFiles, config } from '#design-diff/tests/helpers'
+
+const button = 'packages/emcn/src/button.tsx'
+const icon = 'packages/emcn/src/icon.tsx'
+const index = 'packages/emcn/src/index.ts'
+const consumer = 'apps/sim/page.tsx'
+const unrelated = 'apps/sim/icon-only.tsx'
+const settings = { ...config, themes: [] }
+const fixture = {
+ 'packages/emcn/package.json': '{"name":"@sim/emcn","exports":"./src/index.ts"}',
+ [button]: 'export const Button=(props)=>
',
+ [icon]: 'export const Icon=()=>
',
+ [index]: 'export * from "./button";export * from "./icon"',
+ [consumer]:
+ 'import {Button} from "@sim/emcn";export const A=()=>
',
+ [unrelated]:
+ 'import {Icon} from "@sim/emcn";export const A=()=>
',
+}
+
+it('retains a nearby visual consumer before a distant opaque consumer', async () => {
+ const token = 'apps/sim/token.ts'
+ const direct = 'apps/sim/z-title.tsx'
+ const report = await compareFiles(
+ {
+ [token]: 'export const title="red"',
+ [direct]:
+ 'import {title} from "./token";export const Title=()=> ',
+ 'apps/sim/helper.ts': 'import {title} from "./token";export const config=unknown(title)',
+ 'apps/sim/a-distant.tsx':
+ 'import {config} from "./helper";export const View=()=> ',
+ },
+ { [token]: 'export const title="blue"' },
+ settings
+ )
+ expect(report.findings[0].example?.change.after?.location.file).toBe(direct)
+ expect(report.findings[0].example?.change.category).toBe('colour')
+})
+
+it('keeps direct findings and declares partial indirect coverage after qualification', async () => {
+ const token = 'apps/sim/token.ts'
+ const other = 'apps/sim/other.tsx'
+ const files = {
+ [token]: 'export const colour="red"',
+ [consumer]: 'export const View=()=> ',
+ [other]: 'import {colour} from "./token";export const Other=()=> ',
+ 'apps/sim/z-extra.tsx':
+ 'import {colour} from "./token";export const Extra=()=>
',
+ }
+ const report = await compareFiles(
+ files,
+ {
+ [token]: 'export const colour="blue"',
+ [consumer]: files[consumer].replace('p-2', 'p-4'),
+ },
+ settings
+ )
+ expect(report.flagged).toBe(true)
+ expect(report.findings.some((finding) => finding.source.after?.file === consumer)).toBe(true)
+ expect(report.limitations.join(' ')).toContain(
+ 'additional indirect effects are not exhaustively catalogued'
+ )
+ expect(
+ (await compareFiles(files, { [token]: 'export const colour="blue"' }, settings)).flagged
+ ).toBe(true)
+})
+
+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=()=> ' },
+ { [consumer]: 'export const A=()=> ' },
+ settings
+ )
+ expect(report.findings).toHaveLength(1)
+ expect(report.findings[0].changes).toHaveLength(2)
+ expect(report.findings[0].categories).toEqual(['colour', 'dimensions'])
+})
+
+it('anchors imported token changes at their source and retains one changed consumer example', async () => {
+ const token = 'apps/sim/token.ts'
+ const second = 'apps/sim/second.tsx'
+ const view = 'import {colour} from "./token";export const A=()=> '
+ const report = await compareFiles(
+ { [token]: 'export const colour="red"', [consumer]: view, [second]: view },
+ { [token]: 'export const colour="blue"' },
+ settings
+ )
+ expect(report.findings).toHaveLength(1)
+ const finding = report.findings[0]
+ expect(finding.source.before?.file).toBe(token)
+ expect(finding.source.after?.file).toBe(token)
+ expect(finding.changes).toEqual([])
+ expect(finding.example?.basis).toBe('changed-definition')
+ expect(finding.example?.change.before?.value).toBe('red')
+ expect(finding.example?.change.after?.value).toBe('blue')
+ expect(finding.consumers).toEqual([consumer, second].sort())
+ expect(finding.impact.after.referenceCount).toBe(2)
+})
+
+it('keeps independent changed sources separate, including shared consumers', async () => {
+ const a = 'apps/sim/a.ts'
+ const b = 'apps/sim/b.ts'
+ const report = await compareFiles(
+ {
+ [a]: 'export const colour="red"',
+ [b]: 'export const padding=4',
+ [consumer]:
+ 'import {colour} from "./a";import {padding} from "./b";export const A=()=> ',
+ },
+ { [a]: 'export const colour="blue"', [b]: 'export const padding=8' },
+ settings
+ )
+ expect(report.findings.map((finding) => finding.source.after?.file)).toEqual([a, b])
+ expect(report.findings.every((finding) => finding.decision === 'flag')).toBe(true)
+})
+
+it('uses only binary decisions while retaining uncertainty and operational failure separately', async () => {
+ const report = await compareFiles(
+ { [consumer]: 'export const A=()=>
' },
+ { [consumer]: 'export const A=()=>
' },
+ settings
+ )
+ expect(report.schemaVersion).toBe('3.0.0')
+ expect(report.policyVersion).toBe('5.0.0')
+ expect(report.status).toBe('completed')
+ expect(report.flagged).toBe(false)
+ expect(report.findings.every((finding) => finding.decision === 'flag')).toBe(true)
+ expect(allChanges(report).every((change) => change.decision === 'flag')).toBe(true)
+ expect(report.limitations).toContain('Function call is not executed')
+})
diff --git a/scripts/design-diff/tests/helpers.ts b/scripts/design-diff/tests/helpers.ts
new file mode 100644
index 00000000000..956db2d2584
--- /dev/null
+++ b/scripts/design-diff/tests/helpers.ts
@@ -0,0 +1,63 @@
+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 { Change, Config, Report } 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
+
+/** Extracts retained source evidence and the representative consumer from grouped reports. */
+export function allChanges(report: Report): Change[] {
+ return report.findings.flatMap((finding) => [
+ ...finding.changes,
+ ...(finding.example ? [finding.example.change] : []),
+ ])
+}
+
+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/index-runtime.ts b/scripts/design-diff/tests/index-runtime.ts
new file mode 100644
index 00000000000..83b022295ee
--- /dev/null
+++ b/scripts/design-diff/tests/index-runtime.ts
@@ -0,0 +1,249 @@
+import assert from 'node:assert/strict'
+import { execFileSync } from 'node:child_process'
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { analyze } from '#design-diff/analyze'
+import { GitReader } from '#design-diff/git'
+import { Metrics, measurements } from '#design-diff/metrics'
+import { serializeReport } from '#design-diff/report'
+import { SourceTree } from '#design-diff/source'
+import { IndexStore } from '#design-diff/store'
+import { config, FixtureRepo } from '#design-diff/tests/helpers'
+import type { Config } from '#design-diff/types'
+import { Database } from 'bun:sqlite'
+
+const settings: Config = { ...config, themes: [] }
+const repo = new FixtureRepo()
+const directory = mkdtempSync(path.join(os.tmpdir(), 'design-index-test-'))
+const mode = process.argv[2]
+const file = 'apps/sim/panel space\tline\n.tsx'
+const tokens = 'apps/sim/palette.ts'
+const indexCLI = path.resolve(new URL('../index-cli.ts', import.meta.url).pathname)
+const diffCLI = path.resolve(new URL('../cli.ts', import.meta.url).pathname)
+try {
+ const base = repo.commit({
+ [tokens]: 'export const palette={control:{color:"red",padding:4},service:{retry:2}}',
+ [file]:
+ 'import {palette} from "./palette";export const Panel=()=>