From f29ceb4964f06ace5894e38fa46007816e690c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Wed, 10 Jun 2026 18:06:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(standalone):=20sexprDiff=20structural=20co?= =?UTF-8?q?mparator=20+=20vitest=20(0003/0004=20=C2=A7B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/pnpm-lock.yaml | 324 ++++++++++++++++++ web/standalone/package.json | 7 +- .../src/wasm/collab/sexpr-diff.test.ts | 110 ++++++ web/standalone/src/wasm/collab/sexpr-diff.ts | 252 ++++++++++++++ web/standalone/vitest.config.ts | 15 + 5 files changed, 706 insertions(+), 2 deletions(-) create mode 100644 web/standalone/src/wasm/collab/sexpr-diff.test.ts create mode 100644 web/standalone/src/wasm/collab/sexpr-diff.ts create mode 100644 web/standalone/vitest.config.ts diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 4582fd8..fea2780 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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 diff --git a/web/standalone/package.json b/web/standalone/package.json index 51acf3a..a8ce6a7 100644 --- a/web/standalone/package.json +++ b/web/standalone/package.json @@ -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" } } diff --git a/web/standalone/src/wasm/collab/sexpr-diff.test.ts b/web/standalone/src/wasm/collab/sexpr-diff.test.ts new file mode 100644 index 0000000..72734b8 --- /dev/null +++ b/web/standalone/src/wasm/collab/sexpr-diff.test.ts @@ -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") )` 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); + }); +}); diff --git a/web/standalone/src/wasm/collab/sexpr-diff.ts b/web/standalone/src/wasm/collab/sexpr-diff.ts new file mode 100644 index 0000000..e68fce6 --- /dev/null +++ b/web/standalone/src/wasm/collab/sexpr-diff.ts @@ -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): 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): 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 { + 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): Map { + const groups = new Map(); + 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): SexprChange[] { + const ga = groupByHead(a, ignore); + const gb = groupByHead(b, ignore); + const heads = new Set([...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(); + 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(); + const itemsB = new Map(); + 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 }; +} diff --git a/web/standalone/vitest.config.ts b/web/standalone/vitest.config.ts new file mode 100644 index 0000000..62549f3 --- /dev/null +++ b/web/standalone/vitest.config.ts @@ -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"], + }, +});