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:
parent
0773acefaa
commit
f29ceb4964
5 changed files with 706 additions and 2 deletions
324
web/pnpm-lock.yaml
generated
324
web/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
web/standalone/src/wasm/collab/sexpr-diff.test.ts
Normal file
110
web/standalone/src/wasm/collab/sexpr-diff.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
252
web/standalone/src/wasm/collab/sexpr-diff.ts
Normal file
252
web/standalone/src/wasm/collab/sexpr-diff.ts
Normal 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
|
||||
* field↔token 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 X↔Y 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 };
|
||||
}
|
||||
15
web/standalone/vitest.config.ts
Normal file
15
web/standalone/vitest.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue