feat(standalone): sexprDiff structural comparator + vitest (0003/0004 §B)

Add the shared, uuid-keyed structural s-expr comparator (README §B) used by both
the live drift check (0003) and the round-trip tests (0004), plus a vitest setup.

- wasm/collab/sexpr-diff.ts: zero-dependency module (importable from standalone
  app code and the Playwright specs). Tokenizes s-expr text into nested lists,
  indexes item nodes by their direct (uuid "X") child, compares item sets order-
  insensitively as multisets of normalized children, emitting
  { equal, added, removed, changed } with per-property a/b. ignoreTokens drops
  volatile tokens. False-positive-free only for same-serializer inputs.
- wasm/collab/sexpr-diff.test.ts: 15 cases (parse, equality, changes, ignoreTokens).
- vitest config + test scripts (node env, isolated from the app build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-10 18:06:06 +02:00
commit f29ceb4964
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 706 additions and 2 deletions

324
web/pnpm-lock.yaml generated
View file

@ -134,6 +134,9 @@ importers:
vite:
specifier: ^6.0.7
version: 6.4.2(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4)
vitest:
specifier: ^3.0.0
version: 3.2.6(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4)
packages:
@ -1019,6 +1022,12 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@ -1042,6 +1051,35 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@3.2.6':
resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==}
'@vitest/mocker@3.2.6':
resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.6':
resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==}
'@vitest/runner@3.2.6':
resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==}
'@vitest/snapshot@3.2.6':
resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==}
'@vitest/spy@3.2.6':
resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==}
'@vitest/utils@3.2.6':
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@ -1078,6 +1116,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@ -1110,6 +1152,10 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
camelcase-css@2.0.1:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
@ -1117,6 +1163,14 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@ -1156,6 +1210,10 @@ packages:
supports-color:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
@ -1172,6 +1230,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
@ -1186,6 +1247,13 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-content-type-parse@1.1.0:
resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==}
@ -1306,6 +1374,9 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@ -1344,6 +1415,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -1352,6 +1426,9 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@ -1410,6 +1487,13 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -1622,6 +1706,9 @@ packages:
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
@ -1633,6 +1720,15 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
@ -1665,10 +1761,28 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -1729,6 +1843,11 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@6.4.2:
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@ -1769,6 +1888,39 @@ packages:
yaml:
optional: true
vitest@3.2.6:
resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.6
'@vitest/ui': 3.2.6
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
y-partyserver@2.2.0:
resolution: {integrity: sha512-AIsTEZ3jPicBe3VHO5c/VdSGSaFr0J9cAGj6+sgRL/RnXLTNSkP3oQXyXf1y8hQNeXv/j9zYDT5NnRv2k1eMGA==}
peerDependencies:
@ -2439,6 +2591,13 @@ snapshots:
dependencies:
'@babel/types': 7.29.7
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {}
'@types/node@22.19.19':
@ -2468,6 +2627,48 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@3.2.6':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.6(vite@6.4.2(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4))':
dependencies:
'@vitest/spy': 3.2.6
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 6.4.2(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4)
'@vitest/pretty-format@3.2.6':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.6':
dependencies:
'@vitest/utils': 3.2.6
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.6':
dependencies:
'@vitest/pretty-format': 3.2.6
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.6':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.6':
dependencies:
'@vitest/pretty-format': 3.2.6
loupe: 3.2.1
tinyrainbow: 2.0.0
abstract-logging@2.0.1: {}
ajv-formats@2.1.1(ajv@8.20.0):
@ -2498,6 +2699,8 @@ snapshots:
dependencies:
tslib: 2.8.1
assertion-error@2.0.1: {}
atomic-sleep@1.0.0: {}
autoprefixer@10.5.0(postcss@8.5.15):
@ -2530,10 +2733,22 @@ snapshots:
node-releases: 2.0.46
update-browserslist-db: 1.2.3(browserslist@4.28.2)
cac@6.7.14: {}
camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001793: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
check-error@2.1.3: {}
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@ -2566,6 +2781,8 @@ snapshots:
dependencies:
ms: 2.1.3
deep-eql@5.0.2: {}
detect-node-es@1.1.0: {}
didyoumean@1.2.2: {}
@ -2576,6 +2793,8 @@ snapshots:
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
@ -2636,6 +2855,12 @@ snapshots:
escalade@3.2.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
expect-type@1.3.0: {}
fast-content-type-parse@1.1.0: {}
fast-decode-uri-component@1.0.1: {}
@ -2756,6 +2981,8 @@ snapshots:
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
jsesc@3.1.0: {}
json-schema-ref-resolver@1.0.1:
@ -2786,6 +3013,8 @@ snapshots:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@ -2794,6 +3023,10 @@ snapshots:
dependencies:
react: 18.3.1
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
merge2@1.4.1: {}
micromatch@4.0.8:
@ -2836,6 +3069,10 @@ snapshots:
path-parse@1.0.7: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@ -3046,6 +3283,8 @@ snapshots:
set-cookie-parser@2.7.2: {}
siginfo@2.0.0: {}
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
@ -3054,6 +3293,14 @@ snapshots:
split2@4.2.0: {}
stackback@0.0.2: {}
std-env@3.10.0: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
sucrase@3.35.1:
dependencies:
'@jridgewell/gen-mapping': 0.3.13
@ -3112,11 +3359,21 @@ snapshots:
dependencies:
real-require: 0.2.0
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.4: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@ -3169,6 +3426,27 @@ snapshots:
util-deprecate@1.0.2: {}
vite-node@3.2.4(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.4.2(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vite@6.4.2(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4):
dependencies:
esbuild: 0.25.12
@ -3183,6 +3461,52 @@ snapshots:
jiti: 1.21.7
tsx: 4.22.4
vitest@3.2.6(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.6
'@vitest/mocker': 3.2.6(vite@6.4.2(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4))
'@vitest/pretty-format': 3.2.6
'@vitest/runner': 3.2.6
'@vitest/snapshot': 3.2.6
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.4
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.17
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 6.4.2(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4)
vite-node: 3.2.4(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.19
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
y-partyserver@2.2.0(@cloudflare/workers-types@4.20260610.1)(partyserver@0.5.6(@cloudflare/workers-types@4.20260610.1))(yjs@13.6.31):
dependencies:
'@cloudflare/workers-types': 4.20260610.1

View file

@ -8,7 +8,9 @@
"link-wasm": "node scripts/link-wasm.mjs",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hocuspocus/provider": "^4.1.1",
@ -38,6 +40,7 @@
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^3.0.0"
}
}

View file

@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { parseSexpr, sexprDiff } from "./sexpr-diff";
// A tiny item helper: a list node `(type (uuid "ID") <props…>)` as text.
const item = (type: string, id: string, ...props: string[]) =>
`(${type} (uuid "${id}") ${props.join(" ")})`;
describe("parseSexpr", () => {
it("parses nested lists and atoms", () => {
expect(parseSexpr("(a b (c 1))")).toEqual([["a", "b", ["c", "1"]]]);
});
it("keeps quoted strings (with quotes) as single atoms, spaces preserved", () => {
expect(parseSexpr('(text "hello world")')).toEqual([["text", '"hello world"']]);
});
it("handles escaped quotes inside strings", () => {
expect(parseSexpr('(t "a\\"b")')).toEqual([["t", '"a\\"b"']]);
});
it("throws on unbalanced parens", () => {
expect(() => parseSexpr("(a (b)")).toThrow(/unbalanced/);
});
it("throws on an unterminated string", () => {
expect(() => parseSexpr('(a "oops)')).toThrow(/unterminated/);
});
});
describe("sexprDiff — equality", () => {
it("identical text is equal", () => {
const a = `(root ${item("seg", "u1", "(x 1)", "(y 2)")})`;
expect(sexprDiff(a, a).equal).toBe(true);
});
it("reordered items are equal (keyed by uuid)", () => {
const a = `(root ${item("seg", "u1", "(x 1)")} ${item("seg", "u2", "(x 2)")})`;
const b = `(root ${item("seg", "u2", "(x 2)")} ${item("seg", "u1", "(x 1)")})`;
expect(sexprDiff(a, b).equal).toBe(true);
});
it("reordered properties within an item are equal", () => {
const a = `(root ${item("seg", "u1", "(x 1)", "(y 2)", "(layer F)")})`;
const b = `(root ${item("seg", "u1", "(layer F)", "(y 2)", "(x 1)")})`;
expect(sexprDiff(a, b).equal).toBe(true);
});
it("whitespace differences are irrelevant", () => {
const a = `(root ${item("seg", "u1", "(x 1)")})`;
const b = `(root\n (seg (uuid "u1")\n (x 1) )\n)`;
expect(sexprDiff(a, b).equal).toBe(true);
});
});
describe("sexprDiff — changes", () => {
it("catches a single changed value with uuid + path + a/b", () => {
const a = `(root ${item("seg", "u1", "(x 1)", "(y 2)")})`;
const b = `(root ${item("seg", "u1", "(x 1)", "(y 9)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.changed).toHaveLength(1);
expect(r.changed[0]).toMatchObject({ uuid: "u1", path: "y", a: "(2 y)", b: "(9 y)" });
});
it("distinguishes a quoted string from the same bare token", () => {
const a = `(root ${item("t", "u1", '(val "5")')})`;
const b = `(root ${item("t", "u1", "(val 5)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.changed[0]).toMatchObject({ uuid: "u1", path: "val" });
});
it("detects an added item (present only in B)", () => {
const a = `(root ${item("seg", "u1", "(x 1)")})`;
const b = `(root ${item("seg", "u1", "(x 1)")} ${item("seg", "u2", "(x 2)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.added).toEqual(["u2"]);
expect(r.removed).toEqual([]);
});
it("detects a removed item (present only in A)", () => {
const a = `(root ${item("seg", "u1", "(x 1)")} ${item("seg", "u2", "(x 2)")})`;
const b = `(root ${item("seg", "u1", "(x 1)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.removed).toEqual(["u2"]);
expect(r.added).toEqual([]);
});
it("compares nested blob items by their own uuids", () => {
const a = `(root (fp (uuid "f1") (at 0 0) ${item("pad", "p1", "(size 1)")}))`;
const b = `(root (fp (uuid "f1") (at 0 0) ${item("pad", "p1", "(size 2)")}))`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
// The nested pad's value change surfaces under its own uuid…
expect(r.changed.some((c) => c.uuid === "p1" && c.path === "size")).toBe(true);
// …and the parent footprint differs too (it contains the pad subtree).
expect(r.changed.some((c) => c.uuid === "f1")).toBe(true);
});
});
describe("sexprDiff — ignoreTokens", () => {
it("ignores a volatile token so equal-but-for-version compares equal", () => {
const a = `(root ${item("seg", "u1", '(generator_version "8.0")', "(x 1)")})`;
const b = `(root ${item("seg", "u1", '(generator_version "9.9")', "(x 1)")})`;
expect(sexprDiff(a, b).equal).toBe(false); // by default the version diff shows
expect(sexprDiff(a, b, { ignoreTokens: ["generator_version"] }).equal).toBe(true);
});
});

View file

@ -0,0 +1,252 @@
/**
* `sexprDiff` a minimal, uuid-keyed STRUCTURAL comparator for KiCad s-expr text
* (README §B; shared by the live drift check 0003 and the round-trip tests 0004).
*
* It is deliberately NOT a domain parser a full KiCad parse could itself be a
* source of false positives. It does only four things:
*
* 1. Tokenize the s-expr text into a tree of nested lists + atoms (balanced
* parens, quoted strings with escapes, bare atoms). No KiCad semantics.
* 2. Index "item" nodes by uuid: any list node with a direct `(uuid "X")` child
* is an item keyed by `X`. Nested items are collected too.
* 3. Compare order-insensitively, strictly on values: the two item SETS must
* have the same uuid keys, and for each shared uuid the two nodes must be
* equal as multisets of normalized children (reordering of sibling
* properties / items is allowed; any added / removed / value-changed token
* is a diff).
* 4. Emit `{ equal, added, removed, changed }` for readable failures.
*
* IMPORTANT compare like-for-like. This is only false-positive-free when both
* inputs are produced by the SAME tool serializer (e.g. 0004 saves the live model
* AND a model rebuilt from the Y.Doc, both through the tool's own writer). Feeding
* a saved file against decomposed-scalar Y.Doc fields would need a per-type
* fieldtoken map exactly the trap this comparator avoids by construction.
*
* Tradeoff of the multiset rule: positional scalar tuples (e.g. `(at X Y)`) are
* compared as a multiset, so a pure XY swap with otherwise-equal values would
* not be flagged. Acceptable for same-serializer inputs (the serializer emits a
* consistent order on both sides); revisit if a value-swap drift class appears.
*
* No imports usable from standalone app code (`@/wasm/collab/sexpr-diff`) and
* from the Playwright specs (relative import) alike.
*/
/** Parsed node: a `string` is an atom (quoted strings keep their quotes, so a
* string value is distinct from the same bare token); an array is a list. */
export type SNode = string | SNode[];
export interface SexprChange {
uuid: string;
/** Best-effort location: the head keyword of the differing child, or "·atom"
* for a differing positional scalar. */
path: string;
/** The differing child as serialized on side A (the base/original), or null
* if absent on A. */
a: string | null;
/** The differing child as serialized on side B (the new), or null if absent. */
b: string | null;
}
export interface SexprDiffResult {
equal: boolean;
/** uuids present in B but not A. */
added: string[];
/** uuids present in A but not B. */
removed: string[];
/** per-property differences for uuids present in both. */
changed: SexprChange[];
}
export interface SexprDiffOptions {
/** Head keywords whose list nodes are dropped before comparing, at any depth
* for known-volatile serializer output (e.g. "generator_version"). Declared
* explicitly so drift is never hidden silently. */
ignoreTokens?: string[];
}
// ── Parser ──────────────────────────────────────────────────────────────────
/** Parse s-expr text into a flat list of top-level forms (usually one). Throws
* on unbalanced parens or an unterminated string. */
export function parseSexpr(src: string): SNode[] {
let i = 0;
const n = src.length;
const isWs = (c: string | undefined) =>
c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f";
const skipWs = () => {
while (i < n && isWs(src[i])) i++;
};
const parseString = (): string => {
const start = i;
i++; // opening quote
while (i < n) {
const c = src[i];
if (c === "\\") {
i += 2; // escape — skip the next char too
continue;
}
if (c === '"') {
i++;
return src.slice(start, i); // keep surrounding quotes
}
i++;
}
throw new Error("sexprDiff: unterminated string");
};
const parseAtom = (): string => {
const start = i;
while (i < n && !isWs(src[i]) && src[i] !== "(" && src[i] !== ")" && src[i] !== '"') i++;
return src.slice(start, i);
};
const parseList = (): SNode[] => {
i++; // consume '('
const list: SNode[] = [];
for (;;) {
skipWs();
if (i >= n) throw new Error("sexprDiff: unbalanced parens (EOF inside list)");
const c = src[i];
if (c === ")") {
i++;
return list;
}
if (c === "(") list.push(parseList());
else if (c === '"') list.push(parseString());
else list.push(parseAtom());
}
};
const forms: SNode[] = [];
for (;;) {
skipWs();
if (i >= n) break;
if (src[i] === ")") throw new Error("sexprDiff: unbalanced parens (stray ')')");
if (src[i] === "(") forms.push(parseList());
else if (src[i] === '"') forms.push(parseString());
else forms.push(parseAtom());
}
return forms;
}
// ── uuid indexing ─────────────────────────────────────────────────────────────
function stripQuotes(atom: string): string {
return atom.length >= 2 && atom.startsWith('"') && atom.endsWith('"')
? atom.slice(1, -1)
: atom;
}
/** The uuid of a list node, from its direct `(uuid "X")` child, or null. */
function directUuid(node: SNode[]): string | null {
for (const c of node) {
if (Array.isArray(c) && c[0] === "uuid" && typeof c[1] === "string") {
return stripQuotes(c[1]);
}
}
return null;
}
/** Collect every list node that has a direct uuid child, keyed by uuid. */
function collectItems(node: SNode, out: Map<string, SNode[]>): void {
if (!Array.isArray(node)) return;
const id = directUuid(node);
if (id !== null && !out.has(id)) out.set(id, node);
for (const c of node) collectItems(c, out);
}
// ── Canonicalization (order-insensitive multiset) ──────────────────────────────
function isIgnored(node: SNode, ignore: Set<string>): boolean {
return Array.isArray(node) && typeof node[0] === "string" && ignore.has(node[0]);
}
/** Canonical string of a node: atoms verbatim, lists as `(` + sorted children + `)`. */
function canonical(node: SNode, ignore: Set<string>): string {
if (!Array.isArray(node)) return node;
const kids = node
.filter((c) => !isIgnored(c, ignore))
.map((c) => canonical(c, ignore))
.sort();
return "(" + kids.join(" ") + ")";
}
/** Group an item's direct children by head keyword, as canonical strings. */
function groupByHead(node: SNode[], ignore: Set<string>): Map<string, string[]> {
const groups = new Map<string, string[]>();
for (const c of node) {
if (isIgnored(c, ignore)) continue;
const head = Array.isArray(c) && typeof c[0] === "string" ? c[0] : "·atom";
const list = groups.get(head) ?? [];
list.push(canonical(c, ignore));
groups.set(head, list);
}
return groups;
}
/** Per-property changes between two item nodes sharing a uuid. */
function diffItem(uuid: string, a: SNode[], b: SNode[], ignore: Set<string>): SexprChange[] {
const ga = groupByHead(a, ignore);
const gb = groupByHead(b, ignore);
const heads = new Set<string>([...ga.keys(), ...gb.keys()]);
const out: SexprChange[] = [];
for (const head of heads) {
const as = (ga.get(head) ?? []).slice().sort();
const bs = (gb.get(head) ?? []).slice().sort();
// Multiset difference of the canonical child strings under this head.
const countB = new Map<string, number>();
for (const s of bs) countB.set(s, (countB.get(s) ?? 0) + 1);
const onlyA: string[] = [];
for (const s of as) {
const c = countB.get(s) ?? 0;
if (c > 0) countB.set(s, c - 1);
else onlyA.push(s);
}
const onlyB: string[] = [];
for (const [s, c] of countB) for (let k = 0; k < c; k++) onlyB.push(s);
const max = Math.max(onlyA.length, onlyB.length);
for (let k = 0; k < max; k++) {
out.push({ uuid, path: head, a: onlyA[k] ?? null, b: onlyB[k] ?? null });
}
}
return out;
}
// ── Public API ─────────────────────────────────────────────────────────────────
/**
* Structurally compare two s-expr texts by uuid-keyed items.
*
* @param a base / original text (e.g. the as-loaded model save)
* @param b new text (e.g. the rebuilt-from-Y.Doc model save)
*/
export function sexprDiff(a: string, b: string, opts: SexprDiffOptions = {}): SexprDiffResult {
const ignore = new Set(opts.ignoreTokens ?? []);
const itemsA = new Map<string, SNode[]>();
const itemsB = new Map<string, SNode[]>();
collectItems(parseSexpr(a), itemsA);
collectItems(parseSexpr(b), itemsB);
const removed: string[] = [];
const added: string[] = [];
const changed: SexprChange[] = [];
for (const id of itemsA.keys()) if (!itemsB.has(id)) removed.push(id);
for (const id of itemsB.keys()) if (!itemsA.has(id)) added.push(id);
for (const [id, na] of itemsA) {
const nb = itemsB.get(id);
if (!nb) continue;
if (canonical(na, ignore) !== canonical(nb, ignore)) {
changed.push(...diffItem(id, na, nb, ignore));
}
}
const equal = added.length === 0 && removed.length === 0 && changed.length === 0;
return { equal, added: added.sort(), removed: removed.sort(), changed };
}

View file

@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
// Standalone unit-test config, intentionally separate from vite.config.ts so the
// test run doesn't pull in the React plugin / WASM-asset dev server. Pure-logic
// modules (e.g. the sexpr-diff comparator) run in the node environment.
export default defineConfig({
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});