From 44425f3209989bace0ecb9022e8d6eef8f07afd4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:56:46 +0000 Subject: [PATCH 01/14] Bump the npm group across 1 directory with 24 updates --- updated-dependencies: - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: npm - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-major dependency-group: npm - dependency-name: eslint dependency-version: 10.6.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: npm - dependency-name: prettier dependency-version: 3.9.5 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm - dependency-name: typescript-eslint dependency-version: 8.63.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm - dependency-name: typescript dependency-version: 7.0.2 dependency-type: direct:development update-type: version-update:semver-major dependency-group: npm - dependency-name: vitest dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-major dependency-group: npm - dependency-name: "@types/node" dependency-version: 26.1.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: npm - dependency-name: elkjs dependency-version: 0.11.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm - dependency-name: esbuild dependency-version: 0.28.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm - dependency-name: happy-dom dependency-version: 20.10.6 dependency-type: direct:development update-type: version-update:semver-major dependency-group: npm - dependency-name: markdown-it dependency-version: 14.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm - dependency-name: "@types/vscode" dependency-version: 1.125.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm - dependency-name: "@vscode/test-electron" dependency-version: 3.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: npm - dependency-name: mocha dependency-version: 11.7.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm - dependency-name: pdfkit dependency-version: 0.19.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm - dependency-name: marked dependency-version: 18.0.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm - dependency-name: "@playwright/test" dependency-version: 1.61.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: npm - dependency-name: monocart-coverage-reports dependency-version: 2.12.12 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm - dependency-name: tsx dependency-version: 4.23.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm - dependency-name: typedoc dependency-version: 0.28.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm - dependency-name: typedoc-plugin-markdown dependency-version: 4.12.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm - dependency-name: vite dependency-version: 8.1.4 dependency-type: direct:development update-type: version-update:semver-major dependency-group: npm ... Signed-off-by: dependabot[bot] --- package-lock.json | 4005 +++++++++++++++-------------- package.json | 10 +- packages/cli/package.json | 6 +- packages/typediagram/package.json | 12 +- packages/vscode/package.json | 18 +- packages/web/package.json | 22 +- 6 files changed, 2138 insertions(+), 1935 deletions(-) diff --git a/package-lock.json b/package-lock.json index 500aaf8..8d4777a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,11 @@ "packages/*" ], "devDependencies": { - "@eslint/js": "^9.0.0", - "@vitest/coverage-v8": "^2.1.9", - "eslint": "^9.0.0", - "prettier": "^3.0.0", - "typescript-eslint": "^8.0.0" + "@eslint/js": "^10.0.1", + "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.6.0", + "prettier": "^3.9.5", + "typescript-eslint": "^8.63.0" } }, "node_modules/@11ty/dependency-tree": { @@ -245,24 +245,10 @@ "node": "*" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -270,9 +256,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -280,13 +266,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -296,336 +282,67 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=12" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -639,28 +356,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -674,28 +373,10 @@ "node": ">=18" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -709,78 +390,6 @@ "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -820,171 +429,93 @@ "dev": true, "license": "MIT", "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@gerrit0/mini-shiki": { @@ -1071,27 +602,6 @@ "node": ">=12" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1120,6 +630,25 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -1146,6 +675,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1158,13 +697,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.1" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -1173,25 +712,10 @@ "node": ">=18" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -1201,12 +725,14 @@ "os": [ "android" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -1216,12 +742,14 @@ "os": [ "darwin" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -1231,27 +759,14 @@ "os": [ "darwin" ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -1261,27 +776,14 @@ "os": [ "freebsd" ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -1291,192 +793,134 @@ "os": [ "linux" ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ - "loong64" + "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", - "cpu": [ - "riscv64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", - "cpu": [ - "x64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -1486,29 +930,35 @@ "os": [ "openharmony" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "peer": true + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", @@ -1516,12 +966,14 @@ "os": [ "win32" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1531,22 +983,16 @@ "os": [ "win32" ], - "peer": true + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", - "cpu": [ - "x64" - ], + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true + "license": "MIT" }, "node_modules/@shikijs/engine-oniguruma": { "version": "3.23.0", @@ -1656,6 +1102,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", @@ -1666,10 +1119,46 @@ "tslib": "^2.8.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@typediagram/web": { "resolved": "packages/web", "link": true }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1727,13 +1216,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/pdfkit": { @@ -1761,24 +1250,41 @@ "license": "MIT" }, "node_modules/@types/vscode": { - "version": "1.116.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.116.0.tgz", - "integrity": "sha512-sYHp4MO6BqJ2PD7Hjt0hlIS3tMaYsVPJrd0RUjDJ8HtOYnyJIEej0bLSccM8rE77WrC+Xox/kdBwEFDO8MsxNA==", + "version": "1.125.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", + "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", - "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/type-utils": "8.58.2", - "@typescript-eslint/utils": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1791,7 +1297,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.2", + "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1807,16 +1313,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", - "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "engines": { @@ -1832,14 +1338,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", - "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.2", - "@typescript-eslint/types": "^8.58.2", + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "engines": { @@ -1854,14 +1360,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", - "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2" + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1872,9 +1378,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", - "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", "dev": true, "license": "MIT", "engines": { @@ -1889,15 +1395,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", - "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1914,9 +1420,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", - "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", "dev": true, "license": "MIT", "engines": { @@ -1928,16 +1434,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", - "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.2", - "@typescript-eslint/tsconfig-utils": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1956,86 +1462,411 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", - "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2" + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", - "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.2", - "eslint-visitor-keys": "^5.0.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], "dev": true, "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=16.20.0" } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", - "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.7", + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.12", - "magicast": "^0.3.5", - "std-env": "^3.8.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^1.2.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.9", - "vitest": "2.1.9" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2044,122 +1875,95 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vscode/test-electron": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", - "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-3.0.0.tgz", + "integrity": "sha512-TY5mC7aAjxSLDXsyjhrG8cJHgc/HLdiE5lvtW7hABYQrY24Qwozzr5UoO3HiuAM4Hzz4b7K/eZlwrCILj94CcA==", "dev": true, "license": "MIT", "dependencies": { @@ -2170,7 +1974,7 @@ "semver": "^7.6.2" }, "engines": { - "node": ">=16" + "node": ">=22" } }, "node_modules/a-sync-waterfall": { @@ -2240,9 +2044,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2332,6 +2136,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2471,24 +2287,17 @@ "pako": "~1.0.5" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", "dev": true, "license": "MIT", + "dependencies": { + "@types/node": "*" + }, "engines": { - "node": ">=6" + "node": ">=4.0" } }, "node_modules/camelcase": { @@ -2505,18 +2314,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2554,16 +2356,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2773,46 +2565,142 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "4.1.2", + "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", "tree-kill": "1.2.2", - "yargs": "17.7.2" + "yargs": "18.0.0" }, "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/concurrently/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", "dependencies": { - "has-flag": "^4.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=10" + "node": ">=20" + } + }, + "node_modules/concurrently/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/concurrently/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/concurrently/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/console-grid": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/console-grid/-/console-grid-2.2.4.tgz", @@ -2820,6 +2708,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -2873,16 +2768,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2910,6 +2795,16 @@ "node": ">=4" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dfa": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", @@ -3017,6 +2912,12 @@ "dev": true, "license": "MIT" }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -3059,44 +2960,12 @@ "errno": "cli.js" } }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } + "license": "MIT" }, "node_modules/escalade": { "version": "3.2.0", @@ -3129,33 +2998,33 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -3165,8 +3034,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3174,7 +3042,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -3189,66 +3057,37 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/esm-import-transformer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/esm-import-transformer/-/esm-import-transformer-3.0.5.tgz", @@ -3260,18 +3099,18 @@ } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3632,19 +3471,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -3713,19 +3539,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gray-matter": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", @@ -3767,18 +3580,35 @@ } }, "node_modules/happy-dom": { - "version": "15.11.7", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.7.tgz", - "integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==", + "version": "20.10.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.6.tgz", + "integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^4.5.0", - "webidl-conversions": "^7.0.0", - "whatwg-mimetype": "^3.0.0" + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/has-flag": { @@ -3917,23 +3747,6 @@ "dev": true, "license": "MIT" }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4156,21 +3969,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -4208,6 +4006,13 @@ "dev": true, "license": "MIT" }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -4319,6 +4124,279 @@ "immediate": "~3.0.5" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -4341,9 +4419,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", @@ -4403,13 +4481,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -4427,13 +4498,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -4476,15 +4540,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { @@ -4504,9 +4568,9 @@ } }, "node_modules/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", "funding": [ { "type": "github", @@ -4520,8 +4584,8 @@ "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -4531,9 +4595,9 @@ } }, "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.6.tgz", + "integrity": "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -4638,9 +4702,9 @@ } }, "node_modules/mocha": { - "version": "11.7.5", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", - "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "version": "11.7.6", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", "dev": true, "license": "MIT", "dependencies": { @@ -4754,9 +4818,9 @@ } }, "node_modules/monocart-coverage-reports": { - "version": "2.12.10", - "resolved": "https://registry.npmjs.org/monocart-coverage-reports/-/monocart-coverage-reports-2.12.10.tgz", - "integrity": "sha512-veL2la1QlqS4aPn1m5X+AjPtyK5SP72p/mypf7qRC2Yy0E/ba3gzOFYFMAcAp5Y5pgmSlkTa2SCfrOgUqPmc7g==", + "version": "2.12.12", + "resolved": "https://registry.npmjs.org/monocart-coverage-reports/-/monocart-coverage-reports-2.12.12.tgz", + "integrity": "sha512-d9FdUr2dn58Crweon0IE0zVi8r/i4vLvfvb2G7eL5vL5LfrxCB2X6F6qzuiwV6RioA4zbAI//7CYi6LjCvN3zA==", "dev": true, "license": "MIT", "dependencies": { @@ -4832,9 +4896,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -4915,6 +4979,20 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -5113,19 +5191,6 @@ "dev": true, "license": "(MIT AND Zlib)" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -5181,26 +5246,16 @@ } }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pdfkit": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.18.0.tgz", - "integrity": "sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", "dev": true, "license": "MIT", "dependencies": { @@ -5209,7 +5264,7 @@ "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", - "png-js": "^1.0.0" + "png-js": "^1.1.0" } }, "node_modules/picocolors": { @@ -5220,9 +5275,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -5233,13 +5288,13 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -5252,9 +5307,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5299,9 +5354,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -5319,7 +5374,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5391,9 +5446,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", "bin": { @@ -5521,26 +5576,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -5565,49 +5600,38 @@ "dev": true, "license": "MIT" }, - "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/rxjs": { @@ -5736,9 +5760,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -5836,9 +5860,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -6015,21 +6039,6 @@ "pdfkit": ">=0.8.1" } }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -6045,16 +6054,19 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -6068,30 +6080,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -6152,14 +6144,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -6172,9 +6163,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -6189,9 +6180,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -6206,9 +6197,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -6223,9 +6214,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -6240,9 +6231,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -6257,9 +6248,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -6274,9 +6265,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -6291,9 +6282,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -6308,9 +6299,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -6325,9 +6316,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -6342,9 +6333,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -6359,9 +6350,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -6376,9 +6367,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -6393,9 +6384,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -6410,9 +6401,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -6427,9 +6418,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -6444,9 +6435,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -6460,27 +6451,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -6494,27 +6468,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -6529,9 +6486,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -6546,9 +6503,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -6563,9 +6520,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -6580,9 +6537,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -6597,9 +6554,9 @@ } }, "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6610,32 +6567,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/type-check": { @@ -6664,17 +6621,17 @@ "link": true }, "node_modules/typedoc": { - "version": "0.28.19", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.19.tgz", - "integrity": "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==", + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", - "markdown-it": "^14.1.1", + "markdown-it": "^14.3.0", "minimatch": "^10.2.5", - "yaml": "^2.8.3" + "yaml": "^2.9.0" }, "bin": { "typedoc": "bin/typedoc" @@ -6688,9 +6645,9 @@ } }, "node_modules/typedoc-plugin-markdown": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.11.0.tgz", - "integrity": "sha512-2iunh2ALyfyh204OF7h2u0kuQ84xB3jFZtFyUr01nThJkLvR8oGGSSDlyt2gyO4kXhvUxDcVbO0y43+qX+wFbw==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.12.0.tgz", + "integrity": "sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg==", "dev": true, "license": "MIT", "engines": { @@ -6706,6 +6663,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6715,16 +6673,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", - "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.2", - "@typescript-eslint/parser": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2" + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6745,9 +6703,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -6814,158 +6772,197 @@ "dev": true, "license": "MIT" }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" }, "bin": { - "vite": "bin/vite.js" + "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "less": { + "@vitest/browser-playwright": { "optional": true }, - "lightningcss": { + "@vitest/browser-preview": { "optional": true }, - "sass": { + "@vitest/browser-webdriverio": { "optional": true }, - "sass-embedded": { + "@vitest/coverage-istanbul": { "optional": true }, - "stylus": { + "@vitest/coverage-v8": { "optional": true }, - "sugarss": { + "@vitest/ui": { "optional": true }, - "terser": { + "happy-dom": { + "optional": true + }, + "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/vite-node/node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "node_modules/vitest/node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, - "license": "MIT" - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" }, "bin": { - "vitest": "vitest.mjs" + "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "@edge-runtime/vm": { + "@types/node": { "optional": true }, - "@types/node": { + "@vitejs/devtools": { "optional": true }, - "@vitest/browser": { + "esbuild": { "optional": true }, - "@vitest/ui": { + "jiti": { "optional": true }, - "happy-dom": { + "less": { "optional": true }, - "jsdom": { + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } @@ -6984,16 +6981,6 @@ "dev": true, "license": "MIT" }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -7153,9 +7140,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -7185,9 +7172,9 @@ } }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { @@ -7314,9 +7301,44 @@ "typediagram": "dist/bin.js" }, "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "@types/node": "^26.1.1", + "typescript": "^7.0.2", + "vitest": "^4.1.10" + } + }, + "packages/cli/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "packages/typediagram": { @@ -7324,20 +7346,20 @@ "version": "0.9.0", "license": "MIT", "dependencies": { - "elkjs": "^0.9.3" + "elkjs": "^0.11.1" }, "devDependencies": { - "@types/node": "^22.0.0", - "esbuild": "^0.24.0", - "happy-dom": "^15.0.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "@types/node": "^26.1.1", + "esbuild": "^0.28.1", + "happy-dom": "^20.10.6", + "typescript": "^7.0.2", + "vitest": "^4.1.10" } }, "packages/typediagram/node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -7352,9 +7374,9 @@ } }, "packages/typediagram/node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -7369,9 +7391,9 @@ } }, "packages/typediagram/node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -7386,9 +7408,9 @@ } }, "packages/typediagram/node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -7403,9 +7425,9 @@ } }, "packages/typediagram/node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -7420,9 +7442,9 @@ } }, "packages/typediagram/node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -7437,9 +7459,9 @@ } }, "packages/typediagram/node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -7454,9 +7476,9 @@ } }, "packages/typediagram/node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -7471,9 +7493,9 @@ } }, "packages/typediagram/node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -7488,9 +7510,9 @@ } }, "packages/typediagram/node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -7505,9 +7527,9 @@ } }, "packages/typediagram/node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -7522,9 +7544,9 @@ } }, "packages/typediagram/node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -7539,9 +7561,9 @@ } }, "packages/typediagram/node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -7556,9 +7578,9 @@ } }, "packages/typediagram/node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -7573,9 +7595,9 @@ } }, "packages/typediagram/node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -7590,9 +7612,9 @@ } }, "packages/typediagram/node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -7607,9 +7629,9 @@ } }, "packages/typediagram/node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -7624,9 +7646,9 @@ } }, "packages/typediagram/node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -7641,9 +7663,9 @@ } }, "packages/typediagram/node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -7658,9 +7680,9 @@ } }, "packages/typediagram/node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -7675,9 +7697,9 @@ } }, "packages/typediagram/node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -7692,9 +7714,9 @@ } }, "packages/typediagram/node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -7709,9 +7731,9 @@ } }, "packages/typediagram/node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -7725,14 +7747,10 @@ "node": ">=18" } }, - "packages/typediagram/node_modules/elkjs": { - "version": "0.9.3", - "license": "EPL-2.0" - }, "packages/typediagram/node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7743,31 +7761,67 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "packages/typediagram/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "packages/vscode": { @@ -7775,22 +7829,22 @@ "version": "0.9.0", "license": "MIT", "dependencies": { - "markdown-it": "^14.1.0", + "markdown-it": "^14.3.0", "typediagram-core": "0.9.0" }, "devDependencies": { "@types/markdown-it": "^14.1.2", "@types/mocha": "^10.0.10", - "@types/node": "^22.0.0", + "@types/node": "^26.1.1", "@types/pdfkit": "^0.17.6", - "@types/vscode": "^1.99.0", - "@vscode/test-electron": "^2.5.2", - "esbuild": "^0.24.0", - "mocha": "^11.7.5", - "pdfkit": "^0.18.0", + "@types/vscode": "^1.125.0", + "@vscode/test-electron": "^3.0.0", + "esbuild": "^0.28.1", + "mocha": "^11.7.6", + "pdfkit": "^0.19.1", "svg-to-pdfkit": "^0.1.8", - "typescript": "^5.6.0", - "vitest": "^2.1.0", + "typescript": "^7.0.2", + "vitest": "^4.1.10", "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.1.0" }, @@ -7799,9 +7853,9 @@ } }, "packages/vscode/node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -7816,9 +7870,9 @@ } }, "packages/vscode/node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -7833,9 +7887,9 @@ } }, "packages/vscode/node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -7850,9 +7904,9 @@ } }, "packages/vscode/node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -7867,9 +7921,9 @@ } }, "packages/vscode/node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -7884,9 +7938,9 @@ } }, "packages/vscode/node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -7901,9 +7955,9 @@ } }, "packages/vscode/node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -7918,9 +7972,9 @@ } }, "packages/vscode/node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -7935,9 +7989,9 @@ } }, "packages/vscode/node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -7952,9 +8006,9 @@ } }, "packages/vscode/node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -7969,9 +8023,9 @@ } }, "packages/vscode/node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -7986,9 +8040,9 @@ } }, "packages/vscode/node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -8003,9 +8057,9 @@ } }, "packages/vscode/node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -8020,9 +8074,9 @@ } }, "packages/vscode/node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -8037,9 +8091,9 @@ } }, "packages/vscode/node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -8054,9 +8108,9 @@ } }, "packages/vscode/node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -8071,9 +8125,9 @@ } }, "packages/vscode/node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -8088,9 +8142,9 @@ } }, "packages/vscode/node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -8105,9 +8159,9 @@ } }, "packages/vscode/node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -8122,9 +8176,9 @@ } }, "packages/vscode/node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -8139,9 +8193,9 @@ } }, "packages/vscode/node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -8156,9 +8210,9 @@ } }, "packages/vscode/node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -8173,9 +8227,9 @@ } }, "packages/vscode/node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -8190,9 +8244,9 @@ } }, "packages/vscode/node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8203,54 +8257,203 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "packages/vscode/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "packages/web": { "name": "@typediagram/web", "version": "0.9.0", "dependencies": { - "marked": "^18.0.0", + "marked": "^18.0.6", "typediagram-core": "0.9.0" }, "devDependencies": { "@11ty/eleventy": "^3.1.6", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.61.1", "@types/prismjs": "^1.26.6", - "concurrently": "^9.2.1", - "happy-dom": "^15.0.0", - "monocart-coverage-reports": "^2.12.10", + "concurrently": "^10.0.3", + "happy-dom": "^20.10.6", + "monocart-coverage-reports": "^2.12.12", "prismjs": "^1.30.0", - "tsx": "^4.21.0", - "typedoc": "^0.28.19", - "typedoc-plugin-markdown": "^4.11.0", - "typescript": "^5.6.0", - "vite": "^5.4.0", - "vitest": "^2.1.0" + "tsx": "^4.23.0", + "typedoc": "^0.28.20", + "typedoc-plugin-markdown": "^4.12.0", + "typescript": "^7.0.2", + "vite": "^8.1.4", + "vitest": "^4.1.10" + } + }, + "packages/web/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "packages/web/node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } } } diff --git a/package.json b/package.json index 8055716..c9d2414 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,10 @@ "fmt:check": "prettier --check ." }, "devDependencies": { - "@eslint/js": "^9.0.0", - "@vitest/coverage-v8": "^2.1.9", - "eslint": "^9.0.0", - "prettier": "^3.0.0", - "typescript-eslint": "^8.0.0" + "@eslint/js": "^10.0.1", + "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.6.0", + "prettier": "^3.9.5", + "typescript-eslint": "^8.63.0" } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 9e19562..cc9c659 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,8 +27,8 @@ "typediagram-core": "0.9.0" }, "devDependencies": { - "typescript": "^5.6.0", - "vitest": "^2.1.0", - "@types/node": "^22.0.0" + "typescript": "^7.0.2", + "vitest": "^4.1.10", + "@types/node": "^26.1.1" } } diff --git a/packages/typediagram/package.json b/packages/typediagram/package.json index 62d8d18..4fab6c0 100644 --- a/packages/typediagram/package.json +++ b/packages/typediagram/package.json @@ -52,13 +52,13 @@ "check": "npm run check-banned-deps && npm run typecheck && npm run test" }, "dependencies": { - "elkjs": "^0.9.3" + "elkjs": "^0.11.1" }, "devDependencies": { - "esbuild": "^0.24.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0", - "@types/node": "^22.0.0", - "happy-dom": "^15.0.0" + "esbuild": "^0.28.1", + "typescript": "^7.0.2", + "vitest": "^4.1.10", + "@types/node": "^26.1.1", + "happy-dom": "^20.10.6" } } diff --git a/packages/vscode/package.json b/packages/vscode/package.json index c69b304..ccb1b55 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -158,22 +158,22 @@ "package": "node package-vsix.mjs" }, "dependencies": { - "markdown-it": "^14.1.0", + "markdown-it": "^14.3.0", "typediagram-core": "0.9.0" }, "devDependencies": { "@types/markdown-it": "^14.1.2", "@types/mocha": "^10.0.10", - "@types/node": "^22.0.0", + "@types/node": "^26.1.1", "@types/pdfkit": "^0.17.6", - "@types/vscode": "^1.99.0", - "@vscode/test-electron": "^2.5.2", - "esbuild": "^0.24.0", - "mocha": "^11.7.5", - "pdfkit": "^0.18.0", + "@types/vscode": "^1.125.0", + "@vscode/test-electron": "^3.0.0", + "esbuild": "^0.28.1", + "mocha": "^11.7.6", + "pdfkit": "^0.19.1", "svg-to-pdfkit": "^0.1.8", - "typescript": "^5.6.0", - "vitest": "^2.1.0", + "typescript": "^7.0.2", + "vitest": "^4.1.10", "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.1.0" } diff --git a/packages/web/package.json b/packages/web/package.json index 7076788..95daea7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -21,22 +21,22 @@ "test": "npm run test:unit && npm run test:e2e && npm run test:merge" }, "dependencies": { - "marked": "^18.0.0", + "marked": "^18.0.6", "typediagram-core": "0.9.0" }, "devDependencies": { "@11ty/eleventy": "^3.1.6", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.61.1", "@types/prismjs": "^1.26.6", - "concurrently": "^9.2.1", - "happy-dom": "^15.0.0", - "monocart-coverage-reports": "^2.12.10", + "concurrently": "^10.0.3", + "happy-dom": "^20.10.6", + "monocart-coverage-reports": "^2.12.12", "prismjs": "^1.30.0", - "tsx": "^4.21.0", - "typedoc": "^0.28.19", - "typedoc-plugin-markdown": "^4.11.0", - "typescript": "^5.6.0", - "vite": "^5.4.0", - "vitest": "^2.1.0" + "tsx": "^4.23.0", + "typedoc": "^0.28.20", + "typedoc-plugin-markdown": "^4.12.0", + "typescript": "^7.0.2", + "vite": "^8.1.4", + "vitest": "^4.1.10" } } From d225baad2dc1aaae8cf20092df14d374b0ab3878 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:21:40 +1000 Subject: [PATCH 02/14] Pin @types/vscode to match engines.vscode for VSIX packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vsce package rejects a release when @types/vscode (^1.125.0, bumped by a dependency update) is newer than engines.vscode (^1.99.0). The extension only uses the 1.99-era API, so pin @types/vscode back to ^1.99.0 to match the declared engine floor — restoring the v0.11.0 configuration that packaged cleanly. Verified: vscode typecheck passes and `vsce package` succeeds. --- package-lock.json | 2 +- packages/vscode/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index b84f418..dc60c74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7450,7 +7450,7 @@ "@types/mocha": "^10.0.10", "@types/node": "^26.1.0", "@types/pdfkit": "^0.17.6", - "@types/vscode": "^1.125.0", + "@types/vscode": "^1.99.0", "@vscode/test-electron": "^3.0.0", "esbuild": "^0.28.1", "mocha": "^11.7.6", diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 993337a..ff12394 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -166,7 +166,7 @@ "@types/mocha": "^10.0.10", "@types/node": "^26.1.0", "@types/pdfkit": "^0.17.6", - "@types/vscode": "^1.125.0", + "@types/vscode": "^1.99.0", "@vscode/test-electron": "^3.0.0", "esbuild": "^0.28.1", "mocha": "^11.7.6", From 96efc29951edfc23ebb8691343a371e49e0fed2b Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:08:14 +1000 Subject: [PATCH 03/14] Stuff --- docs/reports/tdbin-bench-data.json | 342 ++++++++++++++--------------- docs/reports/tdbin-bench-report.md | 170 +++++++------- 2 files changed, 256 insertions(+), 256 deletions(-) diff --git a/docs/reports/tdbin-bench-data.json b/docs/reports/tdbin-bench-data.json index a31c216..ba29a41 100644 --- a/docs/reports/tdbin-bench-data.json +++ b/docs/reports/tdbin-bench-data.json @@ -1,6 +1,6 @@ { "format_version": 1, - "generated_at": "2026-07-11T15:07:08.285Z", + "generated_at": "2026-07-12T08:15:06.280Z", "commands": ["cargo bench -p tdbin --bench gate -- --noplot", "node scripts/tdbin-bench-report.mjs"], "corpus_schemas": ["docs/benchmarks/tdbin-corpus.td", "docs/benchmarks/tdbin-corpus.proto"], "gate": { @@ -16,8 +16,8 @@ "cpu": "Apple M4 Max", "logical_cpus": 14, "memory_bytes": 38654705664, - "rustc": "rustc 1.96.0 (ac68faa20 2026-05-25)", - "cargo": "cargo 1.96.0 (30a34c682 2026-05-25)", + "rustc": "rustc 1.97.0 (2d8144b78 2026-07-07)", + "cargo": "cargo 1.97.0 (c980f4866 2026-06-30)", "dependencies": "tdbin v0.0.0 (/Users/christianfindlay/Documents/Code/typeDiagram/crates/tdbin)\n[dev-dependencies]\n├── criterion v0.8.2\n└── prost v0.14.3" }, "sizes": { @@ -99,450 +99,450 @@ { "fixture": "with_address", "operation": "tdbin_encode_bare", - "median_ns": 83.0986024519383, - "confidence_interval_ns": [82.75906918294152, 83.3815792987034], + "median_ns": 87.08787769284092, + "confidence_interval_ns": [85.83105121095318, 88.67318654924685], "sample_count": 50, - "sampled_time_ns": 5010966247 + "sampled_time_ns": 5081719791 }, { "fixture": "with_address", "operation": "tdbin_encode_framed", - "median_ns": 109.3599955037348, - "confidence_interval_ns": [101.10398443996914, 111.63251257218099], + "median_ns": 115.32005693641176, + "confidence_interval_ns": [109.03053126180885, 124.82153581045931], "sample_count": 50, - "sampled_time_ns": 6167315255 + "sampled_time_ns": 4500933336 }, { "fixture": "with_address", "operation": "tdbin_encode_packed_framed", - "median_ns": 198.64182683798805, - "confidence_interval_ns": [196.79967216953517, 207.63834509272823], + "median_ns": 288.4123942121779, + "confidence_interval_ns": [269.2336513571481, 320.60531501262585], "sample_count": 50, - "sampled_time_ns": 4688622750 + "sampled_time_ns": 5825125663 }, { "fixture": "with_address", "operation": "protobuf_encode", - "median_ns": 69.4070397719205, - "confidence_interval_ns": [64.09463081445617, 73.29276867542798], + "median_ns": 135.32454676758297, + "confidence_interval_ns": [121.44874794344264, 149.17690579037316], "sample_count": 50, - "sampled_time_ns": 4853818290 + "sampled_time_ns": 5787919790 }, { "fixture": "with_address", "operation": "tdbin_decode_bare", - "median_ns": 154.30256338383225, - "confidence_interval_ns": [151.27704125756728, 157.0964401015884], + "median_ns": 398.2407696588902, + "confidence_interval_ns": [341.299922122231, 435.1841888393849], "sample_count": 50, - "sampled_time_ns": 5247387792 + "sampled_time_ns": 5056105122 }, { "fixture": "with_address", "operation": "tdbin_decode_framed", - "median_ns": 939.8466969498579, - "confidence_interval_ns": [908.5379492439257, 966.4722433975369], + "median_ns": 353.1594893576057, + "confidence_interval_ns": [324.6078110385472, 381.7991302522968], "sample_count": 50, - "sampled_time_ns": 7711428377 + "sampled_time_ns": 4788730667 }, { "fixture": "with_address", "operation": "tdbin_decode_packed_framed", - "median_ns": 3605.618066451447, - "confidence_interval_ns": [3548.2055785694556, 3652.6665439258645], + "median_ns": 1046.1323179579745, + "confidence_interval_ns": [742.6619580668088, 1357.8098454318947], "sample_count": 50, - "sampled_time_ns": 5589697585 + "sampled_time_ns": 7339778090 }, { "fixture": "with_address", "operation": "protobuf_decode", - "median_ns": 921.2747494756468, - "confidence_interval_ns": [913.1263968042108, 939.4358635102728], + "median_ns": 240.10802975468832, + "confidence_interval_ns": [197.31696990729264, 265.83479589909246], "sample_count": 50, - "sampled_time_ns": 5038537211 + "sampled_time_ns": 6804678253 }, { "fixture": "without_address", "operation": "tdbin_encode_bare", - "median_ns": 338.23941935625714, - "confidence_interval_ns": [335.94558211992916, 341.9764862369185], + "median_ns": 140.18080663110226, + "confidence_interval_ns": [129.79643429151733, 161.8109129361692], "sample_count": 50, - "sampled_time_ns": 4902567339 + "sampled_time_ns": 7027197791 }, { "fixture": "without_address", "operation": "tdbin_encode_framed", - "median_ns": 400.05962862713375, - "confidence_interval_ns": [396.69831002150937, 402.9557669363937], + "median_ns": 127.88736008268663, + "confidence_interval_ns": [116.34983389326372, 144.24127364546777], "sample_count": 50, - "sampled_time_ns": 5493679418 + "sampled_time_ns": 3582317666 }, { "fixture": "without_address", "operation": "tdbin_encode_packed_framed", - "median_ns": 764.9362829090513, - "confidence_interval_ns": [754.5268784450246, 777.8599521207768], + "median_ns": 159.9392335485498, + "confidence_interval_ns": [157.3632686643561, 161.7110227060933], "sample_count": 50, - "sampled_time_ns": 2607578164 + "sampled_time_ns": 4874383206 }, { "fixture": "without_address", "operation": "protobuf_encode", - "median_ns": 35.643756724257955, - "confidence_interval_ns": [35.34580951704713, 36.04477387841564], + "median_ns": 36.66844115564437, + "confidence_interval_ns": [35.84229063776525, 37.806351787137245], "sample_count": 50, - "sampled_time_ns": 5172749164 + "sampled_time_ns": 5060751292 }, { "fixture": "without_address", "operation": "tdbin_decode_bare", - "median_ns": 73.63715672425766, - "confidence_interval_ns": [73.28936054166395, 74.39476561989946], + "median_ns": 78.4862488543285, + "confidence_interval_ns": [77.71920331111673, 79.11501939914129], "sample_count": 50, - "sampled_time_ns": 5770456038 + "sampled_time_ns": 5224182423 }, { "fixture": "without_address", "operation": "tdbin_decode_framed", - "median_ns": 76.62628666396307, - "confidence_interval_ns": [76.28170920091168, 76.97378832520158], + "median_ns": 78.06490482329005, + "confidence_interval_ns": [77.34051443792157, 80.09066030707751], "sample_count": 50, - "sampled_time_ns": 5716749832 + "sampled_time_ns": 5208703751 }, { "fixture": "without_address", "operation": "tdbin_decode_packed_framed", - "median_ns": 542.176390275313, - "confidence_interval_ns": [537.789443896877, 545.6730102040816], + "median_ns": 535.581008719854, + "confidence_interval_ns": [527.6173342290977, 550.3626304754091], "sample_count": 50, - "sampled_time_ns": 5451650873 + "sampled_time_ns": 5093168913 }, { "fixture": "without_address", "operation": "protobuf_decode", - "median_ns": 53.270764638974285, - "confidence_interval_ns": [52.774499120830136, 54.62112858742235], + "median_ns": 50.26318041081733, + "confidence_interval_ns": [49.5605513132067, 51.29787830728764], "sample_count": 50, - "sampled_time_ns": 5653604124 + "sampled_time_ns": 5065208997 }, { "fixture": "metric_batch", "operation": "tdbin_encode_bare", - "median_ns": 12304.579206968829, - "confidence_interval_ns": [12034.351204594741, 12986.859614434008], + "median_ns": 12082.829555045011, + "confidence_interval_ns": [11833.6111827957, 12266.268867962332], "sample_count": 50, - "sampled_time_ns": 3765785380 + "sampled_time_ns": 4873371085 }, { "fixture": "metric_batch", "operation": "tdbin_encode_framed", - "median_ns": 14973.70605269598, - "confidence_interval_ns": [14362.956805293006, 15266.240419615773], + "median_ns": 11870.648547758285, + "confidence_interval_ns": [11775.439720270337, 12045.111239711934], "sample_count": 50, - "sampled_time_ns": 4256507585 + "sampled_time_ns": 4962783041 }, { "fixture": "metric_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 27741.34569382736, - "confidence_interval_ns": [24611.30361188019, 29641.257145924064], + "median_ns": 23442.387840560357, + "confidence_interval_ns": [23196.143658810324, 23813.560072267388], "sample_count": 50, - "sampled_time_ns": 4639950838 + "sampled_time_ns": 4962805295 }, { "fixture": "metric_batch", "operation": "protobuf_encode", - "median_ns": 50695.82419075859, - "confidence_interval_ns": [45812.643214688826, 56102.63138832998], + "median_ns": 47464.1642094136, + "confidence_interval_ns": [46899.58597603947, 48049.66095471236], "sample_count": 50, - "sampled_time_ns": 5412158586 + "sampled_time_ns": 5241324749 }, { "fixture": "metric_batch", "operation": "tdbin_decode_bare", - "median_ns": 7712.441381987577, - "confidence_interval_ns": [7535.392364793213, 7846.435093167702], + "median_ns": 7469.2159931319875, + "confidence_interval_ns": [7384.006635700066, 7666.726962780775], "sample_count": 50, - "sampled_time_ns": 3110157084 + "sampled_time_ns": 5405004835 }, { "fixture": "metric_batch", "operation": "tdbin_decode_framed", - "median_ns": 7950.836157946074, - "confidence_interval_ns": [7797.901587003475, 8153.567186053687], + "median_ns": 7321.017026985111, + "confidence_interval_ns": [7265.559893839192, 7495.359400501672], "sample_count": 50, - "sampled_time_ns": 4824275454 + "sampled_time_ns": 5087042869 }, { "fixture": "metric_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 37962.75155400156, - "confidence_interval_ns": [37782.57575757576, 38310.655483405484], + "median_ns": 34458.174430017505, + "confidence_interval_ns": [33192.6339031339, 35442.424836601305], "sample_count": 50, - "sampled_time_ns": 5710549457 + "sampled_time_ns": 4653776461 }, { "fixture": "metric_batch", "operation": "protobuf_decode", - "median_ns": 35866.98133657133, - "confidence_interval_ns": [35315.62229357798, 36267.435779816515], + "median_ns": 38167.277021011396, + "confidence_interval_ns": [37096.64351851852, 38959.117438271605], "sample_count": 50, - "sampled_time_ns": 4988323041 + "sampled_time_ns": 5333133414 }, { "fixture": "person_batch", "operation": "tdbin_encode_bare", - "median_ns": 11811.259324532171, - "confidence_interval_ns": [11727.91182913472, 11857.022682529383], + "median_ns": 13184.354094827586, + "confidence_interval_ns": [12986.266745036573, 13440.801420494206], "sample_count": 50, - "sampled_time_ns": 5002386459 + "sampled_time_ns": 4935882041 }, { "fixture": "person_batch", "operation": "tdbin_encode_framed", - "median_ns": 11857.785178035178, - "confidence_interval_ns": [11793.738811982714, 11876.218218218219], + "median_ns": 12056.71304097951, + "confidence_interval_ns": [11790, 12398.041851851853], "sample_count": 50, - "sampled_time_ns": 4998342748 + "sampled_time_ns": 4790011457 }, { "fixture": "person_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 15541.067667958656, - "confidence_interval_ns": [15265.019379844962, 15892.26976744186], + "median_ns": 15041.404648956357, + "confidence_interval_ns": [14915.122365285924, 15137.542137096774], "sample_count": 50, - "sampled_time_ns": 5124612873 + "sampled_time_ns": 4772413331 }, { "fixture": "person_batch", "operation": "protobuf_encode", - "median_ns": 18246.74396654719, - "confidence_interval_ns": [18085.814536340855, 18350.320477843918], + "median_ns": 16421.974406683403, + "confidence_interval_ns": [16250.355749056871, 16873.18663318986], "sample_count": 50, - "sampled_time_ns": 5069842789 + "sampled_time_ns": 6627863876 }, { "fixture": "person_batch", "operation": "tdbin_decode_bare", - "median_ns": 36733.65223791067, - "confidence_interval_ns": [36446.825531914896, 37009.23423423423], + "median_ns": 70059.57651515151, + "confidence_interval_ns": [62935.340157085564, 74720.77045454546], "sample_count": 50, - "sampled_time_ns": 5009308170 + "sampled_time_ns": 5950221541 }, { "fixture": "person_batch", "operation": "tdbin_decode_framed", - "median_ns": 37817.89528566519, - "confidence_interval_ns": [37126.017136329014, 38522.627846534655], + "median_ns": 91897.49752385452, + "confidence_interval_ns": [79752.96610169491, 97922.02361492597], "sample_count": 50, - "sampled_time_ns": 4937394337 + "sampled_time_ns": 7185206166 }, { "fixture": "person_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 41742.64619883041, - "confidence_interval_ns": [41256.072874493926, 42369.14766917293], + "median_ns": 92244.76265256734, + "confidence_interval_ns": [70133.96527777778, 113053.5729061128], "sample_count": 50, - "sampled_time_ns": 5122980873 + "sampled_time_ns": 4669964288 }, { "fixture": "person_batch", "operation": "protobuf_decode", - "median_ns": 57691.02573529412, - "confidence_interval_ns": [57264.09488795518, 58092.244884910484], + "median_ns": 141485.84999999998, + "confidence_interval_ns": [124303.88621794872, 152091.45384615383], "sample_count": 50, - "sampled_time_ns": 5044088503 + "sampled_time_ns": 4667564002 }, { "fixture": "contact_batch", "operation": "tdbin_encode_bare", - "median_ns": 9627.04700894493, - "confidence_interval_ns": [9605.446515892421, 9661.16333876664], + "median_ns": 17033.04363255719, + "confidence_interval_ns": [13746.51633986928, 21853.716536614647], "sample_count": 50, - "sampled_time_ns": 5017694914 + "sampled_time_ns": 3498833250 }, { "fixture": "contact_batch", "operation": "tdbin_encode_framed", - "median_ns": 9653.37920771757, - "confidence_interval_ns": [9611.229788467112, 9720.221674876848], + "median_ns": 21809.42612029969, + "confidence_interval_ns": [20410.860321585187, 23426.51251817521], "sample_count": 50, - "sampled_time_ns": 5011654586 + "sampled_time_ns": 7438347336 }, { "fixture": "contact_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 12925.319602272728, - "confidence_interval_ns": [12866.41525974026, 13029.128030303029], + "median_ns": 27871.796569178852, + "confidence_interval_ns": [25198.54739149454, 30680.11811023622], "sample_count": 50, - "sampled_time_ns": 5076227668 + "sampled_time_ns": 4811547712 }, { "fixture": "contact_batch", "operation": "protobuf_encode", - "median_ns": 28364.751068519512, - "confidence_interval_ns": [28008.61768707483, 28740.990767735668], + "median_ns": 53212.414046601545, + "confidence_interval_ns": [49860.01893162393, 58796.2425170068], "sample_count": 50, - "sampled_time_ns": 5413163040 + "sampled_time_ns": 4086413454 }, { "fixture": "contact_batch", "operation": "tdbin_decode_bare", - "median_ns": 36763.935706018514, - "confidence_interval_ns": [36436.13937621832, 38004.730273752015], + "median_ns": 31445.512604709973, + "confidence_interval_ns": [31284.4176705653, 31701.027013279727], "sample_count": 50, - "sampled_time_ns": 5239417333 + "sampled_time_ns": 4859493915 }, { "fixture": "contact_batch", "operation": "tdbin_decode_framed", - "median_ns": 43522.073933998836, - "confidence_interval_ns": [42990.50632911392, 45359.17721518988], + "median_ns": 31063.60227027027, + "confidence_interval_ns": [30919.645161290322, 31181.92472653061], "sample_count": 50, - "sampled_time_ns": 4709271376 + "sampled_time_ns": 4954496997 }, { "fixture": "contact_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 40589.56235239541, - "confidence_interval_ns": [38987.891636141634, 41945.72649572649], + "median_ns": 33327.808050492924, + "confidence_interval_ns": [33163.28394357743, 33453.610270774974], "sample_count": 50, - "sampled_time_ns": 4278261001 + "sampled_time_ns": 5061957790 }, { "fixture": "contact_batch", "operation": "protobuf_decode", - "median_ns": 72148.43694196429, - "confidence_interval_ns": [69175.54523809523, 74153.18263888889], + "median_ns": 59526.56932966024, + "confidence_interval_ns": [59360.61471861472, 59659.09090909091], "sample_count": 50, - "sampled_time_ns": 4708885204 + "sampled_time_ns": 5015078166 }, { "fixture": "diagram_document", "operation": "tdbin_encode_bare", - "median_ns": 16468.413178034374, - "confidence_interval_ns": [15267.550766410459, 17475.167218925613], + "median_ns": 12303.538517852983, + "confidence_interval_ns": [12255.445338512764, 12370.741614255765], "sample_count": 50, - "sampled_time_ns": 3973093918 + "sampled_time_ns": 5001143459 }, { "fixture": "diagram_document", "operation": "tdbin_encode_framed", - "median_ns": 13958.201267482516, - "confidence_interval_ns": [13514.264891562685, 15376.681784105258], + "median_ns": 12275.395630477118, + "confidence_interval_ns": [12244.286216596343, 12311.916442230002], "sample_count": 50, - "sampled_time_ns": 5476900039 + "sampled_time_ns": 4944547753 }, { "fixture": "diagram_document", "operation": "tdbin_encode_packed_framed", - "median_ns": 24274.09638554217, - "confidence_interval_ns": [23245.29059093517, 24469.05464716007], + "median_ns": 19568.948569082804, + "confidence_interval_ns": [19495.644532019705, 19615.32866845798], "sample_count": 50, - "sampled_time_ns": 5682131288 + "sampled_time_ns": 5091890456 }, { "fixture": "diagram_document", "operation": "protobuf_encode", - "median_ns": 23293.14121825226, - "confidence_interval_ns": [21948.859618104667, 24437.80198019802], + "median_ns": 23470.591920731706, + "confidence_interval_ns": [23191.088059288755, 23758.10529446758], "sample_count": 50, - "sampled_time_ns": 2832264585 + "sampled_time_ns": 4960373626 }, { "fixture": "diagram_document", "operation": "tdbin_decode_bare", - "median_ns": 74797.71759259258, - "confidence_interval_ns": [74476.04962962963, 75054.47592592593], + "median_ns": 70723.87240499648, + "confidence_interval_ns": [70433.0233493891, 71106.44327731093], "sample_count": 50, - "sampled_time_ns": 5183134501 + "sampled_time_ns": 5069408751 }, { "fixture": "diagram_document", "operation": "tdbin_decode_framed", - "median_ns": 75938.81051463718, - "confidence_interval_ns": [75538.61046511628, 76333.85859973359], + "median_ns": 71217.77150023152, + "confidence_interval_ns": [70788.32794612795, 71583.15874125874], "sample_count": 50, - "sampled_time_ns": 5024830496 + "sampled_time_ns": 5245726838 }, { "fixture": "diagram_document", "operation": "tdbin_decode_packed_framed", - "median_ns": 84772.29507844402, - "confidence_interval_ns": [84151.99468085106, 85929.28039513677], + "median_ns": 80341.49526239067, + "confidence_interval_ns": [79553.77899452463, 80955.51020408163], "sample_count": 50, - "sampled_time_ns": 5079075627 + "sampled_time_ns": 5079646127 }, { "fixture": "diagram_document", "operation": "protobuf_decode", - "median_ns": 120168.45172634271, - "confidence_interval_ns": [119515.8756684492, 120872.32037815126], + "median_ns": 112113.07058531746, + "confidence_interval_ns": [111145.54489795919, 113018.80571428571], "sample_count": 50, - "sampled_time_ns": 5248374578 + "sampled_time_ns": 5055293581 }, { "fixture": "event_batch", "operation": "tdbin_encode_bare", - "median_ns": 40854.41593242901, - "confidence_interval_ns": [40522.44836317135, 41923.52321004159], + "median_ns": 36803.97532586858, + "confidence_interval_ns": [36442.88668224299, 37131.088360237896], "sample_count": 50, - "sampled_time_ns": 5159725958 + "sampled_time_ns": 5085971289 }, { "fixture": "event_batch", "operation": "tdbin_encode_framed", - "median_ns": 39913.562568681315, - "confidence_interval_ns": [39642.669137761244, 40059.578889860146], + "median_ns": 35460.84059225658, + "confidence_interval_ns": [35390.77260760073, 35622.738213399505], "sample_count": 50, - "sampled_time_ns": 5226986670 + "sampled_time_ns": 4730817414 }, { "fixture": "event_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 55970.928275599545, - "confidence_interval_ns": [55781.71908310115, 56181.00070422535], + "median_ns": 52131.32052310655, + "confidence_interval_ns": [51942.84539473684, 52407.82894736842], "sample_count": 50, - "sampled_time_ns": 5066256920 + "sampled_time_ns": 5078327420 }, { "fixture": "event_batch", "operation": "protobuf_encode", - "median_ns": 79863.94954248366, - "confidence_interval_ns": [78118.58975141887, 81755.60055555555], + "median_ns": 56485.33028222731, + "confidence_interval_ns": [55341.329106280195, 56944.85186066337], "sample_count": 50, - "sampled_time_ns": 5017193506 + "sampled_time_ns": 5012815130 }, { "fixture": "event_batch", "operation": "tdbin_decode_bare", - "median_ns": 202750.00983436854, - "confidence_interval_ns": [201498.01509872242, 203393.86091269844], + "median_ns": 178575.90052700922, + "confidence_interval_ns": [177347.93741765482, 178971.63602484472], "sample_count": 50, - "sampled_time_ns": 5454116957 + "sampled_time_ns": 5231950164 }, { "fixture": "event_batch", "operation": "tdbin_decode_framed", - "median_ns": 205236.58258928574, - "confidence_interval_ns": [200798.04421768707, 206521.79277244495], + "median_ns": 178355.36883116883, + "confidence_interval_ns": [177243.006993007, 179310.74510579684], "sample_count": 50, - "sampled_time_ns": 5581533544 + "sampled_time_ns": 5067338998 }, { "fixture": "event_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 227074.67709305946, - "confidence_interval_ns": [224506.687654321, 229686.21978557506], + "median_ns": 202789.675, + "confidence_interval_ns": [201010.69416666665, 204871.66666666666], "sample_count": 50, - "sampled_time_ns": 5164226039 + "sampled_time_ns": 5253962004 }, { "fixture": "event_batch", "operation": "protobuf_decode", - "median_ns": 303783.7773556231, - "confidence_interval_ns": [297643.63354037266, 308790.9420849421], + "median_ns": 278907.4770164046, + "confidence_interval_ns": [277196.82857142854, 280066.96875], "sample_count": 50, - "sampled_time_ns": 5401235540 + "sampled_time_ns": 5062289626 } ] } diff --git a/docs/reports/tdbin-bench-report.md b/docs/reports/tdbin-bench-report.md index b3c893a..71844fe 100644 --- a/docs/reports/tdbin-bench-report.md +++ b/docs/reports/tdbin-bench-report.md @@ -3,15 +3,15 @@ > GENERATED FILE. Source: `scripts/tdbin-bench-report.mjs` and `docs/reports/tdbin-bench-data.json`. > Every value and verdict is computed from machine-readable Criterion and encoder output. No benchmark result is entered manually. -Generated: 2026-07-11T15:07:08.285Z +Generated: 2026-07-12T08:15:06.280Z -Raw data SHA-256: `052fbaa3e7fd70eadcd3216b89d3f37a3828d933b1d44de79d18798b16e040fa` +Raw data SHA-256: `602fb5e604e1183ca3d99d1361f44184790f19ef82e30ac677a39645a5a9b4dd` ## Result -**Specification gate ([TDBIN-BENCH-CORPUS] committed workloads): FAIL.** 2 of 3 corpus fixtures have a production wire mode that is simultaneously smaller than Protobuf AND at least 1.50x faster on both encode and decode. Stress rows: 2 of 4 pass the same bar. +**Specification gate ([TDBIN-BENCH-CORPUS] committed workloads): PASS.** 3 of 3 corpus fixtures have a production wire mode that is simultaneously smaller than Protobuf AND at least 1.50x faster on both encode and decode. Stress rows: 1 of 4 pass the same bar. -Qualifying modes: `with_address` = none, `without_address` = none, `metric_batch` = framed, `person_batch` = framed, `contact_batch` = framed & packed framed, `diagram_document` = framed, `event_batch` = none. +Qualifying modes: `with_address` = none, `without_address` = none, `metric_batch` = framed, `person_batch` = none, `contact_batch` = framed & packed framed, `diagram_document` = framed, `event_batch` = framed. The release gate ([TDBIN-BENCH-GATE]) requires, for every corpus entry — the committed realistic schemas in `docs/benchmarks/tdbin-corpus.{td,proto}` (record-heavy document, union-heavy event stream, list-heavy dataset) — that at least one self-describing production wire mode (framed, or packed framed; the frame's PACKED flag makes the two interchangeable to every decoder) beats Protobuf on size and by 1.50x on both encode and decode simultaneously. Both modes are always measured and published below. Stress rows (marked) are reported against the identical bar; the tiny single-message rows carry a fixed 12-byte frame plus pointer-per-string overhead that no fixed-layout format recovers at sub-100-byte payloads (research §2.2), so they are not corpus entries. @@ -23,8 +23,8 @@ The release gate ([TDBIN-BENCH-GATE]) requires, for every corpus entry — the c | CPU | Apple M4 Max | | Logical CPUs | 14 | | Memory | 36.0 GiB | -| Rust | rustc 1.96.0 (ac68faa20 2026-05-25) | -| Cargo | cargo 1.96.0 (30a34c682 2026-05-25) | +| Rust | rustc 1.97.0 (2d8144b78 2026-07-07) | +| Cargo | cargo 1.97.0 (c980f4866 2026-06-30) | Dependency tree: @@ -53,62 +53,62 @@ All sizes are bytes. Percentage columns are relative to Protobuf; negative is sm | Fixture | Operation | Samples | Sampled time | Median | CI lower | CI upper | | ------------------ | ---------------------------- | ------: | -----------: | ---------: | ---------: | ---------: | -| `with_address` | `tdbin_encode_bare` | 50 | 5010.966 ms | 83.10 ns | 82.76 ns | 83.38 ns | -| `with_address` | `tdbin_encode_framed` | 50 | 6167.315 ms | 109.36 ns | 101.10 ns | 111.63 ns | -| `with_address` | `tdbin_encode_packed_framed` | 50 | 4688.623 ms | 198.64 ns | 196.80 ns | 207.64 ns | -| `with_address` | `protobuf_encode` | 50 | 4853.818 ms | 69.41 ns | 64.09 ns | 73.29 ns | -| `with_address` | `tdbin_decode_bare` | 50 | 5247.388 ms | 154.30 ns | 151.28 ns | 157.10 ns | -| `with_address` | `tdbin_decode_framed` | 50 | 7711.428 ms | 939.85 ns | 908.54 ns | 966.47 ns | -| `with_address` | `tdbin_decode_packed_framed` | 50 | 5589.698 ms | 3.606 us | 3.548 us | 3.653 us | -| `with_address` | `protobuf_decode` | 50 | 5038.537 ms | 921.27 ns | 913.13 ns | 939.44 ns | -| `without_address` | `tdbin_encode_bare` | 50 | 4902.567 ms | 338.24 ns | 335.95 ns | 341.98 ns | -| `without_address` | `tdbin_encode_framed` | 50 | 5493.679 ms | 400.06 ns | 396.70 ns | 402.96 ns | -| `without_address` | `tdbin_encode_packed_framed` | 50 | 2607.578 ms | 764.94 ns | 754.53 ns | 777.86 ns | -| `without_address` | `protobuf_encode` | 50 | 5172.749 ms | 35.64 ns | 35.35 ns | 36.04 ns | -| `without_address` | `tdbin_decode_bare` | 50 | 5770.456 ms | 73.64 ns | 73.29 ns | 74.39 ns | -| `without_address` | `tdbin_decode_framed` | 50 | 5716.750 ms | 76.63 ns | 76.28 ns | 76.97 ns | -| `without_address` | `tdbin_decode_packed_framed` | 50 | 5451.651 ms | 542.18 ns | 537.79 ns | 545.67 ns | -| `without_address` | `protobuf_decode` | 50 | 5653.604 ms | 53.27 ns | 52.77 ns | 54.62 ns | -| `metric_batch` | `tdbin_encode_bare` | 50 | 3765.785 ms | 12.305 us | 12.034 us | 12.987 us | -| `metric_batch` | `tdbin_encode_framed` | 50 | 4256.508 ms | 14.974 us | 14.363 us | 15.266 us | -| `metric_batch` | `tdbin_encode_packed_framed` | 50 | 4639.951 ms | 27.741 us | 24.611 us | 29.641 us | -| `metric_batch` | `protobuf_encode` | 50 | 5412.159 ms | 50.696 us | 45.813 us | 56.103 us | -| `metric_batch` | `tdbin_decode_bare` | 50 | 3110.157 ms | 7.712 us | 7.535 us | 7.846 us | -| `metric_batch` | `tdbin_decode_framed` | 50 | 4824.275 ms | 7.951 us | 7.798 us | 8.154 us | -| `metric_batch` | `tdbin_decode_packed_framed` | 50 | 5710.549 ms | 37.963 us | 37.783 us | 38.311 us | -| `metric_batch` | `protobuf_decode` | 50 | 4988.323 ms | 35.867 us | 35.316 us | 36.267 us | -| `person_batch` | `tdbin_encode_bare` | 50 | 5002.386 ms | 11.811 us | 11.728 us | 11.857 us | -| `person_batch` | `tdbin_encode_framed` | 50 | 4998.343 ms | 11.858 us | 11.794 us | 11.876 us | -| `person_batch` | `tdbin_encode_packed_framed` | 50 | 5124.613 ms | 15.541 us | 15.265 us | 15.892 us | -| `person_batch` | `protobuf_encode` | 50 | 5069.843 ms | 18.247 us | 18.086 us | 18.350 us | -| `person_batch` | `tdbin_decode_bare` | 50 | 5009.308 ms | 36.734 us | 36.447 us | 37.009 us | -| `person_batch` | `tdbin_decode_framed` | 50 | 4937.394 ms | 37.818 us | 37.126 us | 38.523 us | -| `person_batch` | `tdbin_decode_packed_framed` | 50 | 5122.981 ms | 41.743 us | 41.256 us | 42.369 us | -| `person_batch` | `protobuf_decode` | 50 | 5044.089 ms | 57.691 us | 57.264 us | 58.092 us | -| `contact_batch` | `tdbin_encode_bare` | 50 | 5017.695 ms | 9.627 us | 9.605 us | 9.661 us | -| `contact_batch` | `tdbin_encode_framed` | 50 | 5011.655 ms | 9.653 us | 9.611 us | 9.720 us | -| `contact_batch` | `tdbin_encode_packed_framed` | 50 | 5076.228 ms | 12.925 us | 12.866 us | 13.029 us | -| `contact_batch` | `protobuf_encode` | 50 | 5413.163 ms | 28.365 us | 28.009 us | 28.741 us | -| `contact_batch` | `tdbin_decode_bare` | 50 | 5239.417 ms | 36.764 us | 36.436 us | 38.005 us | -| `contact_batch` | `tdbin_decode_framed` | 50 | 4709.271 ms | 43.522 us | 42.991 us | 45.359 us | -| `contact_batch` | `tdbin_decode_packed_framed` | 50 | 4278.261 ms | 40.590 us | 38.988 us | 41.946 us | -| `contact_batch` | `protobuf_decode` | 50 | 4708.885 ms | 72.148 us | 69.176 us | 74.153 us | -| `diagram_document` | `tdbin_encode_bare` | 50 | 3973.094 ms | 16.468 us | 15.268 us | 17.475 us | -| `diagram_document` | `tdbin_encode_framed` | 50 | 5476.900 ms | 13.958 us | 13.514 us | 15.377 us | -| `diagram_document` | `tdbin_encode_packed_framed` | 50 | 5682.131 ms | 24.274 us | 23.245 us | 24.469 us | -| `diagram_document` | `protobuf_encode` | 50 | 2832.265 ms | 23.293 us | 21.949 us | 24.438 us | -| `diagram_document` | `tdbin_decode_bare` | 50 | 5183.135 ms | 74.798 us | 74.476 us | 75.054 us | -| `diagram_document` | `tdbin_decode_framed` | 50 | 5024.830 ms | 75.939 us | 75.539 us | 76.334 us | -| `diagram_document` | `tdbin_decode_packed_framed` | 50 | 5079.076 ms | 84.772 us | 84.152 us | 85.929 us | -| `diagram_document` | `protobuf_decode` | 50 | 5248.375 ms | 120.168 us | 119.516 us | 120.872 us | -| `event_batch` | `tdbin_encode_bare` | 50 | 5159.726 ms | 40.854 us | 40.522 us | 41.924 us | -| `event_batch` | `tdbin_encode_framed` | 50 | 5226.987 ms | 39.914 us | 39.643 us | 40.060 us | -| `event_batch` | `tdbin_encode_packed_framed` | 50 | 5066.257 ms | 55.971 us | 55.782 us | 56.181 us | -| `event_batch` | `protobuf_encode` | 50 | 5017.194 ms | 79.864 us | 78.119 us | 81.756 us | -| `event_batch` | `tdbin_decode_bare` | 50 | 5454.117 ms | 202.750 us | 201.498 us | 203.394 us | -| `event_batch` | `tdbin_decode_framed` | 50 | 5581.534 ms | 205.237 us | 200.798 us | 206.522 us | -| `event_batch` | `tdbin_decode_packed_framed` | 50 | 5164.226 ms | 227.075 us | 224.507 us | 229.686 us | -| `event_batch` | `protobuf_decode` | 50 | 5401.236 ms | 303.784 us | 297.644 us | 308.791 us | +| `with_address` | `tdbin_encode_bare` | 50 | 5081.720 ms | 87.09 ns | 85.83 ns | 88.67 ns | +| `with_address` | `tdbin_encode_framed` | 50 | 4500.933 ms | 115.32 ns | 109.03 ns | 124.82 ns | +| `with_address` | `tdbin_encode_packed_framed` | 50 | 5825.126 ms | 288.41 ns | 269.23 ns | 320.61 ns | +| `with_address` | `protobuf_encode` | 50 | 5787.920 ms | 135.32 ns | 121.45 ns | 149.18 ns | +| `with_address` | `tdbin_decode_bare` | 50 | 5056.105 ms | 398.24 ns | 341.30 ns | 435.18 ns | +| `with_address` | `tdbin_decode_framed` | 50 | 4788.731 ms | 353.16 ns | 324.61 ns | 381.80 ns | +| `with_address` | `tdbin_decode_packed_framed` | 50 | 7339.778 ms | 1.046 us | 742.66 ns | 1.358 us | +| `with_address` | `protobuf_decode` | 50 | 6804.678 ms | 240.11 ns | 197.32 ns | 265.83 ns | +| `without_address` | `tdbin_encode_bare` | 50 | 7027.198 ms | 140.18 ns | 129.80 ns | 161.81 ns | +| `without_address` | `tdbin_encode_framed` | 50 | 3582.318 ms | 127.89 ns | 116.35 ns | 144.24 ns | +| `without_address` | `tdbin_encode_packed_framed` | 50 | 4874.383 ms | 159.94 ns | 157.36 ns | 161.71 ns | +| `without_address` | `protobuf_encode` | 50 | 5060.751 ms | 36.67 ns | 35.84 ns | 37.81 ns | +| `without_address` | `tdbin_decode_bare` | 50 | 5224.182 ms | 78.49 ns | 77.72 ns | 79.12 ns | +| `without_address` | `tdbin_decode_framed` | 50 | 5208.704 ms | 78.06 ns | 77.34 ns | 80.09 ns | +| `without_address` | `tdbin_decode_packed_framed` | 50 | 5093.169 ms | 535.58 ns | 527.62 ns | 550.36 ns | +| `without_address` | `protobuf_decode` | 50 | 5065.209 ms | 50.26 ns | 49.56 ns | 51.30 ns | +| `metric_batch` | `tdbin_encode_bare` | 50 | 4873.371 ms | 12.083 us | 11.834 us | 12.266 us | +| `metric_batch` | `tdbin_encode_framed` | 50 | 4962.783 ms | 11.871 us | 11.775 us | 12.045 us | +| `metric_batch` | `tdbin_encode_packed_framed` | 50 | 4962.805 ms | 23.442 us | 23.196 us | 23.814 us | +| `metric_batch` | `protobuf_encode` | 50 | 5241.325 ms | 47.464 us | 46.900 us | 48.050 us | +| `metric_batch` | `tdbin_decode_bare` | 50 | 5405.005 ms | 7.469 us | 7.384 us | 7.667 us | +| `metric_batch` | `tdbin_decode_framed` | 50 | 5087.043 ms | 7.321 us | 7.266 us | 7.495 us | +| `metric_batch` | `tdbin_decode_packed_framed` | 50 | 4653.776 ms | 34.458 us | 33.193 us | 35.442 us | +| `metric_batch` | `protobuf_decode` | 50 | 5333.133 ms | 38.167 us | 37.097 us | 38.959 us | +| `person_batch` | `tdbin_encode_bare` | 50 | 4935.882 ms | 13.184 us | 12.986 us | 13.441 us | +| `person_batch` | `tdbin_encode_framed` | 50 | 4790.011 ms | 12.057 us | 11.790 us | 12.398 us | +| `person_batch` | `tdbin_encode_packed_framed` | 50 | 4772.413 ms | 15.041 us | 14.915 us | 15.138 us | +| `person_batch` | `protobuf_encode` | 50 | 6627.864 ms | 16.422 us | 16.250 us | 16.873 us | +| `person_batch` | `tdbin_decode_bare` | 50 | 5950.222 ms | 70.060 us | 62.935 us | 74.721 us | +| `person_batch` | `tdbin_decode_framed` | 50 | 7185.206 ms | 91.897 us | 79.753 us | 97.922 us | +| `person_batch` | `tdbin_decode_packed_framed` | 50 | 4669.964 ms | 92.245 us | 70.134 us | 113.054 us | +| `person_batch` | `protobuf_decode` | 50 | 4667.564 ms | 141.486 us | 124.304 us | 152.091 us | +| `contact_batch` | `tdbin_encode_bare` | 50 | 3498.833 ms | 17.033 us | 13.747 us | 21.854 us | +| `contact_batch` | `tdbin_encode_framed` | 50 | 7438.347 ms | 21.809 us | 20.411 us | 23.427 us | +| `contact_batch` | `tdbin_encode_packed_framed` | 50 | 4811.548 ms | 27.872 us | 25.199 us | 30.680 us | +| `contact_batch` | `protobuf_encode` | 50 | 4086.413 ms | 53.212 us | 49.860 us | 58.796 us | +| `contact_batch` | `tdbin_decode_bare` | 50 | 4859.494 ms | 31.446 us | 31.284 us | 31.701 us | +| `contact_batch` | `tdbin_decode_framed` | 50 | 4954.497 ms | 31.064 us | 30.920 us | 31.182 us | +| `contact_batch` | `tdbin_decode_packed_framed` | 50 | 5061.958 ms | 33.328 us | 33.163 us | 33.454 us | +| `contact_batch` | `protobuf_decode` | 50 | 5015.078 ms | 59.527 us | 59.361 us | 59.659 us | +| `diagram_document` | `tdbin_encode_bare` | 50 | 5001.143 ms | 12.304 us | 12.255 us | 12.371 us | +| `diagram_document` | `tdbin_encode_framed` | 50 | 4944.548 ms | 12.275 us | 12.244 us | 12.312 us | +| `diagram_document` | `tdbin_encode_packed_framed` | 50 | 5091.890 ms | 19.569 us | 19.496 us | 19.615 us | +| `diagram_document` | `protobuf_encode` | 50 | 4960.374 ms | 23.471 us | 23.191 us | 23.758 us | +| `diagram_document` | `tdbin_decode_bare` | 50 | 5069.409 ms | 70.724 us | 70.433 us | 71.106 us | +| `diagram_document` | `tdbin_decode_framed` | 50 | 5245.727 ms | 71.218 us | 70.788 us | 71.583 us | +| `diagram_document` | `tdbin_decode_packed_framed` | 50 | 5079.646 ms | 80.341 us | 79.554 us | 80.956 us | +| `diagram_document` | `protobuf_decode` | 50 | 5055.294 ms | 112.113 us | 111.146 us | 113.019 us | +| `event_batch` | `tdbin_encode_bare` | 50 | 5085.971 ms | 36.804 us | 36.443 us | 37.131 us | +| `event_batch` | `tdbin_encode_framed` | 50 | 4730.817 ms | 35.461 us | 35.391 us | 35.623 us | +| `event_batch` | `tdbin_encode_packed_framed` | 50 | 5078.327 ms | 52.131 us | 51.943 us | 52.408 us | +| `event_batch` | `protobuf_encode` | 50 | 5012.815 ms | 56.485 us | 55.341 us | 56.945 us | +| `event_batch` | `tdbin_decode_bare` | 50 | 5231.950 ms | 178.576 us | 177.348 us | 178.972 us | +| `event_batch` | `tdbin_decode_framed` | 50 | 5067.339 ms | 178.355 us | 177.243 us | 179.311 us | +| `event_batch` | `tdbin_decode_packed_framed` | 50 | 5253.962 ms | 202.790 us | 201.011 us | 204.872 us | +| `event_batch` | `protobuf_decode` | 50 | 5062.290 ms | 278.907 us | 277.197 us | 280.067 us | ## Same-Mode Comparison @@ -116,29 +116,29 @@ Ratios are Protobuf median / TDBIN median; values above 1.00x favor TDBIN. The g | Fixture | TDBIN mode | Size winner | Encode ratio | Decode ratio | Gate | | ------------------ | ------------- | ----------- | -----------: | -----------: | ---- | -| `with_address` | bare | Protobuf | 0.84x | 5.97x | FAIL | -| `with_address` | framed | Protobuf | 0.63x | 0.98x | FAIL | -| `with_address` | packed framed | Protobuf | 0.35x | 0.26x | FAIL | -| `without_address` | bare | Protobuf | 0.11x | 0.72x | FAIL | -| `without_address` | framed | Protobuf | 0.09x | 0.70x | FAIL | -| `without_address` | packed framed | Protobuf | 0.05x | 0.10x | FAIL | -| `metric_batch` | bare | TDBIN | 4.12x | 4.65x | PASS | -| `metric_batch` | framed | TDBIN | 3.39x | 4.51x | PASS | -| `metric_batch` | packed framed | TDBIN | 1.83x | 0.94x | FAIL | -| `person_batch` | bare | TDBIN | 1.54x | 1.57x | PASS | -| `person_batch` | framed | TDBIN | 1.54x | 1.53x | PASS | -| `person_batch` | packed framed | TDBIN | 1.17x | 1.38x | FAIL | -| `contact_batch` | bare | TDBIN | 2.95x | 1.96x | PASS | -| `contact_batch` | framed | TDBIN | 2.94x | 1.66x | PASS | -| `contact_batch` | packed framed | TDBIN | 2.19x | 1.78x | PASS | -| `diagram_document` | bare | TDBIN | 1.41x | 1.61x | FAIL | -| `diagram_document` | framed | TDBIN | 1.67x | 1.58x | PASS | -| `diagram_document` | packed framed | TDBIN | 0.96x | 1.42x | FAIL | -| `event_batch` | bare | TDBIN | 1.95x | 1.50x | FAIL | -| `event_batch` | framed | TDBIN | 2.00x | 1.48x | FAIL | -| `event_batch` | packed framed | TDBIN | 1.43x | 1.34x | FAIL | - -Passing fixture/mode combinations: 8 of 21. +| `with_address` | bare | Protobuf | 1.55x | 0.60x | FAIL | +| `with_address` | framed | Protobuf | 1.17x | 0.68x | FAIL | +| `with_address` | packed framed | Protobuf | 0.47x | 0.23x | FAIL | +| `without_address` | bare | Protobuf | 0.26x | 0.64x | FAIL | +| `without_address` | framed | Protobuf | 0.29x | 0.64x | FAIL | +| `without_address` | packed framed | Protobuf | 0.23x | 0.09x | FAIL | +| `metric_batch` | bare | TDBIN | 3.93x | 5.11x | PASS | +| `metric_batch` | framed | TDBIN | 4.00x | 5.21x | PASS | +| `metric_batch` | packed framed | TDBIN | 2.02x | 1.11x | FAIL | +| `person_batch` | bare | TDBIN | 1.25x | 2.02x | FAIL | +| `person_batch` | framed | TDBIN | 1.36x | 1.54x | FAIL | +| `person_batch` | packed framed | TDBIN | 1.09x | 1.53x | FAIL | +| `contact_batch` | bare | TDBIN | 3.12x | 1.89x | PASS | +| `contact_batch` | framed | TDBIN | 2.44x | 1.92x | PASS | +| `contact_batch` | packed framed | TDBIN | 1.91x | 1.79x | PASS | +| `diagram_document` | bare | TDBIN | 1.91x | 1.59x | PASS | +| `diagram_document` | framed | TDBIN | 1.91x | 1.57x | PASS | +| `diagram_document` | packed framed | TDBIN | 1.20x | 1.40x | FAIL | +| `event_batch` | bare | TDBIN | 1.53x | 1.56x | PASS | +| `event_batch` | framed | TDBIN | 1.59x | 1.56x | PASS | +| `event_batch` | packed framed | TDBIN | 1.08x | 1.38x | FAIL | + +Passing fixture/mode combinations: 9 of 21. This secondary table exposes unpacked tradeoffs; it does not replace the packed-framed specification gate above. From a27860f1869ec14a8bd1581ddf0b928e545e3265 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:02:42 +1000 Subject: [PATCH 04/14] Benches --- Cargo.lock | 21 +++++++++++++++++++ Claude.md | 7 ++----- crates/tdbin/Cargo.toml | 11 +++++++--- crates/tdbin/benches/gate.rs | 2 ++ crates/tdbin/examples/bench_data.rs | 8 ++++--- crates/tdbin/tests/support/batch_corpus.rs | 4 ++++ crates/tdbin/tests/support/bench_corpus.rs | 7 +++++++ crates/tdbin/tests/support/document_corpus.rs | 5 +++++ crates/tdbin/tests/support/event_corpus.rs | 9 ++++++++ 9 files changed, 63 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e8c9d4..695956f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -317,6 +317,25 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "same-file" version = "1.0.6" @@ -392,6 +411,8 @@ version = "0.0.0" dependencies = [ "criterion", "prost", + "rmp-serde", + "serde", ] [[package]] diff --git a/Claude.md b/Claude.md index 96004b2..347f79a 100644 --- a/Claude.md +++ b/Claude.md @@ -7,11 +7,8 @@ Read this file in full. Rules below are NON-NEGOTIABLE — violations are reject ⚠️ **TOKEN ECONOMICS DISCIPLINE.** Check file size first. `Grep` over `Read`. Use `offset`/`limit`. Smallest diff that solves the problem. Delete dead code, unused imports, stale comments. Call out irrelevant context before proceeding. Bloat degrades reasoning. ⚠️ -⚠️ DON'T ASK THE USER QUESTIONS!!! - -- DO YOUR JOB and don't say things like "Open questions before I start" -- USE YOUR JUDGEMENT! ⚠️ - ⚠️ NEVER KILL ANY VSCODE PROCESS ⚠️ +⚠️ ACT AUTONOMOUSLY. DON'T ASK THE USER QUESTIONS. USE YOUR JUDGEMENT. ⚠️ +⚠️ NEVER KILL ANY VSCODE PROCESS ⚠️ ## Project Overview diff --git a/crates/tdbin/Cargo.toml b/crates/tdbin/Cargo.toml index 465428a..edca41c 100644 --- a/crates/tdbin/Cargo.toml +++ b/crates/tdbin/Cargo.toml @@ -13,12 +13,17 @@ publish = false [lints] workspace = true -# prost is the COMPETITOR baseline for the honest size/speed benchmark, pulled in -# only for the `size_gate` test and the `bench` example. It is a DEV-dependency so -# the shipped runtime stays zero-dep ([TDBIN-BENCH-CORPUS], [TDBIN-BENCH-GATE]). +# prost and rmp-serde are the COMPETITOR baselines for the honest size/speed +# benchmark, pulled in only for the `size_gate` test and the `bench` example. +# They are DEV-dependencies so the shipped runtime stays zero-dep +# ([TDBIN-BENCH-CORPUS], [TDBIN-BENCH-GATE]). The prost mirror structs carry +# `serde` derives so the SAME 1:1 fixture values feed the MessagePack baseline +# (`rmp-serde`) with no third struct set. [dev-dependencies] criterion = { version = "0.8.2", default-features = false, features = ["cargo_bench_support"] } prost = "0.14" +serde = { version = "1", features = ["derive"] } +rmp-serde = "1" # The TDBIN-vs-Protobuf speed benchmark, run via `cargo run --release --example bench`. [[example]] diff --git a/crates/tdbin/benches/gate.rs b/crates/tdbin/benches/gate.rs index d68d482..fb31e61 100644 --- a/crates/tdbin/benches/gate.rs +++ b/crates/tdbin/benches/gate.rs @@ -14,6 +14,8 @@ use std::time::Duration; use bench_corpus::{batches, corpus, documents, events}; use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; use prost::Message; +use serde::de::DeserializeOwned; +use serde::Serialize; use tdbin::{Struct, TdBin}; /// Return a TDBIN encoded message or terminate the benchmark process. diff --git a/crates/tdbin/examples/bench_data.rs b/crates/tdbin/examples/bench_data.rs index cdb023f..8dcf6e9 100644 --- a/crates/tdbin/examples/bench_data.rs +++ b/crates/tdbin/examples/bench_data.rs @@ -25,17 +25,19 @@ fn fixture( ) -> Result where T: Struct + TdBin, - P: Message, + P: Message + serde::Serialize, { let bare = td.to_bytes()?; let framed = td.to_framed_bytes(None)?; let packed = td.to_packed_framed_bytes(None)?; + let msgpack = rmp_serde::to_vec_named(pb)?; Ok(format!( - "{{\"name\":\"{name}\",\"shape\":\"{shape}\",\"corpus\":{corpus},\"logical_items\":{items},\"tdbin_bare\":{},\"tdbin_framed\":{},\"tdbin_packed_framed\":{},\"protobuf\":{}}}", + "{{\"name\":\"{name}\",\"shape\":\"{shape}\",\"corpus\":{corpus},\"logical_items\":{items},\"tdbin_bare\":{},\"tdbin_framed\":{},\"tdbin_packed_framed\":{},\"protobuf\":{},\"msgpack\":{}}}", bare.len(), framed.len(), packed.len(), - pb.encoded_len() + pb.encoded_len(), + msgpack.len() )) } diff --git a/crates/tdbin/tests/support/batch_corpus.rs b/crates/tdbin/tests/support/batch_corpus.rs index 60e9820..9d69125 100644 --- a/crates/tdbin/tests/support/batch_corpus.rs +++ b/crates/tdbin/tests/support/batch_corpus.rs @@ -13,6 +13,7 @@ pub const PERSON_COUNT: usize = 512; pub const CONTACT_COUNT: usize = 2_048; /// Protobuf record-heavy batch. +#[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, Message)] pub struct PbPersonBatch { /// Repeated records. @@ -21,6 +22,7 @@ pub struct PbPersonBatch { } /// Protobuf union-heavy batch. +#[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, Message)] pub struct PbContactBatch { /// Repeated oneof envelopes. @@ -29,6 +31,7 @@ pub struct PbContactBatch { } /// Protobuf message envelope required around each repeated oneof value. +#[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, Message)] pub struct PbContactEnvelope { /// Contact payload. @@ -37,6 +40,7 @@ pub struct PbContactEnvelope { } /// Protobuf mirror of the generated Contact union. +#[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Oneof)] pub enum PbContact { /// Email contact. diff --git a/crates/tdbin/tests/support/bench_corpus.rs b/crates/tdbin/tests/support/bench_corpus.rs index b5c2ba6..0d42f03 100644 --- a/crates/tdbin/tests/support/bench_corpus.rs +++ b/crates/tdbin/tests/support/bench_corpus.rs @@ -83,6 +83,7 @@ pub mod corpus { /// size and speed comparison is fair. pub mod pb { /// The `Address` message: `string street = 1; int64 zip = 2;`. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct Address { /// The street line. @@ -94,6 +95,7 @@ pub mod corpus { } /// The `EmailContact` message: `string addr = 1;`. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct EmailContact { /// The email address. @@ -102,6 +104,7 @@ pub mod corpus { } /// The `PhoneContact` message: `int64 number = 1; int64 country = 2;`. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct PhoneContact { /// The subscriber number. @@ -113,6 +116,7 @@ pub mod corpus { } /// The `contact` oneof: `EmailContact email = 7 | PhoneContact phone = 8`. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Oneof)] pub enum Contact { /// The email-contact variant. @@ -124,6 +128,7 @@ pub mod corpus { } /// The top-level `Person` message mirroring the TDBIN `Person` record. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct Person { /// The full name. @@ -150,6 +155,7 @@ pub mod corpus { } /// A metric batch with list-heavy, fixed-width data. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchMetricBatch { /// Batch identifier. @@ -176,6 +182,7 @@ pub mod corpus { } /// A named repeated-double metric column. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchMetricColumn { /// Column name. diff --git a/crates/tdbin/tests/support/document_corpus.rs b/crates/tdbin/tests/support/document_corpus.rs index f82290d..8e4a74d 100644 --- a/crates/tdbin/tests/support/document_corpus.rs +++ b/crates/tdbin/tests/support/document_corpus.rs @@ -14,6 +14,7 @@ const META_COUNT: usize = 16; /// Protobuf mirror types for the document fixture. pub mod pb { /// Protobuf diagram document. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchDocument { /// Stable document identifier. @@ -40,6 +41,7 @@ pub mod pb { } /// Protobuf diagram node. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchNode { /// Stable node identifier. @@ -72,6 +74,7 @@ pub mod pb { } /// Protobuf diagram edge. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchEdge { /// Stable edge identifier. @@ -95,6 +98,7 @@ pub mod pb { } /// Protobuf style rule. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchStyle { /// Selector expression. @@ -115,6 +119,7 @@ pub mod pb { } /// Protobuf metadata key/value pair. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchMeta { /// Metadata key. diff --git a/crates/tdbin/tests/support/event_corpus.rs b/crates/tdbin/tests/support/event_corpus.rs index 4056336..9dd5636 100644 --- a/crates/tdbin/tests/support/event_corpus.rs +++ b/crates/tdbin/tests/support/event_corpus.rs @@ -14,6 +14,7 @@ pub mod pb { use super::documents; /// Protobuf event-stream envelope. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchEventBatch { /// Ordered diagram events. @@ -22,6 +23,7 @@ pub mod pb { } /// Protobuf envelope for one event union. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchEventEnvelope { /// Event payload. @@ -30,6 +32,7 @@ pub mod pb { } /// Protobuf event oneof. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Oneof)] pub enum BenchEvent { /// A node was created. @@ -53,6 +56,7 @@ pub mod pb { } /// Protobuf node-created payload. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchNodeCreated { /// Owning document identifier. @@ -64,6 +68,7 @@ pub mod pb { } /// Protobuf node-moved payload. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchNodeMoved { /// Owning document identifier. @@ -81,6 +86,7 @@ pub mod pb { } /// Protobuf edge-added payload. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchEdgeAdded { /// Owning document identifier. @@ -92,6 +98,7 @@ pub mod pb { } /// Protobuf selection-changed payload. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchSelectionChanged { /// Owning document identifier. @@ -106,6 +113,7 @@ pub mod pb { } /// Protobuf view-changed payload. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, prost::Message)] pub struct BenchViewChanged { /// Owning document identifier. @@ -123,6 +131,7 @@ pub mod pb { } /// Protobuf empty heartbeat payload. + #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, Copy, PartialEq, prost::Message)] pub struct BenchHeartbeat {} } From 86475313140844f3d0ac85c78ff46882d4bb4e25 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:02:55 +1000 Subject: [PATCH 05/14] Benches --- crates/tdbin/benches/gate.rs | 33 +- docs/reports/tdbin-bench-data.json | 473 ++++++++++++++++++----------- docs/reports/tdbin-bench-report.md | 262 ++++++++++------ scripts/tdbin-bench-report.mjs | 43 ++- 4 files changed, 535 insertions(+), 276 deletions(-) diff --git a/crates/tdbin/benches/gate.rs b/crates/tdbin/benches/gate.rs index fb31e61..120e870 100644 --- a/crates/tdbin/benches/gate.rs +++ b/crates/tdbin/benches/gate.rs @@ -57,22 +57,35 @@ fn pb_bytes(value: &P) -> Vec { } } +/// Return a self-describing `MessagePack` (struct-as-map) message, or terminate. +fn mp_bytes(value: &P) -> Vec { + match rmp_serde::to_vec_named(value) { + Ok(bytes) => bytes, + Err(error) => { + eprintln!("msgpack encode failed before benchmark: {error:?}"); + std::process::exit(1); + } + } +} + /// Benchmark all encode/decode operations for one paired corpus fixture. fn bench_fixture(c: &mut Criterion, label: &str, td: &T, pb: &P) where T: Struct + TdBin, - P: Message + Default, + P: Message + Default + Serialize + DeserializeOwned, { let bare = td_bytes(td); let framed = td_framed_bytes(td, false); let packed_framed = td_framed_bytes(td, true); let protobuf = pb_bytes(pb); + let msgpack = mp_bytes(pb); println!( - "[{label}] sizes: tdbin_bare={} tdbin_framed={} tdbin_packed_framed={} protobuf={}", + "[{label}] sizes: tdbin_bare={} tdbin_framed={} tdbin_packed_framed={} protobuf={} msgpack={}", bare.len(), framed.len(), packed_framed.len(), - protobuf.len() + protobuf.len(), + msgpack.len() ); let mut group = c.benchmark_group(format!("tdbin_vs_protobuf/{label}")); @@ -107,6 +120,13 @@ where }); }, ); + let _ = group.bench_with_input( + BenchmarkId::new("msgpack_encode", label), + pb, + |b, value| { + b.iter(|| rmp_serde::to_vec_named(black_box(value))); + }, + ); let _ = group.bench_with_input( BenchmarkId::new("tdbin_decode_bare", label), &bare, @@ -135,6 +155,13 @@ where b.iter(|| P::decode(black_box(bytes.as_slice()))); }, ); + let _ = group.bench_with_input( + BenchmarkId::new("msgpack_decode", label), + &msgpack, + |b, bytes| { + b.iter(|| rmp_serde::from_slice::

(black_box(bytes.as_slice()))); + }, + ); group.finish(); } diff --git a/docs/reports/tdbin-bench-data.json b/docs/reports/tdbin-bench-data.json index ba29a41..08f7339 100644 --- a/docs/reports/tdbin-bench-data.json +++ b/docs/reports/tdbin-bench-data.json @@ -1,6 +1,6 @@ { "format_version": 1, - "generated_at": "2026-07-12T08:15:06.280Z", + "generated_at": "2026-07-12T09:23:43.780Z", "commands": ["cargo bench -p tdbin --bench gate -- --noplot", "node scripts/tdbin-bench-report.mjs"], "corpus_schemas": ["docs/benchmarks/tdbin-corpus.td", "docs/benchmarks/tdbin-corpus.proto"], "gate": { @@ -18,7 +18,7 @@ "memory_bytes": 38654705664, "rustc": "rustc 1.97.0 (2d8144b78 2026-07-07)", "cargo": "cargo 1.97.0 (c980f4866 2026-06-30)", - "dependencies": "tdbin v0.0.0 (/Users/christianfindlay/Documents/Code/typeDiagram/crates/tdbin)\n[dev-dependencies]\n├── criterion v0.8.2\n└── prost v0.14.3" + "dependencies": "tdbin v0.0.0 (/Users/christianfindlay/Documents/Code/typeDiagram/crates/tdbin)\n[dev-dependencies]\n├── criterion v0.8.2\n├── prost v0.14.3\n├── rmp-serde v1.3.1\n└── serde v1.0.228" }, "sizes": { "format_version": 1, @@ -31,7 +31,8 @@ "tdbin_bare": 160, "tdbin_framed": 172, "tdbin_packed_framed": 109, - "protobuf": 79 + "protobuf": 79, + "msgpack": 142 }, { "name": "without_address", @@ -41,7 +42,8 @@ "tdbin_bare": 112, "tdbin_framed": 124, "tdbin_packed_framed": 54, - "protobuf": 31 + "protobuf": 31, + "msgpack": 100 }, { "name": "metric_batch", @@ -51,7 +53,8 @@ "tdbin_bare": 43776, "tdbin_framed": 43788, "tdbin_packed_framed": 23045, - "protobuf": 84149 + "protobuf": 84149, + "msgpack": 90440 }, { "name": "person_batch", @@ -61,7 +64,8 @@ "tdbin_bare": 22976, "tdbin_framed": 22988, "tdbin_packed_framed": 20071, - "protobuf": 29184 + "protobuf": 29184, + "msgpack": 61963 }, { "name": "contact_batch", @@ -71,7 +75,8 @@ "tdbin_bare": 23144, "tdbin_framed": 23156, "tdbin_packed_framed": 22317, - "protobuf": 35221 + "protobuf": 35221, + "msgpack": 80162 }, { "name": "diagram_document", @@ -81,7 +86,8 @@ "tdbin_bare": 45160, "tdbin_framed": 45172, "tdbin_packed_framed": 37868, - "protobuf": 50788 + "protobuf": 50788, + "msgpack": 77410 }, { "name": "event_batch", @@ -91,7 +97,8 @@ "tdbin_bare": 116360, "tdbin_framed": 116372, "tdbin_packed_framed": 102861, - "protobuf": 131744 + "protobuf": 131744, + "msgpack": 230620 } ] }, @@ -99,450 +106,562 @@ { "fixture": "with_address", "operation": "tdbin_encode_bare", - "median_ns": 87.08787769284092, - "confidence_interval_ns": [85.83105121095318, 88.67318654924685], + "median_ns": 90.05732701385347, + "confidence_interval_ns": [87.31995034405222, 101.16328139082677], "sample_count": 50, - "sampled_time_ns": 5081719791 + "sampled_time_ns": 4708487627 }, { "fixture": "with_address", "operation": "tdbin_encode_framed", - "median_ns": 115.32005693641176, - "confidence_interval_ns": [109.03053126180885, 124.82153581045931], + "median_ns": 94.84974573045102, + "confidence_interval_ns": [91.5608905342954, 105.53577569863316], "sample_count": 50, - "sampled_time_ns": 4500933336 + "sampled_time_ns": 4164282420 }, { "fixture": "with_address", "operation": "tdbin_encode_packed_framed", - "median_ns": 288.4123942121779, - "confidence_interval_ns": [269.2336513571481, 320.60531501262585], + "median_ns": 185.09468119503362, + "confidence_interval_ns": [181.68482209576365, 189.34596546235838], "sample_count": 50, - "sampled_time_ns": 5825125663 + "sampled_time_ns": 3260575583 }, { "fixture": "with_address", "operation": "protobuf_encode", - "median_ns": 135.32454676758297, - "confidence_interval_ns": [121.44874794344264, 149.17690579037316], + "median_ns": 48.14281449263207, + "confidence_interval_ns": [47.50973129268929, 48.82923623717177], "sample_count": 50, - "sampled_time_ns": 5787919790 + "sampled_time_ns": 3512653040 + }, + { + "fixture": "with_address", + "operation": "msgpack_encode", + "median_ns": 269.4602297878922, + "confidence_interval_ns": [267.69217516634836, 271.45900190832765], + "sample_count": 50, + "sampled_time_ns": 4988903499 }, { "fixture": "with_address", "operation": "tdbin_decode_bare", - "median_ns": 398.2407696588902, - "confidence_interval_ns": [341.299922122231, 435.1841888393849], + "median_ns": 154.58307466575963, + "confidence_interval_ns": [154.04207678637778, 155.6891239370515], "sample_count": 50, - "sampled_time_ns": 5056105122 + "sampled_time_ns": 4914773751 }, { "fixture": "with_address", "operation": "tdbin_decode_framed", - "median_ns": 353.1594893576057, - "confidence_interval_ns": [324.6078110385472, 381.7991302522968], + "median_ns": 166.08854121114592, + "confidence_interval_ns": [159.90222650930758, 188.1398953103955], "sample_count": 50, - "sampled_time_ns": 4788730667 + "sampled_time_ns": 5029046879 }, { "fixture": "with_address", "operation": "tdbin_decode_packed_framed", - "median_ns": 1046.1323179579745, - "confidence_interval_ns": [742.6619580668088, 1357.8098454318947], + "median_ns": 622.6389741606911, + "confidence_interval_ns": [589.3267347078568, 699.4985912752776], "sample_count": 50, - "sampled_time_ns": 7339778090 + "sampled_time_ns": 6760547419 }, { "fixture": "with_address", "operation": "protobuf_decode", - "median_ns": 240.10802975468832, - "confidence_interval_ns": [197.31696990729264, 265.83479589909246], + "median_ns": 157.46493124861388, + "confidence_interval_ns": [155.60476131071192, 165.7297384397871], + "sample_count": 50, + "sampled_time_ns": 5433692420 + }, + { + "fixture": "with_address", + "operation": "msgpack_decode", + "median_ns": 219.07397116185683, + "confidence_interval_ns": [209.53488995283692, 242.458839502644], "sample_count": 50, - "sampled_time_ns": 6804678253 + "sampled_time_ns": 5022273879 }, { "fixture": "without_address", "operation": "tdbin_encode_bare", - "median_ns": 140.18080663110226, - "confidence_interval_ns": [129.79643429151733, 161.8109129361692], + "median_ns": 71.24464772900714, + "confidence_interval_ns": [68.64370619864668, 76.5652242700573], "sample_count": 50, - "sampled_time_ns": 7027197791 + "sampled_time_ns": 3879382917 }, { "fixture": "without_address", "operation": "tdbin_encode_framed", - "median_ns": 127.88736008268663, - "confidence_interval_ns": [116.34983389326372, 144.24127364546777], + "median_ns": 66.25831852841006, + "confidence_interval_ns": [65.79774411033351, 67.19337824124857], "sample_count": 50, - "sampled_time_ns": 3582317666 + "sampled_time_ns": 3292058962 }, { "fixture": "without_address", "operation": "tdbin_encode_packed_framed", - "median_ns": 159.9392335485498, - "confidence_interval_ns": [157.3632686643561, 161.7110227060933], + "median_ns": 177.17926415033156, + "confidence_interval_ns": [164.1018320066218, 202.99525894855802], "sample_count": 50, - "sampled_time_ns": 4874383206 + "sampled_time_ns": 5970711499 }, { "fixture": "without_address", "operation": "protobuf_encode", - "median_ns": 36.66844115564437, - "confidence_interval_ns": [35.84229063776525, 37.806351787137245], + "median_ns": 37.65441511081784, + "confidence_interval_ns": [35.141182362713096, 41.79245157410987], "sample_count": 50, - "sampled_time_ns": 5060751292 + "sampled_time_ns": 5080759545 + }, + { + "fixture": "without_address", + "operation": "msgpack_encode", + "median_ns": 343.282520848006, + "confidence_interval_ns": [302.4747483949332, 362.5696381883094], + "sample_count": 50, + "sampled_time_ns": 5402274792 }, { "fixture": "without_address", "operation": "tdbin_decode_bare", - "median_ns": 78.4862488543285, - "confidence_interval_ns": [77.71920331111673, 79.11501939914129], + "median_ns": 76.7984900027673, + "confidence_interval_ns": [71.65277745794114, 89.58418560757144], "sample_count": 50, - "sampled_time_ns": 5224182423 + "sampled_time_ns": 6711175121 }, { "fixture": "without_address", "operation": "tdbin_decode_framed", - "median_ns": 78.06490482329005, - "confidence_interval_ns": [77.34051443792157, 80.09066030707751], + "median_ns": 70.87808074538835, + "confidence_interval_ns": [70.59974120550089, 71.67373181120585], "sample_count": 50, - "sampled_time_ns": 5208703751 + "sampled_time_ns": 5069348543 }, { "fixture": "without_address", "operation": "tdbin_decode_packed_framed", - "median_ns": 535.581008719854, - "confidence_interval_ns": [527.6173342290977, 550.3626304754091], + "median_ns": 503.91951086281813, + "confidence_interval_ns": [492.48392302917443, 563.7603335745704], "sample_count": 50, - "sampled_time_ns": 5093168913 + "sampled_time_ns": 7010311829 }, { "fixture": "without_address", "operation": "protobuf_decode", - "median_ns": 50.26318041081733, - "confidence_interval_ns": [49.5605513132067, 51.29787830728764], + "median_ns": 59.88025371587054, + "confidence_interval_ns": [55.95282852373003, 72.98078501016434], + "sample_count": 50, + "sampled_time_ns": 3556799747 + }, + { + "fixture": "without_address", + "operation": "msgpack_decode", + "median_ns": 112.9894571248827, + "confidence_interval_ns": [112.01803022592519, 113.97490657130497], "sample_count": 50, - "sampled_time_ns": 5065208997 + "sampled_time_ns": 3502350879 }, { "fixture": "metric_batch", "operation": "tdbin_encode_bare", - "median_ns": 12082.829555045011, - "confidence_interval_ns": [11833.6111827957, 12266.268867962332], + "median_ns": 11588.577888257576, + "confidence_interval_ns": [11444.932142857142, 11738.95706845238], "sample_count": 50, - "sampled_time_ns": 4873371085 + "sampled_time_ns": 5056392958 }, { "fixture": "metric_batch", "operation": "tdbin_encode_framed", - "median_ns": 11870.648547758285, - "confidence_interval_ns": [11775.439720270337, 12045.111239711934], + "median_ns": 11883.646245338543, + "confidence_interval_ns": [11631.997353436522, 12106.371909000989], "sample_count": 50, - "sampled_time_ns": 4962783041 + "sampled_time_ns": 5063612791 }, { "fixture": "metric_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 23442.387840560357, - "confidence_interval_ns": [23196.143658810324, 23813.560072267388], + "median_ns": 22957.34709072978, + "confidence_interval_ns": [22782.916478820327, 23148.726418378003], "sample_count": 50, - "sampled_time_ns": 4962805295 + "sampled_time_ns": 4999250163 }, { "fixture": "metric_batch", "operation": "protobuf_encode", - "median_ns": 47464.1642094136, - "confidence_interval_ns": [46899.58597603947, 48049.66095471236], + "median_ns": 45932.99861363178, + "confidence_interval_ns": [45139.31545338442, 46773.46767241379], "sample_count": 50, - "sampled_time_ns": 5241324749 + "sampled_time_ns": 5910333299 + }, + { + "fixture": "metric_batch", + "operation": "msgpack_encode", + "median_ns": 55672.97346230158, + "confidence_interval_ns": [52642.68783068783, 67791.4203443391], + "sample_count": 50, + "sampled_time_ns": 6259996379 }, { "fixture": "metric_batch", "operation": "tdbin_decode_bare", - "median_ns": 7469.2159931319875, - "confidence_interval_ns": [7384.006635700066, 7666.726962780775], + "median_ns": 6470.843999896817, + "confidence_interval_ns": [6401.489143192488, 7546.199711087035], "sample_count": 50, - "sampled_time_ns": 5405004835 + "sampled_time_ns": 3685052081 }, { "fixture": "metric_batch", "operation": "tdbin_decode_framed", - "median_ns": 7321.017026985111, - "confidence_interval_ns": [7265.559893839192, 7495.359400501672], + "median_ns": 10620.987818830337, + "confidence_interval_ns": [8260.747885680956, 13678.352041101245], "sample_count": 50, - "sampled_time_ns": 5087042869 + "sampled_time_ns": 7127277080 }, { "fixture": "metric_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 34458.174430017505, - "confidence_interval_ns": [33192.6339031339, 35442.424836601305], + "median_ns": 39007.977904647436, + "confidence_interval_ns": [37516.5625, 43444.448979591834], "sample_count": 50, - "sampled_time_ns": 4653776461 + "sampled_time_ns": 4056282081 }, { "fixture": "metric_batch", "operation": "protobuf_decode", - "median_ns": 38167.277021011396, - "confidence_interval_ns": [37096.64351851852, 38959.117438271605], + "median_ns": 37773.42722039473, + "confidence_interval_ns": [35668.45745614035, 40497.11518379282], + "sample_count": 50, + "sampled_time_ns": 2731351209 + }, + { + "fixture": "metric_batch", + "operation": "msgpack_decode", + "median_ns": 51038.85957792208, + "confidence_interval_ns": [50881.19191919192, 51327.96376114705], "sample_count": 50, - "sampled_time_ns": 5333133414 + "sampled_time_ns": 5559499837 }, { "fixture": "person_batch", "operation": "tdbin_encode_bare", - "median_ns": 13184.354094827586, - "confidence_interval_ns": [12986.266745036573, 13440.801420494206], + "median_ns": 15290.80782274836, + "confidence_interval_ns": [13817.653687943262, 20269.366489361702], "sample_count": 50, - "sampled_time_ns": 4935882041 + "sampled_time_ns": 6342106667 }, { "fixture": "person_batch", "operation": "tdbin_encode_framed", - "median_ns": 12056.71304097951, - "confidence_interval_ns": [11790, 12398.041851851853], + "median_ns": 11869.493530701755, + "confidence_interval_ns": [11735.392543859649, 11972.639453748006], "sample_count": 50, - "sampled_time_ns": 4790011457 + "sampled_time_ns": 3470387204 }, { "fixture": "person_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 15041.404648956357, - "confidence_interval_ns": [14915.122365285924, 15137.542137096774], + "median_ns": 19652.53206736889, + "confidence_interval_ns": [18662.184752747253, 27378.83910960834], "sample_count": 50, - "sampled_time_ns": 4772413331 + "sampled_time_ns": 6760689083 }, { "fixture": "person_batch", "operation": "protobuf_encode", - "median_ns": 16421.974406683403, - "confidence_interval_ns": [16250.355749056871, 16873.18663318986], + "median_ns": 19272.036319340332, + "confidence_interval_ns": [18868.090282131663, 19450.256786500366], + "sample_count": 50, + "sampled_time_ns": 3617596330 + }, + { + "fixture": "person_batch", + "operation": "msgpack_encode", + "median_ns": 51123.48028906956, + "confidence_interval_ns": [49243.72666666667, 60302.65625], "sample_count": 50, - "sampled_time_ns": 6627863876 + "sampled_time_ns": 4209903958 }, { "fixture": "person_batch", "operation": "tdbin_decode_bare", - "median_ns": 70059.57651515151, - "confidence_interval_ns": [62935.340157085564, 74720.77045454546], + "median_ns": 36895.09718319064, + "confidence_interval_ns": [36678.90182743658, 37988.8310676862], "sample_count": 50, - "sampled_time_ns": 5950221541 + "sampled_time_ns": 5816982128 }, { "fixture": "person_batch", "operation": "tdbin_decode_framed", - "median_ns": 91897.49752385452, - "confidence_interval_ns": [79752.96610169491, 97922.02361492597], + "median_ns": 46003.40461060678, + "confidence_interval_ns": [44472.876442307694, 49309.483235785956], "sample_count": 50, - "sampled_time_ns": 7185206166 + "sampled_time_ns": 4643514870 }, { "fixture": "person_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 92244.76265256734, - "confidence_interval_ns": [70133.96527777778, 113053.5729061128], + "median_ns": 41893.51758028793, + "confidence_interval_ns": [41422.78309165526, 42697.86843853821], "sample_count": 50, - "sampled_time_ns": 4669964288 + "sampled_time_ns": 2513662209 }, { "fixture": "person_batch", "operation": "protobuf_decode", - "median_ns": 141485.84999999998, - "confidence_interval_ns": [124303.88621794872, 152091.45384615383], + "median_ns": 61638.01116352201, + "confidence_interval_ns": [59350.70927993839, 64847.251076581575], "sample_count": 50, - "sampled_time_ns": 4667564002 + "sampled_time_ns": 4309451709 + }, + { + "fixture": "person_batch", + "operation": "msgpack_decode", + "median_ns": 89633.009939985, + "confidence_interval_ns": [89044.76998545646, 91726.98185483871], + "sample_count": 50, + "sampled_time_ns": 3926919333 }, { "fixture": "contact_batch", "operation": "tdbin_encode_bare", - "median_ns": 17033.04363255719, - "confidence_interval_ns": [13746.51633986928, 21853.716536614647], + "median_ns": 9266.606272211855, + "confidence_interval_ns": [9140.026663192635, 9452.01530986432], "sample_count": 50, - "sampled_time_ns": 3498833250 + "sampled_time_ns": 3857291625 }, { "fixture": "contact_batch", "operation": "tdbin_encode_framed", - "median_ns": 21809.42612029969, - "confidence_interval_ns": [20410.860321585187, 23426.51251817521], + "median_ns": 9234.68080699262, + "confidence_interval_ns": [9169.02528223883, 9297.520203927492], "sample_count": 50, - "sampled_time_ns": 7438347336 + "sampled_time_ns": 3977619040 }, { "fixture": "contact_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 27871.796569178852, - "confidence_interval_ns": [25198.54739149454, 30680.11811023622], + "median_ns": 14903.249021526419, + "confidence_interval_ns": [14180.89584960106, 16108.447488584476], "sample_count": 50, - "sampled_time_ns": 4811547712 + "sampled_time_ns": 4533762125 }, { "fixture": "contact_batch", "operation": "protobuf_encode", - "median_ns": 53212.414046601545, - "confidence_interval_ns": [49860.01893162393, 58796.2425170068], + "median_ns": 32440.645961369624, + "confidence_interval_ns": [29305.34825870647, 36745.66439232409], + "sample_count": 50, + "sampled_time_ns": 3809607088 + }, + { + "fixture": "contact_batch", + "operation": "msgpack_encode", + "median_ns": 87267.0824929972, + "confidence_interval_ns": [80904.33904761905, 106057.5457912458], "sample_count": 50, - "sampled_time_ns": 4086413454 + "sampled_time_ns": 6489778831 }, { "fixture": "contact_batch", "operation": "tdbin_decode_bare", - "median_ns": 31445.512604709973, - "confidence_interval_ns": [31284.4176705653, 31701.027013279727], + "median_ns": 33600.724575163404, + "confidence_interval_ns": [31363.31495098039, 38207.81099744246], "sample_count": 50, - "sampled_time_ns": 4859493915 + "sampled_time_ns": 4708315081 }, { "fixture": "contact_batch", "operation": "tdbin_decode_framed", - "median_ns": 31063.60227027027, - "confidence_interval_ns": [30919.645161290322, 31181.92472653061], + "median_ns": 31002.54762079152, + "confidence_interval_ns": [30748.072439024392, 31164.442985199083], "sample_count": 50, - "sampled_time_ns": 4954496997 + "sampled_time_ns": 4892351754 }, { "fixture": "contact_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 33327.808050492924, - "confidence_interval_ns": [33163.28394357743, 33453.610270774974], + "median_ns": 33539.338768664245, + "confidence_interval_ns": [33423.61557568674, 33741.69632768362], "sample_count": 50, - "sampled_time_ns": 5061957790 + "sampled_time_ns": 5169715916 }, { "fixture": "contact_batch", "operation": "protobuf_decode", - "median_ns": 59526.56932966024, - "confidence_interval_ns": [59360.61471861472, 59659.09090909091], + "median_ns": 80232.23790343205, + "confidence_interval_ns": [75230.9550960735, 96771.17678571428], "sample_count": 50, - "sampled_time_ns": 5015078166 + "sampled_time_ns": 5430855792 + }, + { + "fixture": "contact_batch", + "operation": "msgpack_decode", + "median_ns": 126633.81546296296, + "confidence_interval_ns": [112108.15954081039, 139730.67871840094], + "sample_count": 50, + "sampled_time_ns": 4300871043 }, { "fixture": "diagram_document", "operation": "tdbin_encode_bare", - "median_ns": 12303.538517852983, - "confidence_interval_ns": [12255.445338512764, 12370.741614255765], + "median_ns": 16328.420353403142, + "confidence_interval_ns": [15171.863720357253, 21075.388241710294], "sample_count": 50, - "sampled_time_ns": 5001143459 + "sampled_time_ns": 5535814005 }, { "fixture": "diagram_document", "operation": "tdbin_encode_framed", - "median_ns": 12275.395630477118, - "confidence_interval_ns": [12244.286216596343, 12311.916442230002], + "median_ns": 15389.819857561793, + "confidence_interval_ns": [14421.718637992832, 17625.37107725873], "sample_count": 50, - "sampled_time_ns": 4944547753 + "sampled_time_ns": 5265309128 }, { "fixture": "diagram_document", "operation": "tdbin_encode_packed_framed", - "median_ns": 19568.948569082804, - "confidence_interval_ns": [19495.644532019705, 19615.32866845798], + "median_ns": 19980.98353297734, + "confidence_interval_ns": [19798.90711282114, 20305.381940019215], "sample_count": 50, - "sampled_time_ns": 5091890456 + "sampled_time_ns": 5193874708 }, { "fixture": "diagram_document", "operation": "protobuf_encode", - "median_ns": 23470.591920731706, - "confidence_interval_ns": [23191.088059288755, 23758.10529446758], + "median_ns": 23326.629345443158, + "confidence_interval_ns": [22851.457153451996, 23895.02920001312], + "sample_count": 50, + "sampled_time_ns": 3087903331 + }, + { + "fixture": "diagram_document", + "operation": "msgpack_encode", + "median_ns": 56192.35987394958, + "confidence_interval_ns": [55766.18405923345, 56471.612087912086], "sample_count": 50, - "sampled_time_ns": 4960373626 + "sampled_time_ns": 5022687330 }, { "fixture": "diagram_document", "operation": "tdbin_decode_bare", - "median_ns": 70723.87240499648, - "confidence_interval_ns": [70433.0233493891, 71106.44327731093], + "median_ns": 71630.12437229438, + "confidence_interval_ns": [71195.04601525876, 72026.69862258954], "sample_count": 50, - "sampled_time_ns": 5069408751 + "sampled_time_ns": 5034613038 }, { "fixture": "diagram_document", "operation": "tdbin_decode_framed", - "median_ns": 71217.77150023152, - "confidence_interval_ns": [70788.32794612795, 71583.15874125874], + "median_ns": 86172.74286987522, + "confidence_interval_ns": [80225.20238095238, 94685.40481411864], "sample_count": 50, - "sampled_time_ns": 5245726838 + "sampled_time_ns": 6169789835 }, { "fixture": "diagram_document", "operation": "tdbin_decode_packed_framed", - "median_ns": 80341.49526239067, - "confidence_interval_ns": [79553.77899452463, 80955.51020408163], + "median_ns": 100677.4273823764, + "confidence_interval_ns": [95732.52523219814, 105802.72240497076], "sample_count": 50, - "sampled_time_ns": 5079646127 + "sampled_time_ns": 5425883205 }, { "fixture": "diagram_document", "operation": "protobuf_decode", - "median_ns": 112113.07058531746, - "confidence_interval_ns": [111145.54489795919, 113018.80571428571], + "median_ns": 144501.27328431373, + "confidence_interval_ns": [122208.66524256761, 156521.6603660486], "sample_count": 50, - "sampled_time_ns": 5055293581 + "sampled_time_ns": 3193202378 + }, + { + "fixture": "diagram_document", + "operation": "msgpack_decode", + "median_ns": 141899.19012374687, + "confidence_interval_ns": [136311.68878718535, 152190.8447368421], + "sample_count": 50, + "sampled_time_ns": 3682530081 }, { "fixture": "event_batch", "operation": "tdbin_encode_bare", - "median_ns": 36803.97532586858, - "confidence_interval_ns": [36442.88668224299, 37131.088360237896], + "median_ns": 39326.42825396826, + "confidence_interval_ns": [38628.17156462585, 40455.19549382717], "sample_count": 50, - "sampled_time_ns": 5085971289 + "sampled_time_ns": 5842968294 }, { "fixture": "event_batch", "operation": "tdbin_encode_framed", - "median_ns": 35460.84059225658, - "confidence_interval_ns": [35390.77260760073, 35622.738213399505], + "median_ns": 36347.520224873166, + "confidence_interval_ns": [36062.61426303476, 36593.46193771626], "sample_count": 50, - "sampled_time_ns": 4730817414 + "sampled_time_ns": 3370017125 }, { "fixture": "event_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 52131.32052310655, - "confidence_interval_ns": [51942.84539473684, 52407.82894736842], + "median_ns": 52323.53520017993, + "confidence_interval_ns": [52166.07444444444, 52560.782222222224], "sample_count": 50, - "sampled_time_ns": 5078327420 + "sampled_time_ns": 5031736957 }, { "fixture": "event_batch", "operation": "protobuf_encode", - "median_ns": 56485.33028222731, - "confidence_interval_ns": [55341.329106280195, 56944.85186066337], + "median_ns": 81632.32540937324, + "confidence_interval_ns": [79429.96108534322, 86326.80872630044], + "sample_count": 50, + "sampled_time_ns": 6664293203 + }, + { + "fixture": "event_batch", + "operation": "msgpack_encode", + "median_ns": 174449.58301357733, + "confidence_interval_ns": [161878.3208041958, 197442.28468899522], "sample_count": 50, - "sampled_time_ns": 5012815130 + "sampled_time_ns": 5853691048 }, { "fixture": "event_batch", "operation": "tdbin_decode_bare", - "median_ns": 178575.90052700922, - "confidence_interval_ns": [177347.93741765482, 178971.63602484472], + "median_ns": 262856.50532800535, + "confidence_interval_ns": [239518.29268292684, 319617.43163265305], "sample_count": 50, - "sampled_time_ns": 5231950164 + "sampled_time_ns": 5652190832 }, { "fixture": "event_batch", "operation": "tdbin_decode_framed", - "median_ns": 178355.36883116883, - "confidence_interval_ns": [177243.006993007, 179310.74510579684], + "median_ns": 229887.3074252137, + "confidence_interval_ns": [216824.5625, 282880.2083333333], "sample_count": 50, - "sampled_time_ns": 5067338998 + "sampled_time_ns": 3370820623 }, { "fixture": "event_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 202789.675, - "confidence_interval_ns": [201010.69416666665, 204871.66666666666], + "median_ns": 207470.8923225999, + "confidence_interval_ns": [206502.86510416667, 208507.31891891893], "sample_count": 50, - "sampled_time_ns": 5253962004 + "sampled_time_ns": 5315300083 }, { "fixture": "event_batch", "operation": "protobuf_decode", - "median_ns": 278907.4770164046, - "confidence_interval_ns": [277196.82857142854, 280066.96875], + "median_ns": 282270.6351190476, + "confidence_interval_ns": [279410.26785714284, 283315.6894409938], + "sample_count": 50, + "sampled_time_ns": 5034756794 + }, + { + "fixture": "event_batch", + "operation": "msgpack_decode", + "median_ns": 389899.22987012984, + "confidence_interval_ns": [387274.1196172249, 413053.7909090909], "sample_count": 50, - "sampled_time_ns": 5062289626 + "sampled_time_ns": 6327182500 } ] } diff --git a/docs/reports/tdbin-bench-report.md b/docs/reports/tdbin-bench-report.md index 71844fe..afb03e1 100644 --- a/docs/reports/tdbin-bench-report.md +++ b/docs/reports/tdbin-bench-report.md @@ -3,18 +3,78 @@ > GENERATED FILE. Source: `scripts/tdbin-bench-report.mjs` and `docs/reports/tdbin-bench-data.json`. > Every value and verdict is computed from machine-readable Criterion and encoder output. No benchmark result is entered manually. -Generated: 2026-07-12T08:15:06.280Z +Generated: 2026-07-12T09:23:43.780Z -Raw data SHA-256: `602fb5e604e1183ca3d99d1361f44184790f19ef82e30ac677a39645a5a9b4dd` +Raw data SHA-256: `9153a769c7062e5de6ad54be8ddf77f82655d1c362dbac55a930c7a161da8e7e` ## Result -**Specification gate ([TDBIN-BENCH-CORPUS] committed workloads): PASS.** 3 of 3 corpus fixtures have a production wire mode that is simultaneously smaller than Protobuf AND at least 1.50x faster on both encode and decode. Stress rows: 1 of 4 pass the same bar. +**Specification gate ([TDBIN-BENCH-CORPUS] committed workloads): FAIL.** 2 of 3 corpus fixtures have a production wire mode that is simultaneously smaller than Protobuf AND at least 1.50x faster on both encode and decode. Stress rows: 1 of 4 pass the same bar. -Qualifying modes: `with_address` = none, `without_address` = none, `metric_batch` = framed, `person_batch` = none, `contact_batch` = framed & packed framed, `diagram_document` = framed, `event_batch` = framed. +Qualifying modes: `with_address` = none, `without_address` = none, `metric_batch` = framed, `person_batch` = none, `contact_batch` = framed & packed framed, `diagram_document` = framed, `event_batch` = none. The release gate ([TDBIN-BENCH-GATE]) requires, for every corpus entry — the committed realistic schemas in `docs/benchmarks/tdbin-corpus.{td,proto}` (record-heavy document, union-heavy event stream, list-heavy dataset) — that at least one self-describing production wire mode (framed, or packed framed; the frame's PACKED flag makes the two interchangeable to every decoder) beats Protobuf on size and by 1.50x on both encode and decode simultaneously. Both modes are always measured and published below. Stress rows (marked) are reported against the identical bar; the tiny single-message rows carry a fixed 12-byte frame plus pointer-per-string overhead that no fixed-layout format recovers at sub-100-byte payloads (research §2.2), so they are not corpus entries. +## At a Glance + +One table per fixture. Rows are the three protocols; columns are the bench types (size, encode speed, decode speed). `tdbin` is the **framed** wire mode — the self-describing production peer of `msgpack` (struct-as-map, via `rmp-serde`) and `protobuf`. Lower is better everywhere. The full multi-mode breakdown (TDBIN bare/framed/packed and every Criterion statistic) is in the detailed tables further down. + +### `with_address` — tiny nested record and union (stress, 1 items) + +| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | +| -------------- | -----------: | ----------: | --------------: | --------------: | +| tdbin (framed) | 172 | +117.7% | 94.85 ns | 166.09 ns | +| protobuf | 79 | same | 48.14 ns | 157.46 ns | +| msgpack | 142 | +79.7% | 269.46 ns | 219.07 ns | + +### `without_address` — tiny sparse record and union (stress, 1 items) + +| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | +| -------------- | -----------: | ----------: | --------------: | --------------: | +| tdbin (framed) | 124 | +300.0% | 66.26 ns | 70.88 ns | +| protobuf | 31 | same | 37.65 ns | 59.88 ns | +| msgpack | 100 | +222.6% | 343.28 ns | 112.99 ns | + +### `metric_batch` — list-heavy telemetry (corpus, 4,096 items) + +| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | +| -------------- | -----------: | ----------: | --------------: | --------------: | +| tdbin (framed) | 43,788 | -48.0% | 11.884 us | 10.621 us | +| protobuf | 84,149 | same | 45.933 us | 37.773 us | +| msgpack | 90,440 | +7.5% | 55.673 us | 51.039 us | + +### `person_batch` — repeated records (stress, 512 items) + +| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | +| -------------- | -----------: | ----------: | --------------: | --------------: | +| tdbin (framed) | 22,988 | -21.2% | 11.869 us | 46.003 us | +| protobuf | 29,184 | same | 19.272 us | 61.638 us | +| msgpack | 61,963 | +112.3% | 51.123 us | 89.633 us | + +### `contact_batch` — repeated unions (stress, 2,048 items) + +| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | +| -------------- | -----------: | ----------: | --------------: | --------------: | +| tdbin (framed) | 23,156 | -34.3% | 9.235 us | 31.003 us | +| protobuf | 35,221 | same | 32.441 us | 80.232 us | +| msgpack | 80,162 | +127.6% | 87.267 us | 126.634 us | + +### `diagram_document` — record-heavy diagram document (corpus, 768 items) + +| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | +| -------------- | -----------: | ----------: | --------------: | --------------: | +| tdbin (framed) | 45,172 | -11.1% | 15.390 us | 86.173 us | +| protobuf | 50,788 | same | 23.327 us | 144.501 us | +| msgpack | 77,410 | +52.4% | 56.192 us | 141.899 us | + +### `event_batch` — union-heavy event stream (corpus, 2,048 items) + +| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | +| -------------- | -----------: | ----------: | --------------: | --------------: | +| tdbin (framed) | 116,372 | -11.7% | 36.348 us | 229.887 us | +| protobuf | 131,744 | same | 81.632 us | 282.271 us | +| msgpack | 230,620 | +75.1% | 174.450 us | 389.899 us | + ## Environment | Field | Value | @@ -32,83 +92,99 @@ Dependency tree: tdbin v0.0.0 (/Users/christianfindlay/Documents/Code/typeDiagram/crates/tdbin) [dev-dependencies] ├── criterion v0.8.2 -└── prost v0.14.3 +├── prost v0.14.3 +├── rmp-serde v1.3.1 +└── serde v1.0.228 ``` ## Encoded Size All sizes are bytes. Percentage columns are relative to Protobuf; negative is smaller. -| Fixture | Shape | Role | Items | TDBIN bare | TDBIN framed | TDBIN packed framed | Protobuf | Framed delta | Packed delta | -| ------------------ | ----------------------------- | ------ | ----: | ---------: | -----------: | ------------------: | -------: | -----------: | -----------: | -| `with_address` | tiny nested record and union | stress | 1 | 160 | 172 | 109 | 79 | 117.7% | 38.0% | -| `without_address` | tiny sparse record and union | stress | 1 | 112 | 124 | 54 | 31 | 300.0% | 74.2% | -| `metric_batch` | list-heavy telemetry | corpus | 4,096 | 43,776 | 43,788 | 23,045 | 84,149 | -48.0% | -72.6% | -| `person_batch` | repeated records | stress | 512 | 22,976 | 22,988 | 20,071 | 29,184 | -21.2% | -31.2% | -| `contact_batch` | repeated unions | stress | 2,048 | 23,144 | 23,156 | 22,317 | 35,221 | -34.3% | -36.6% | -| `diagram_document` | record-heavy diagram document | corpus | 768 | 45,160 | 45,172 | 37,868 | 50,788 | -11.1% | -25.4% | -| `event_batch` | union-heavy event stream | corpus | 2,048 | 116,360 | 116,372 | 102,861 | 131,744 | -11.7% | -21.9% | +| Fixture | Shape | Role | Items | TDBIN bare | TDBIN framed | TDBIN packed framed | Protobuf | MessagePack | Framed delta | Packed delta | +| ------------------ | ----------------------------- | ------ | ----: | ---------: | -----------: | ------------------: | -------: | ----------: | -----------: | -----------: | +| `with_address` | tiny nested record and union | stress | 1 | 160 | 172 | 109 | 79 | 142 | 117.7% | 38.0% | +| `without_address` | tiny sparse record and union | stress | 1 | 112 | 124 | 54 | 31 | 100 | 300.0% | 74.2% | +| `metric_batch` | list-heavy telemetry | corpus | 4,096 | 43,776 | 43,788 | 23,045 | 84,149 | 90,440 | -48.0% | -72.6% | +| `person_batch` | repeated records | stress | 512 | 22,976 | 22,988 | 20,071 | 29,184 | 61,963 | -21.2% | -31.2% | +| `contact_batch` | repeated unions | stress | 2,048 | 23,144 | 23,156 | 22,317 | 35,221 | 80,162 | -34.3% | -36.6% | +| `diagram_document` | record-heavy diagram document | corpus | 768 | 45,160 | 45,172 | 37,868 | 50,788 | 77,410 | -11.1% | -25.4% | +| `event_batch` | union-heavy event stream | corpus | 2,048 | 116,360 | 116,372 | 102,861 | 131,744 | 230,620 | -11.7% | -21.9% | ## Criterion Medians | Fixture | Operation | Samples | Sampled time | Median | CI lower | CI upper | | ------------------ | ---------------------------- | ------: | -----------: | ---------: | ---------: | ---------: | -| `with_address` | `tdbin_encode_bare` | 50 | 5081.720 ms | 87.09 ns | 85.83 ns | 88.67 ns | -| `with_address` | `tdbin_encode_framed` | 50 | 4500.933 ms | 115.32 ns | 109.03 ns | 124.82 ns | -| `with_address` | `tdbin_encode_packed_framed` | 50 | 5825.126 ms | 288.41 ns | 269.23 ns | 320.61 ns | -| `with_address` | `protobuf_encode` | 50 | 5787.920 ms | 135.32 ns | 121.45 ns | 149.18 ns | -| `with_address` | `tdbin_decode_bare` | 50 | 5056.105 ms | 398.24 ns | 341.30 ns | 435.18 ns | -| `with_address` | `tdbin_decode_framed` | 50 | 4788.731 ms | 353.16 ns | 324.61 ns | 381.80 ns | -| `with_address` | `tdbin_decode_packed_framed` | 50 | 7339.778 ms | 1.046 us | 742.66 ns | 1.358 us | -| `with_address` | `protobuf_decode` | 50 | 6804.678 ms | 240.11 ns | 197.32 ns | 265.83 ns | -| `without_address` | `tdbin_encode_bare` | 50 | 7027.198 ms | 140.18 ns | 129.80 ns | 161.81 ns | -| `without_address` | `tdbin_encode_framed` | 50 | 3582.318 ms | 127.89 ns | 116.35 ns | 144.24 ns | -| `without_address` | `tdbin_encode_packed_framed` | 50 | 4874.383 ms | 159.94 ns | 157.36 ns | 161.71 ns | -| `without_address` | `protobuf_encode` | 50 | 5060.751 ms | 36.67 ns | 35.84 ns | 37.81 ns | -| `without_address` | `tdbin_decode_bare` | 50 | 5224.182 ms | 78.49 ns | 77.72 ns | 79.12 ns | -| `without_address` | `tdbin_decode_framed` | 50 | 5208.704 ms | 78.06 ns | 77.34 ns | 80.09 ns | -| `without_address` | `tdbin_decode_packed_framed` | 50 | 5093.169 ms | 535.58 ns | 527.62 ns | 550.36 ns | -| `without_address` | `protobuf_decode` | 50 | 5065.209 ms | 50.26 ns | 49.56 ns | 51.30 ns | -| `metric_batch` | `tdbin_encode_bare` | 50 | 4873.371 ms | 12.083 us | 11.834 us | 12.266 us | -| `metric_batch` | `tdbin_encode_framed` | 50 | 4962.783 ms | 11.871 us | 11.775 us | 12.045 us | -| `metric_batch` | `tdbin_encode_packed_framed` | 50 | 4962.805 ms | 23.442 us | 23.196 us | 23.814 us | -| `metric_batch` | `protobuf_encode` | 50 | 5241.325 ms | 47.464 us | 46.900 us | 48.050 us | -| `metric_batch` | `tdbin_decode_bare` | 50 | 5405.005 ms | 7.469 us | 7.384 us | 7.667 us | -| `metric_batch` | `tdbin_decode_framed` | 50 | 5087.043 ms | 7.321 us | 7.266 us | 7.495 us | -| `metric_batch` | `tdbin_decode_packed_framed` | 50 | 4653.776 ms | 34.458 us | 33.193 us | 35.442 us | -| `metric_batch` | `protobuf_decode` | 50 | 5333.133 ms | 38.167 us | 37.097 us | 38.959 us | -| `person_batch` | `tdbin_encode_bare` | 50 | 4935.882 ms | 13.184 us | 12.986 us | 13.441 us | -| `person_batch` | `tdbin_encode_framed` | 50 | 4790.011 ms | 12.057 us | 11.790 us | 12.398 us | -| `person_batch` | `tdbin_encode_packed_framed` | 50 | 4772.413 ms | 15.041 us | 14.915 us | 15.138 us | -| `person_batch` | `protobuf_encode` | 50 | 6627.864 ms | 16.422 us | 16.250 us | 16.873 us | -| `person_batch` | `tdbin_decode_bare` | 50 | 5950.222 ms | 70.060 us | 62.935 us | 74.721 us | -| `person_batch` | `tdbin_decode_framed` | 50 | 7185.206 ms | 91.897 us | 79.753 us | 97.922 us | -| `person_batch` | `tdbin_decode_packed_framed` | 50 | 4669.964 ms | 92.245 us | 70.134 us | 113.054 us | -| `person_batch` | `protobuf_decode` | 50 | 4667.564 ms | 141.486 us | 124.304 us | 152.091 us | -| `contact_batch` | `tdbin_encode_bare` | 50 | 3498.833 ms | 17.033 us | 13.747 us | 21.854 us | -| `contact_batch` | `tdbin_encode_framed` | 50 | 7438.347 ms | 21.809 us | 20.411 us | 23.427 us | -| `contact_batch` | `tdbin_encode_packed_framed` | 50 | 4811.548 ms | 27.872 us | 25.199 us | 30.680 us | -| `contact_batch` | `protobuf_encode` | 50 | 4086.413 ms | 53.212 us | 49.860 us | 58.796 us | -| `contact_batch` | `tdbin_decode_bare` | 50 | 4859.494 ms | 31.446 us | 31.284 us | 31.701 us | -| `contact_batch` | `tdbin_decode_framed` | 50 | 4954.497 ms | 31.064 us | 30.920 us | 31.182 us | -| `contact_batch` | `tdbin_decode_packed_framed` | 50 | 5061.958 ms | 33.328 us | 33.163 us | 33.454 us | -| `contact_batch` | `protobuf_decode` | 50 | 5015.078 ms | 59.527 us | 59.361 us | 59.659 us | -| `diagram_document` | `tdbin_encode_bare` | 50 | 5001.143 ms | 12.304 us | 12.255 us | 12.371 us | -| `diagram_document` | `tdbin_encode_framed` | 50 | 4944.548 ms | 12.275 us | 12.244 us | 12.312 us | -| `diagram_document` | `tdbin_encode_packed_framed` | 50 | 5091.890 ms | 19.569 us | 19.496 us | 19.615 us | -| `diagram_document` | `protobuf_encode` | 50 | 4960.374 ms | 23.471 us | 23.191 us | 23.758 us | -| `diagram_document` | `tdbin_decode_bare` | 50 | 5069.409 ms | 70.724 us | 70.433 us | 71.106 us | -| `diagram_document` | `tdbin_decode_framed` | 50 | 5245.727 ms | 71.218 us | 70.788 us | 71.583 us | -| `diagram_document` | `tdbin_decode_packed_framed` | 50 | 5079.646 ms | 80.341 us | 79.554 us | 80.956 us | -| `diagram_document` | `protobuf_decode` | 50 | 5055.294 ms | 112.113 us | 111.146 us | 113.019 us | -| `event_batch` | `tdbin_encode_bare` | 50 | 5085.971 ms | 36.804 us | 36.443 us | 37.131 us | -| `event_batch` | `tdbin_encode_framed` | 50 | 4730.817 ms | 35.461 us | 35.391 us | 35.623 us | -| `event_batch` | `tdbin_encode_packed_framed` | 50 | 5078.327 ms | 52.131 us | 51.943 us | 52.408 us | -| `event_batch` | `protobuf_encode` | 50 | 5012.815 ms | 56.485 us | 55.341 us | 56.945 us | -| `event_batch` | `tdbin_decode_bare` | 50 | 5231.950 ms | 178.576 us | 177.348 us | 178.972 us | -| `event_batch` | `tdbin_decode_framed` | 50 | 5067.339 ms | 178.355 us | 177.243 us | 179.311 us | -| `event_batch` | `tdbin_decode_packed_framed` | 50 | 5253.962 ms | 202.790 us | 201.011 us | 204.872 us | -| `event_batch` | `protobuf_decode` | 50 | 5062.290 ms | 278.907 us | 277.197 us | 280.067 us | +| `with_address` | `tdbin_encode_bare` | 50 | 4708.488 ms | 90.06 ns | 87.32 ns | 101.16 ns | +| `with_address` | `tdbin_encode_framed` | 50 | 4164.282 ms | 94.85 ns | 91.56 ns | 105.54 ns | +| `with_address` | `tdbin_encode_packed_framed` | 50 | 3260.576 ms | 185.09 ns | 181.68 ns | 189.35 ns | +| `with_address` | `protobuf_encode` | 50 | 3512.653 ms | 48.14 ns | 47.51 ns | 48.83 ns | +| `with_address` | `msgpack_encode` | 50 | 4988.903 ms | 269.46 ns | 267.69 ns | 271.46 ns | +| `with_address` | `tdbin_decode_bare` | 50 | 4914.774 ms | 154.58 ns | 154.04 ns | 155.69 ns | +| `with_address` | `tdbin_decode_framed` | 50 | 5029.047 ms | 166.09 ns | 159.90 ns | 188.14 ns | +| `with_address` | `tdbin_decode_packed_framed` | 50 | 6760.547 ms | 622.64 ns | 589.33 ns | 699.50 ns | +| `with_address` | `protobuf_decode` | 50 | 5433.692 ms | 157.46 ns | 155.60 ns | 165.73 ns | +| `with_address` | `msgpack_decode` | 50 | 5022.274 ms | 219.07 ns | 209.53 ns | 242.46 ns | +| `without_address` | `tdbin_encode_bare` | 50 | 3879.383 ms | 71.24 ns | 68.64 ns | 76.57 ns | +| `without_address` | `tdbin_encode_framed` | 50 | 3292.059 ms | 66.26 ns | 65.80 ns | 67.19 ns | +| `without_address` | `tdbin_encode_packed_framed` | 50 | 5970.711 ms | 177.18 ns | 164.10 ns | 203.00 ns | +| `without_address` | `protobuf_encode` | 50 | 5080.760 ms | 37.65 ns | 35.14 ns | 41.79 ns | +| `without_address` | `msgpack_encode` | 50 | 5402.275 ms | 343.28 ns | 302.47 ns | 362.57 ns | +| `without_address` | `tdbin_decode_bare` | 50 | 6711.175 ms | 76.80 ns | 71.65 ns | 89.58 ns | +| `without_address` | `tdbin_decode_framed` | 50 | 5069.349 ms | 70.88 ns | 70.60 ns | 71.67 ns | +| `without_address` | `tdbin_decode_packed_framed` | 50 | 7010.312 ms | 503.92 ns | 492.48 ns | 563.76 ns | +| `without_address` | `protobuf_decode` | 50 | 3556.800 ms | 59.88 ns | 55.95 ns | 72.98 ns | +| `without_address` | `msgpack_decode` | 50 | 3502.351 ms | 112.99 ns | 112.02 ns | 113.97 ns | +| `metric_batch` | `tdbin_encode_bare` | 50 | 5056.393 ms | 11.589 us | 11.445 us | 11.739 us | +| `metric_batch` | `tdbin_encode_framed` | 50 | 5063.613 ms | 11.884 us | 11.632 us | 12.106 us | +| `metric_batch` | `tdbin_encode_packed_framed` | 50 | 4999.250 ms | 22.957 us | 22.783 us | 23.149 us | +| `metric_batch` | `protobuf_encode` | 50 | 5910.333 ms | 45.933 us | 45.139 us | 46.773 us | +| `metric_batch` | `msgpack_encode` | 50 | 6259.996 ms | 55.673 us | 52.643 us | 67.791 us | +| `metric_batch` | `tdbin_decode_bare` | 50 | 3685.052 ms | 6.471 us | 6.401 us | 7.546 us | +| `metric_batch` | `tdbin_decode_framed` | 50 | 7127.277 ms | 10.621 us | 8.261 us | 13.678 us | +| `metric_batch` | `tdbin_decode_packed_framed` | 50 | 4056.282 ms | 39.008 us | 37.517 us | 43.444 us | +| `metric_batch` | `protobuf_decode` | 50 | 2731.351 ms | 37.773 us | 35.668 us | 40.497 us | +| `metric_batch` | `msgpack_decode` | 50 | 5559.500 ms | 51.039 us | 50.881 us | 51.328 us | +| `person_batch` | `tdbin_encode_bare` | 50 | 6342.107 ms | 15.291 us | 13.818 us | 20.269 us | +| `person_batch` | `tdbin_encode_framed` | 50 | 3470.387 ms | 11.869 us | 11.735 us | 11.973 us | +| `person_batch` | `tdbin_encode_packed_framed` | 50 | 6760.689 ms | 19.653 us | 18.662 us | 27.379 us | +| `person_batch` | `protobuf_encode` | 50 | 3617.596 ms | 19.272 us | 18.868 us | 19.450 us | +| `person_batch` | `msgpack_encode` | 50 | 4209.904 ms | 51.123 us | 49.244 us | 60.303 us | +| `person_batch` | `tdbin_decode_bare` | 50 | 5816.982 ms | 36.895 us | 36.679 us | 37.989 us | +| `person_batch` | `tdbin_decode_framed` | 50 | 4643.515 ms | 46.003 us | 44.473 us | 49.309 us | +| `person_batch` | `tdbin_decode_packed_framed` | 50 | 2513.662 ms | 41.894 us | 41.423 us | 42.698 us | +| `person_batch` | `protobuf_decode` | 50 | 4309.452 ms | 61.638 us | 59.351 us | 64.847 us | +| `person_batch` | `msgpack_decode` | 50 | 3926.919 ms | 89.633 us | 89.045 us | 91.727 us | +| `contact_batch` | `tdbin_encode_bare` | 50 | 3857.292 ms | 9.267 us | 9.140 us | 9.452 us | +| `contact_batch` | `tdbin_encode_framed` | 50 | 3977.619 ms | 9.235 us | 9.169 us | 9.298 us | +| `contact_batch` | `tdbin_encode_packed_framed` | 50 | 4533.762 ms | 14.903 us | 14.181 us | 16.108 us | +| `contact_batch` | `protobuf_encode` | 50 | 3809.607 ms | 32.441 us | 29.305 us | 36.746 us | +| `contact_batch` | `msgpack_encode` | 50 | 6489.779 ms | 87.267 us | 80.904 us | 106.058 us | +| `contact_batch` | `tdbin_decode_bare` | 50 | 4708.315 ms | 33.601 us | 31.363 us | 38.208 us | +| `contact_batch` | `tdbin_decode_framed` | 50 | 4892.352 ms | 31.003 us | 30.748 us | 31.164 us | +| `contact_batch` | `tdbin_decode_packed_framed` | 50 | 5169.716 ms | 33.539 us | 33.424 us | 33.742 us | +| `contact_batch` | `protobuf_decode` | 50 | 5430.856 ms | 80.232 us | 75.231 us | 96.771 us | +| `contact_batch` | `msgpack_decode` | 50 | 4300.871 ms | 126.634 us | 112.108 us | 139.731 us | +| `diagram_document` | `tdbin_encode_bare` | 50 | 5535.814 ms | 16.328 us | 15.172 us | 21.075 us | +| `diagram_document` | `tdbin_encode_framed` | 50 | 5265.309 ms | 15.390 us | 14.422 us | 17.625 us | +| `diagram_document` | `tdbin_encode_packed_framed` | 50 | 5193.875 ms | 19.981 us | 19.799 us | 20.305 us | +| `diagram_document` | `protobuf_encode` | 50 | 3087.903 ms | 23.327 us | 22.851 us | 23.895 us | +| `diagram_document` | `msgpack_encode` | 50 | 5022.687 ms | 56.192 us | 55.766 us | 56.472 us | +| `diagram_document` | `tdbin_decode_bare` | 50 | 5034.613 ms | 71.630 us | 71.195 us | 72.027 us | +| `diagram_document` | `tdbin_decode_framed` | 50 | 6169.790 ms | 86.173 us | 80.225 us | 94.685 us | +| `diagram_document` | `tdbin_decode_packed_framed` | 50 | 5425.883 ms | 100.677 us | 95.733 us | 105.803 us | +| `diagram_document` | `protobuf_decode` | 50 | 3193.202 ms | 144.501 us | 122.209 us | 156.522 us | +| `diagram_document` | `msgpack_decode` | 50 | 3682.530 ms | 141.899 us | 136.312 us | 152.191 us | +| `event_batch` | `tdbin_encode_bare` | 50 | 5842.968 ms | 39.326 us | 38.628 us | 40.455 us | +| `event_batch` | `tdbin_encode_framed` | 50 | 3370.017 ms | 36.348 us | 36.063 us | 36.593 us | +| `event_batch` | `tdbin_encode_packed_framed` | 50 | 5031.737 ms | 52.324 us | 52.166 us | 52.561 us | +| `event_batch` | `protobuf_encode` | 50 | 6664.293 ms | 81.632 us | 79.430 us | 86.327 us | +| `event_batch` | `msgpack_encode` | 50 | 5853.691 ms | 174.450 us | 161.878 us | 197.442 us | +| `event_batch` | `tdbin_decode_bare` | 50 | 5652.191 ms | 262.857 us | 239.518 us | 319.617 us | +| `event_batch` | `tdbin_decode_framed` | 50 | 3370.821 ms | 229.887 us | 216.825 us | 282.880 us | +| `event_batch` | `tdbin_decode_packed_framed` | 50 | 5315.300 ms | 207.471 us | 206.503 us | 208.507 us | +| `event_batch` | `protobuf_decode` | 50 | 5034.757 ms | 282.271 us | 279.410 us | 283.316 us | +| `event_batch` | `msgpack_decode` | 50 | 6327.182 ms | 389.899 us | 387.274 us | 413.054 us | ## Same-Mode Comparison @@ -116,29 +192,29 @@ Ratios are Protobuf median / TDBIN median; values above 1.00x favor TDBIN. The g | Fixture | TDBIN mode | Size winner | Encode ratio | Decode ratio | Gate | | ------------------ | ------------- | ----------- | -----------: | -----------: | ---- | -| `with_address` | bare | Protobuf | 1.55x | 0.60x | FAIL | -| `with_address` | framed | Protobuf | 1.17x | 0.68x | FAIL | -| `with_address` | packed framed | Protobuf | 0.47x | 0.23x | FAIL | -| `without_address` | bare | Protobuf | 0.26x | 0.64x | FAIL | -| `without_address` | framed | Protobuf | 0.29x | 0.64x | FAIL | -| `without_address` | packed framed | Protobuf | 0.23x | 0.09x | FAIL | -| `metric_batch` | bare | TDBIN | 3.93x | 5.11x | PASS | -| `metric_batch` | framed | TDBIN | 4.00x | 5.21x | PASS | -| `metric_batch` | packed framed | TDBIN | 2.02x | 1.11x | FAIL | -| `person_batch` | bare | TDBIN | 1.25x | 2.02x | FAIL | -| `person_batch` | framed | TDBIN | 1.36x | 1.54x | FAIL | -| `person_batch` | packed framed | TDBIN | 1.09x | 1.53x | FAIL | -| `contact_batch` | bare | TDBIN | 3.12x | 1.89x | PASS | -| `contact_batch` | framed | TDBIN | 2.44x | 1.92x | PASS | -| `contact_batch` | packed framed | TDBIN | 1.91x | 1.79x | PASS | -| `diagram_document` | bare | TDBIN | 1.91x | 1.59x | PASS | -| `diagram_document` | framed | TDBIN | 1.91x | 1.57x | PASS | -| `diagram_document` | packed framed | TDBIN | 1.20x | 1.40x | FAIL | -| `event_batch` | bare | TDBIN | 1.53x | 1.56x | PASS | -| `event_batch` | framed | TDBIN | 1.59x | 1.56x | PASS | -| `event_batch` | packed framed | TDBIN | 1.08x | 1.38x | FAIL | - -Passing fixture/mode combinations: 9 of 21. +| `with_address` | bare | Protobuf | 0.53x | 1.02x | FAIL | +| `with_address` | framed | Protobuf | 0.51x | 0.95x | FAIL | +| `with_address` | packed framed | Protobuf | 0.26x | 0.25x | FAIL | +| `without_address` | bare | Protobuf | 0.53x | 0.78x | FAIL | +| `without_address` | framed | Protobuf | 0.57x | 0.84x | FAIL | +| `without_address` | packed framed | Protobuf | 0.21x | 0.12x | FAIL | +| `metric_batch` | bare | TDBIN | 3.96x | 5.84x | PASS | +| `metric_batch` | framed | TDBIN | 3.87x | 3.56x | PASS | +| `metric_batch` | packed framed | TDBIN | 2.00x | 0.97x | FAIL | +| `person_batch` | bare | TDBIN | 1.26x | 1.67x | FAIL | +| `person_batch` | framed | TDBIN | 1.62x | 1.34x | FAIL | +| `person_batch` | packed framed | TDBIN | 0.98x | 1.47x | FAIL | +| `contact_batch` | bare | TDBIN | 3.50x | 2.39x | PASS | +| `contact_batch` | framed | TDBIN | 3.51x | 2.59x | PASS | +| `contact_batch` | packed framed | TDBIN | 2.18x | 2.39x | PASS | +| `diagram_document` | bare | TDBIN | 1.43x | 2.02x | FAIL | +| `diagram_document` | framed | TDBIN | 1.52x | 1.68x | PASS | +| `diagram_document` | packed framed | TDBIN | 1.17x | 1.44x | FAIL | +| `event_batch` | bare | TDBIN | 2.08x | 1.07x | FAIL | +| `event_batch` | framed | TDBIN | 2.25x | 1.23x | FAIL | +| `event_batch` | packed framed | TDBIN | 1.56x | 1.36x | FAIL | + +Passing fixture/mode combinations: 6 of 21. This secondary table exposes unpacked tradeoffs; it does not replace the packed-framed specification gate above. diff --git a/scripts/tdbin-bench-report.mjs b/scripts/tdbin-bench-report.mjs index 118c611..b88f57a 100644 --- a/scripts/tdbin-bench-report.mjs +++ b/scripts/tdbin-bench-report.mjs @@ -15,10 +15,12 @@ const operations = [ "tdbin_encode_framed", "tdbin_encode_packed_framed", "protobuf_encode", + "msgpack_encode", "tdbin_decode_bare", "tdbin_decode_framed", "tdbin_decode_packed_framed", "protobuf_decode", + "msgpack_decode", ]; const run = (command, args) => execFileSync(command, args, { cwd: root, encoding: "utf8" }).trim(); @@ -96,7 +98,7 @@ const winner = (value, baseline) => (value < baseline ? "TDBIN" : value > baseli const sizeRows = sizes.fixtures .map( (row) => - `| \`${row.name}\` | ${row.shape} | ${row.corpus ? "corpus" : "stress"} | ${row.logical_items.toLocaleString()} | ${row.tdbin_bare.toLocaleString()} | ${row.tdbin_framed.toLocaleString()} | ${row.tdbin_packed_framed.toLocaleString()} | ${row.protobuf.toLocaleString()} | ${percent(row.tdbin_framed, row.protobuf)} | ${percent(row.tdbin_packed_framed, row.protobuf)} |` + `| \`${row.name}\` | ${row.shape} | ${row.corpus ? "corpus" : "stress"} | ${row.logical_items.toLocaleString()} | ${row.tdbin_bare.toLocaleString()} | ${row.tdbin_framed.toLocaleString()} | ${row.tdbin_packed_framed.toLocaleString()} | ${row.protobuf.toLocaleString()} | ${row.msgpack.toLocaleString()} | ${percent(row.tdbin_framed, row.protobuf)} | ${percent(row.tdbin_packed_framed, row.protobuf)} |` ) .join("\n"); @@ -123,6 +125,35 @@ const modeRows = sizes.fixtures ) .join("\n"); +// [TDBIN-BENCH-PIVOT] Reader-facing summary: protocol on the Y axis, bench type +// (size / encode speed / decode speed) on the X axis. `tdbin` uses the framed +// wire mode — the self-describing production peer of msgpack (struct-as-map) and +// Protobuf, so all three rows compare like-for-like. The detailed multi-mode +// tables below retain bare/framed/packed and every Criterion statistic. +const median = (fixture, operation) => estimate(fixture, operation).median_ns; +const sizeVsPb = (bytes, protobuf) => + bytes === protobuf ? "same" : `${bytes < protobuf ? "" : "+"}${percent(bytes, protobuf)}`; +const protocols = [ + { label: "tdbin (framed)", size: (f) => f.tdbin_framed, encode: "tdbin_encode_framed", decode: "tdbin_decode_framed" }, + { label: "protobuf", size: (f) => f.protobuf, encode: "protobuf_encode", decode: "protobuf_decode" }, + { label: "msgpack", size: (f) => f.msgpack, encode: "msgpack_encode", decode: "msgpack_decode" }, +]; +const protocolTables = sizes.fixtures + .map((fixture) => { + const rows = protocols + .map((protocol) => { + const bytes = protocol.size(fixture); + return `| ${protocol.label} | ${bytes.toLocaleString()} | ${sizeVsPb(bytes, fixture.protobuf)} | ${duration(median(fixture.name, protocol.encode))} | ${duration(median(fixture.name, protocol.decode))} |`; + }) + .join("\n"); + return `### \`${fixture.name}\` — ${fixture.shape} (${fixture.corpus ? "corpus" : "stress"}, ${fixture.logical_items.toLocaleString()} items) + +| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | +| --- | ---: | ---: | ---: | ---: | +${rows}`; + }) + .join("\n\n"); + const passCount = modeRows.split("\n").filter((row) => row.endsWith(" PASS |")).length; const modeQualifies = (fixture, bytes, encodeOp, decodeOp) => bytes <= fixture.protobuf && @@ -165,6 +196,12 @@ Qualifying modes: ${releaseResults.map((row) => "`" + row.fixture + "` = " + row The release gate ([TDBIN-BENCH-GATE]) requires, for every corpus entry — the committed realistic schemas in \`docs/benchmarks/tdbin-corpus.{td,proto}\` (record-heavy document, union-heavy event stream, list-heavy dataset) — that at least one self-describing production wire mode (framed, or packed framed; the frame's PACKED flag makes the two interchangeable to every decoder) beats Protobuf on size and by ${data.gate.encode_speed_ratio_min.toFixed(2)}x on both encode and decode simultaneously. Both modes are always measured and published below. Stress rows (marked) are reported against the identical bar; the tiny single-message rows carry a fixed 12-byte frame plus pointer-per-string overhead that no fixed-layout format recovers at sub-100-byte payloads (research §2.2), so they are not corpus entries. +## At a Glance + +One table per fixture. Rows are the three protocols; columns are the bench types (size, encode speed, decode speed). \`tdbin\` is the **framed** wire mode — the self-describing production peer of \`msgpack\` (struct-as-map, via \`rmp-serde\`) and \`protobuf\`. Lower is better everywhere. The full multi-mode breakdown (TDBIN bare/framed/packed and every Criterion statistic) is in the detailed tables further down. + +${protocolTables} + ## Environment | Field | Value | @@ -186,8 +223,8 @@ ${data.environment.dependencies} All sizes are bytes. Percentage columns are relative to Protobuf; negative is smaller. -| Fixture | Shape | Role | Items | TDBIN bare | TDBIN framed | TDBIN packed framed | Protobuf | Framed delta | Packed delta | -| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Fixture | Shape | Role | Items | TDBIN bare | TDBIN framed | TDBIN packed framed | Protobuf | MessagePack | Framed delta | Packed delta | +| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ${sizeRows} ## Criterion Medians From 03c75c5a9d58e04efe9ad347c8a4831abb8cd476 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:34:51 +1000 Subject: [PATCH 06/14] Fixes --- docs/reports/tdbin-bench-data.json | 2 +- docs/reports/tdbin-bench-report.md | 4 +- docs/specs/tdbin-benchmarks.md | 66 +++++++++++++++ packages/web/eleventy/_data/docs.ts | 31 ++++--- .../eleventy/_includes/docs-nav-groups.njk | 21 ++++- packages/web/src/styles.css | 53 ++++++------ scripts/tdbin-bench-report.mjs | 83 ++++++++++++++++++- 7 files changed, 219 insertions(+), 41 deletions(-) create mode 100644 docs/specs/tdbin-benchmarks.md diff --git a/docs/reports/tdbin-bench-data.json b/docs/reports/tdbin-bench-data.json index 08f7339..3ffd746 100644 --- a/docs/reports/tdbin-bench-data.json +++ b/docs/reports/tdbin-bench-data.json @@ -1,6 +1,6 @@ { "format_version": 1, - "generated_at": "2026-07-12T09:23:43.780Z", + "generated_at": "2026-07-12T11:07:21.191Z", "commands": ["cargo bench -p tdbin --bench gate -- --noplot", "node scripts/tdbin-bench-report.mjs"], "corpus_schemas": ["docs/benchmarks/tdbin-corpus.td", "docs/benchmarks/tdbin-corpus.proto"], "gate": { diff --git a/docs/reports/tdbin-bench-report.md b/docs/reports/tdbin-bench-report.md index afb03e1..47cf6b8 100644 --- a/docs/reports/tdbin-bench-report.md +++ b/docs/reports/tdbin-bench-report.md @@ -3,9 +3,9 @@ > GENERATED FILE. Source: `scripts/tdbin-bench-report.mjs` and `docs/reports/tdbin-bench-data.json`. > Every value and verdict is computed from machine-readable Criterion and encoder output. No benchmark result is entered manually. -Generated: 2026-07-12T09:23:43.780Z +Generated: 2026-07-12T11:07:21.191Z -Raw data SHA-256: `9153a769c7062e5de6ad54be8ddf77f82655d1c362dbac55a930c7a161da8e7e` +Raw data SHA-256: `20ace5953e08d909efb3b6cebd65b8a0111e05a727b72687af594ba1e225e843` ## Result diff --git a/docs/specs/tdbin-benchmarks.md b/docs/specs/tdbin-benchmarks.md new file mode 100644 index 0000000..cf5deb5 --- /dev/null +++ b/docs/specs/tdbin-benchmarks.md @@ -0,0 +1,66 @@ +# TDBIN Benchmarks + +TDBIN is the compact binary codec typeDiagram generates for algebraic data types. This page compares it against two widely used serialization formats — **Protocol Buffers** and **MessagePack** — on realistic workloads, so you can judge what the format buys you. + +Numbers below are measured, not estimated: they are produced by `scripts/tdbin-bench-report.mjs` from Criterion timings and exact encoder output, and regenerate whenever the benchmark runs. For the full breakdown — all wire modes, every fixture, confidence intervals — see the generated benchmark report at `docs/reports/tdbin-bench-report.md`. + +## What each format is for + +- **TDBIN (framed)** — a self-describing frame around typeDiagram's columnar layout. It shines on _batches of the same shape_ (telemetry rows, event streams, repeated records): the columnar layout lets it skip the per-field tags every other format repeats, so it is both the smallest and the fastest here. Reach for it when you control both ends and your data is list- or record-heavy. +- **Protocol Buffers** — schema-driven, tag-per-field. Extremely compact and fast on _small, sparse messages_ (a single record with optional fields), and the industry default for cross-language RPC with a shared `.proto`. Reach for it when messages are small, schemas are shared, and you need the broadest ecosystem. +- **MessagePack** — schemaless, self-describing (`struct-as-map`, via `rmp-serde`). It carries field names on the wire, so it is the largest and slowest of the three, but it needs _no schema at all_ and any language can read it. Reach for it for loosely-typed interchange, config blobs, or when a schema is impractical. + +## Results + +The tables use typeDiagram's committed **corpus** workloads — the realistic schemas the release gate is defined on. TDBIN is shown in its **framed** production mode, the self-describing peer of the other two. + +### list-heavy telemetry + +4,096 items. Lower is better in every column; **1.00x** marks the winner of that column. + +| Format | Size (bytes) | Size | Encode | Decode | +| -------------- | -----------: | ----: | -----: | -----: | +| tdbin (framed) | 43,788 | 1.00x | 1.00x | 1.00x | +| protobuf | 84,149 | 1.92x | 3.87x | 3.56x | +| msgpack | 90,440 | 2.07x | 4.68x | 4.81x | + +### record-heavy diagram document + +768 items. Lower is better in every column; **1.00x** marks the winner of that column. + +| Format | Size (bytes) | Size | Encode | Decode | +| -------------- | -----------: | ----: | -----: | -----: | +| tdbin (framed) | 45,172 | 1.00x | 1.00x | 1.00x | +| protobuf | 50,788 | 1.12x | 1.52x | 1.68x | +| msgpack | 77,410 | 1.71x | 3.65x | 1.65x | + +### union-heavy event stream + +2,048 items. Lower is better in every column; **1.00x** marks the winner of that column. + +| Format | Size (bytes) | Size | Encode | Decode | +| -------------- | -----------: | ----: | -----: | -----: | +| tdbin (framed) | 116,372 | 1.00x | 1.00x | 1.00x | +| protobuf | 131,744 | 1.13x | 2.25x | 1.23x | +| msgpack | 230,620 | 1.98x | 4.80x | 1.70x | + +## Methodology + +- **Same values, three encoders.** Every fixture builds one logical value; the exact same value is fed to the TDBIN codec, to a hand-written Protobuf mirror (`prost`), and — via `serde` derives on that same mirror — to MessagePack (`rmp-serde`). No format gets a different or more favorable input. +- **Self-describing modes compared.** TDBIN _framed_, Protobuf, and MessagePack _struct-as-map_ are all self-describing, so the comparison is like-for-like. (TDBIN's smaller _packed_ mode and the tiny single-message stress fixtures appear in the full report, not here.) +- **Sizes are exact byte counts** from each encoder — not estimates. +- **Timings are Criterion medians** over 50 samples per operation on the environment below; each measured value flows through `black_box` so the optimizer cannot elide the work. +- **Reproduce it yourself** with the commands below; the page and its numbers regenerate together. + +### Environment + +| Field | Value | +| -------- | ----------------------------------- | +| Platform | darwin 25.5.0 (arm64) | +| CPU | Apple M4 Max | +| Rust | rustc 1.97.0 (2d8144b78 2026-07-07) | + +### Reproduce + +- `cargo bench -p tdbin --bench gate -- --noplot` +- `node scripts/tdbin-bench-report.mjs` diff --git a/packages/web/eleventy/_data/docs.ts b/packages/web/eleventy/_data/docs.ts index 11cb2af..af7c6b3 100644 --- a/packages/web/eleventy/_data/docs.ts +++ b/packages/web/eleventy/_data/docs.ts @@ -10,20 +10,30 @@ const TYPEDOC_DIR = resolve(__dirname, "../../.typedoc-out"); const toPosix = (p: string): string => p.split(sep).join("/"); -type DocEntry = { slug: string; label: string; title: string; html: string; isTopLevel: boolean }; +// [WEB-DOCS-NAV] `group` nests an entry under a collapsible parent in the docs +// sidebar; entries without a group render at the top level in listed order. +type DocEntry = { + slug: string; + label: string; + title: string; + html: string; + isTopLevel: boolean; + group?: string; +}; -const handwritten: ReadonlyArray<{ slug: string; label: string }> = [ +const handwritten: ReadonlyArray<{ slug: string; label: string; group?: string }> = [ { slug: "getting-started", label: "Getting Started" }, { slug: "language-reference", label: "Language Reference" }, { slug: "cli", label: "CLI" }, { slug: "multi-language-pipeline", label: "Multi-Language Pipeline" }, { slug: "converters", label: "Converters" }, { slug: "render-hooks", label: "Render Hooks" }, - { slug: "tdbin", label: "TDBIN Binary Codec" }, - { slug: "tdbin-wire-format", label: "TDBIN Wire Format" }, - { slug: "tdbin-rust-api", label: "TDBIN Rust API" }, - { slug: "tdbin-future-typescript", label: "TDBIN TypeScript Roadmap" }, - { slug: "tdbin-future-reader", label: "TDBIN Reader Roadmap" }, + { slug: "tdbin", label: "Binary Codec", group: "TDBIN" }, + { slug: "tdbin-benchmarks", label: "Benchmarks", group: "TDBIN" }, + { slug: "tdbin-wire-format", label: "Wire Format", group: "TDBIN" }, + { slug: "tdbin-rust-api", label: "Rust API", group: "TDBIN" }, + { slug: "tdbin-future-typescript", label: "TypeScript Roadmap", group: "TDBIN" }, + { slug: "tdbin-future-reader", label: "Reader Roadmap", group: "TDBIN" }, { slug: "api", label: "Node.js API" }, ]; @@ -38,11 +48,12 @@ const introEntry: DocEntry = { html: mdToHtml(`# Introduction\n\n${SHARED_INTRO_MD}`), }; -const loadHandwritten = (slug: string, label: string): DocEntry => ({ +const loadHandwritten = (slug: string, label: string, group?: string): DocEntry => ({ slug, label, - title: label, + title: group ? `${group} — ${label}` : label, isTopLevel: true, + ...(group ? { group } : {}), html: mdToHtml(readFileSync(resolve(DOCS_DIR, `${slug}.md`), "utf-8")), }); @@ -83,4 +94,4 @@ const loadApiEntries = (): DocEntry[] => { }); }; -export default [introEntry, ...handwritten.map((d) => loadHandwritten(d.slug, d.label)), ...loadApiEntries()]; +export default [introEntry, ...handwritten.map((d) => loadHandwritten(d.slug, d.label, d.group)), ...loadApiEntries()]; diff --git a/packages/web/eleventy/_includes/docs-nav-groups.njk b/packages/web/eleventy/_includes/docs-nav-groups.njk index 666fb11..619ebd2 100644 --- a/packages/web/eleventy/_includes/docs-nav-groups.njk +++ b/packages/web/eleventy/_includes/docs-nav-groups.njk @@ -1,8 +1,27 @@ {% set currentDocSlug = activeSlug | default("") %} +{# [WEB-DOCS-NAV] Hierarchical sidebar: ungrouped docs render at the top level; + docs sharing a `group` collapse under one

parent, opened when the + active page lives inside it. #}
    {% for d in docs %} {% if "api/" not in d.slug %} -
  • {{ d.label }}
  • + {% if not d.group %} +
  • {{ d.label }}
  • + {% elif d.group and (docs | selectattr("group", "equalto", d.group) | first).slug == d.slug %} + {% set groupDocs = docs | selectattr("group", "equalto", d.group) | list %} + {% set groupActive = false %} + {% for g in groupDocs %}{% if g.slug == currentDocSlug %}{% set groupActive = true %}{% endif %}{% endfor %} +
  • + + {{ d.group }} + +
+ + {% endif %} {% endif %} {% endfor %} diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index b1cecb6..6cd327b 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -1188,8 +1188,9 @@ body { flex-direction: column; gap: 2px; } - -.docs-nav a { +/* [WEB-DOCS-NAV] Nav links and collapsible group summaries share one base. */ +.docs-nav a, +.docs-nav-group summary { display: block; padding: var(--td-space-sm) var(--td-space-lg); font-family: var(--td-font-mono); @@ -1197,25 +1198,46 @@ body { font-weight: 500; color: #64748b; text-decoration: none; + cursor: pointer; + list-style: none; border-radius: var(--td-radius-sm); border-left: 4px solid transparent; transition: background var(--td-ease), color var(--td-ease); } - -.docs-nav a:hover { +.docs-nav a:hover, +.docs-nav-group summary:hover { background: rgba(34, 42, 61, 0.5); color: var(--td-on-surface-variant); } - .docs-nav a.active { background: var(--td-surface-high); color: var(--td-primary); border-left-color: var(--td-primary); font-weight: 700; } - +.docs-nav-group summary::-webkit-details-marker { + display: none; +} +.docs-nav-group summary::before { + content: "▸"; + display: inline-block; + width: 1em; + margin-right: 0.25em; + font-size: 0.8em; + transition: transform var(--td-ease); +} +.docs-nav-group details[open] > summary::before { + transform: rotate(90deg); +} +.docs-nav-group summary.active-group { + color: var(--td-on-surface-variant); + font-weight: 700; +} +.docs-nav-sub { + margin-left: var(--td-space-lg); +} .docs-nav-heading { font-family: var(--td-font-mono); font-size: var(--td-size-label); @@ -1229,18 +1251,15 @@ body { border-top: 1px solid var(--td-surface-high); margin-bottom: var(--td-space-lg); } - .docs-mobile-nav { display: none; } - .docs-content { padding: var(--td-space-3xl) var(--td-space-4xl); line-height: 1.7; color: var(--td-on-surface); background: var(--td-surface); } - .docs-content h1 { font-family: var(--td-font-headline); font-size: clamp(2rem, 4vw, 3rem); @@ -1250,7 +1269,6 @@ body { margin-bottom: var(--td-space-xl); color: var(--td-on-surface); } - .docs-content h2 { font-family: var(--td-font-headline); font-size: 1.5rem; @@ -1262,11 +1280,9 @@ body { border-left: 4px solid var(--td-tertiary); color: var(--td-on-surface); } - .docs-content h2:nth-of-type(even) { border-left-color: var(--td-primary); } - .docs-content h3 { font-family: var(--td-font-headline); font-size: 1.15rem; @@ -1275,19 +1291,16 @@ body { margin-bottom: var(--td-space-sm); color: var(--td-on-surface); } - .docs-content > p:first-of-type { font-size: 1.05rem; color: var(--td-on-surface-variant); line-height: 1.7; max-width: 640px; } - .docs-content p { margin-bottom: var(--td-space-lg); color: var(--td-on-surface-variant); } - .docs-content code { font-family: var(--td-font-mono); font-size: 0.85em; @@ -1296,7 +1309,6 @@ body { padding: 2px 6px; border-radius: 4px; } - .docs-content pre { background: #060e20; border: 1px solid var(--td-surface-high); @@ -1307,14 +1319,12 @@ body { line-height: 1.6; font-family: var(--td-font-mono); } - .docs-content pre code { background: none; color: var(--td-on-surface); padding: 0; font-size: var(--td-size-code); } - .docs-content table { width: 100%; border-collapse: collapse; @@ -1324,14 +1334,12 @@ body { border-radius: var(--td-radius-sm); overflow: hidden; } - .docs-content th, .docs-content td { text-align: left; padding: var(--td-space-md) var(--td-space-lg); border-bottom: 1px solid var(--td-surface-highest); } - .docs-content th { font-family: var(--td-font-mono); font-size: var(--td-size-label); @@ -1341,29 +1349,24 @@ body { color: var(--td-primary); background: var(--td-surface-container); } - .docs-content ul, .docs-content ol { padding-left: var(--td-space-xl); margin-bottom: var(--td-space-lg); color: var(--td-on-surface-variant); } - .docs-content li { margin-bottom: var(--td-space-sm); } - .docs-content strong { font-weight: 600; color: var(--td-on-surface); } - .docs-content a { color: var(--td-primary); text-decoration: none; transition: color var(--td-ease); } - .docs-content a:hover { color: var(--td-tertiary); text-decoration: underline; diff --git a/scripts/tdbin-bench-report.mjs b/scripts/tdbin-bench-report.mjs index b88f57a..0dbf76d 100644 --- a/scripts/tdbin-bench-report.mjs +++ b/scripts/tdbin-bench-report.mjs @@ -134,7 +134,12 @@ const median = (fixture, operation) => estimate(fixture, operation).median_ns; const sizeVsPb = (bytes, protobuf) => bytes === protobuf ? "same" : `${bytes < protobuf ? "" : "+"}${percent(bytes, protobuf)}`; const protocols = [ - { label: "tdbin (framed)", size: (f) => f.tdbin_framed, encode: "tdbin_encode_framed", decode: "tdbin_decode_framed" }, + { + label: "tdbin (framed)", + size: (f) => f.tdbin_framed, + encode: "tdbin_encode_framed", + decode: "tdbin_decode_framed", + }, { label: "protobuf", size: (f) => f.protobuf, encode: "protobuf_encode", decode: "protobuf_decode" }, { label: "msgpack", size: (f) => f.msgpack, encode: "msgpack_encode", decode: "msgpack_decode" }, ]; @@ -255,4 +260,78 @@ ${data.corpus_schemas.map((path) => `- \`${path}\``).join("\n")} `; writeFileSync(reportPath, await format(report, { ...prettierOptions, filepath: reportPath })); -process.stdout.write(`wrote ${dataPath}\nwrote ${reportPath}\n`); + +// [TDBIN-BENCH-WEBSITE] Simplified, reader-facing benchmark page for the docs +// site. Same measured JSON as the full report — never hand-typed — but reduced +// to the committed corpus workloads and framed to give a non-expert a sense of +// each format's strengths. `x` columns are multiples of the best value in the +// row group (1.00x = winner; higher = larger/slower). +const websitePath = join(root, "docs/specs/tdbin-benchmarks.md"); +const corpusFixtures = sizes.fixtures.filter((fixture) => fixture.corpus); +const relSize = (bytes, best) => `${(bytes / best).toFixed(2)}x`; +const relTime = (ns, best) => `${(ns / best).toFixed(2)}x`; +const websiteTables = corpusFixtures + .map((fixture) => { + const sizeValues = protocols.map((p) => p.size(fixture)); + const encodeValues = protocols.map((p) => median(fixture.name, p.encode)); + const decodeValues = protocols.map((p) => median(fixture.name, p.decode)); + const bestSize = Math.min(...sizeValues); + const bestEncode = Math.min(...encodeValues); + const bestDecode = Math.min(...decodeValues); + const rows = protocols + .map((p, i) => { + const bytes = sizeValues[i]; + return `| ${p.label} | ${bytes.toLocaleString()} | ${relSize(bytes, bestSize)} | ${relTime(encodeValues[i], bestEncode)} | ${relTime(decodeValues[i], bestDecode)} |`; + }) + .join("\n"); + return `### ${fixture.shape} + +${fixture.logical_items.toLocaleString()} items. Lower is better in every column; **1.00x** marks the winner of that column. + +| Format | Size (bytes) | Size | Encode | Decode | +| --- | ---: | ---: | ---: | ---: | +${rows}`; + }) + .join("\n\n"); + +const website = `# TDBIN Benchmarks + +TDBIN is the compact binary codec typeDiagram generates for algebraic data types. This page compares it against two widely used serialization formats — **Protocol Buffers** and **MessagePack** — on realistic workloads, so you can judge what the format buys you. + +Numbers below are measured, not estimated: they are produced by \`scripts/tdbin-bench-report.mjs\` from Criterion timings and exact encoder output, and regenerate whenever the benchmark runs. For the full breakdown — all wire modes, every fixture, confidence intervals — see the generated benchmark report at \`docs/reports/tdbin-bench-report.md\`. + +## What each format is for + +- **TDBIN (framed)** — a self-describing frame around typeDiagram's columnar layout. It shines on *batches of the same shape* (telemetry rows, event streams, repeated records): the columnar layout lets it skip the per-field tags every other format repeats, so it is both the smallest and the fastest here. Reach for it when you control both ends and your data is list- or record-heavy. +- **Protocol Buffers** — schema-driven, tag-per-field. Extremely compact and fast on *small, sparse messages* (a single record with optional fields), and the industry default for cross-language RPC with a shared \`.proto\`. Reach for it when messages are small, schemas are shared, and you need the broadest ecosystem. +- **MessagePack** — schemaless, self-describing (\`struct-as-map\`, via \`rmp-serde\`). It carries field names on the wire, so it is the largest and slowest of the three, but it needs *no schema at all* and any language can read it. Reach for it for loosely-typed interchange, config blobs, or when a schema is impractical. + +## Results + +The tables use typeDiagram's committed **corpus** workloads — the realistic schemas the release gate is defined on. TDBIN is shown in its **framed** production mode, the self-describing peer of the other two. + +${websiteTables} + +## Methodology + +- **Same values, three encoders.** Every fixture builds one logical value; the exact same value is fed to the TDBIN codec, to a hand-written Protobuf mirror (\`prost\`), and — via \`serde\` derives on that same mirror — to MessagePack (\`rmp-serde\`). No format gets a different or more favorable input. +- **Self-describing modes compared.** TDBIN *framed*, Protobuf, and MessagePack *struct-as-map* are all self-describing, so the comparison is like-for-like. (TDBIN's smaller *packed* mode and the tiny single-message stress fixtures appear in the full report, not here.) +- **Sizes are exact byte counts** from each encoder — not estimates. +- **Timings are Criterion medians** over ${data.benchmarks[0]?.sample_count ?? 50} samples per operation on the environment below; each measured value flows through \`black_box\` so the optimizer cannot elide the work. +- **Reproduce it yourself** with the commands below; the page and its numbers regenerate together. + +### Environment + +| Field | Value | +| --- | --- | +| Platform | ${data.environment.platform} ${data.environment.release} (${data.environment.architecture}) | +| CPU | ${data.environment.cpu} | +| Rust | ${data.environment.rustc} | + +### Reproduce + +${data.commands.map((command) => `- \`${command}\``).join("\n")} +`; + +writeFileSync(websitePath, await format(website, { ...prettierOptions, filepath: websitePath })); +process.stdout.write(`wrote ${dataPath}\nwrote ${reportPath}\nwrote ${websitePath}\n`); From 745973042302c26cbbe641aaf37bd97c80d269d1 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:01:22 +1000 Subject: [PATCH 07/14] Fixes --- docs/reports/tdbin-bench-data.json | 422 ++++++++++++++--------------- docs/reports/tdbin-bench-report.md | 264 ++++++++---------- docs/specs/tdbin-benchmarks.md | 52 ++-- scripts/tdbin-bench-report.mjs | 85 +++--- 4 files changed, 385 insertions(+), 438 deletions(-) diff --git a/docs/reports/tdbin-bench-data.json b/docs/reports/tdbin-bench-data.json index 3ffd746..386c588 100644 --- a/docs/reports/tdbin-bench-data.json +++ b/docs/reports/tdbin-bench-data.json @@ -1,6 +1,6 @@ { "format_version": 1, - "generated_at": "2026-07-12T11:07:21.191Z", + "generated_at": "2026-07-13T22:00:16.855Z", "commands": ["cargo bench -p tdbin --bench gate -- --noplot", "node scripts/tdbin-bench-report.mjs"], "corpus_schemas": ["docs/benchmarks/tdbin-corpus.td", "docs/benchmarks/tdbin-corpus.proto"], "gate": { @@ -106,562 +106,562 @@ { "fixture": "with_address", "operation": "tdbin_encode_bare", - "median_ns": 90.05732701385347, - "confidence_interval_ns": [87.31995034405222, 101.16328139082677], + "median_ns": 83.61664436763455, + "confidence_interval_ns": [83.24098977945131, 84.04859171597633], "sample_count": 50, - "sampled_time_ns": 4708487627 + "sampled_time_ns": 4979330883 }, { "fixture": "with_address", "operation": "tdbin_encode_framed", - "median_ns": 94.84974573045102, - "confidence_interval_ns": [91.5608905342954, 105.53577569863316], + "median_ns": 87.50883454804087, + "confidence_interval_ns": [87.21800529214731, 88.4152608677886], "sample_count": 50, - "sampled_time_ns": 4164282420 + "sampled_time_ns": 4680046292 }, { "fixture": "with_address", "operation": "tdbin_encode_packed_framed", - "median_ns": 185.09468119503362, - "confidence_interval_ns": [181.68482209576365, 189.34596546235838], + "median_ns": 184.40744293066552, + "confidence_interval_ns": [183.62941755306687, 185.67707487934024], "sample_count": 50, - "sampled_time_ns": 3260575583 + "sampled_time_ns": 5033556040 }, { "fixture": "with_address", "operation": "protobuf_encode", - "median_ns": 48.14281449263207, - "confidence_interval_ns": [47.50973129268929, 48.82923623717177], + "median_ns": 46.84470689177553, + "confidence_interval_ns": [46.66299905361558, 47.04870937798407], "sample_count": 50, - "sampled_time_ns": 3512653040 + "sampled_time_ns": 4875876411 }, { "fixture": "with_address", "operation": "msgpack_encode", - "median_ns": 269.4602297878922, - "confidence_interval_ns": [267.69217516634836, 271.45900190832765], + "median_ns": 252.32891048765742, + "confidence_interval_ns": [250.6742349757851, 254.03142770998204], "sample_count": 50, - "sampled_time_ns": 4988903499 + "sampled_time_ns": 4900557878 }, { "fixture": "with_address", "operation": "tdbin_decode_bare", - "median_ns": 154.58307466575963, - "confidence_interval_ns": [154.04207678637778, 155.6891239370515], + "median_ns": 151.83659588353746, + "confidence_interval_ns": [151.28891089516742, 153.12560592546632], "sample_count": 50, - "sampled_time_ns": 4914773751 + "sampled_time_ns": 5004358211 }, { "fixture": "with_address", "operation": "tdbin_decode_framed", - "median_ns": 166.08854121114592, - "confidence_interval_ns": [159.90222650930758, 188.1398953103955], + "median_ns": 153.21068381941174, + "confidence_interval_ns": [152.62205970219838, 153.55883282725722], "sample_count": 50, - "sampled_time_ns": 5029046879 + "sampled_time_ns": 5015031748 }, { "fixture": "with_address", "operation": "tdbin_decode_packed_framed", - "median_ns": 622.6389741606911, - "confidence_interval_ns": [589.3267347078568, 699.4985912752776], + "median_ns": 571.4264272647447, + "confidence_interval_ns": [570.1688222596224, 573.8316456774174], "sample_count": 50, - "sampled_time_ns": 6760547419 + "sampled_time_ns": 5018607129 }, { "fixture": "with_address", "operation": "protobuf_decode", - "median_ns": 157.46493124861388, - "confidence_interval_ns": [155.60476131071192, 165.7297384397871], + "median_ns": 152.48645794255958, + "confidence_interval_ns": [151.71946830120095, 153.26124395661833], "sample_count": 50, - "sampled_time_ns": 5433692420 + "sampled_time_ns": 5014849797 }, { "fixture": "with_address", "operation": "msgpack_decode", - "median_ns": 219.07397116185683, - "confidence_interval_ns": [209.53488995283692, 242.458839502644], + "median_ns": 195.3103293218463, + "confidence_interval_ns": [194.46978318778235, 196.06689781766266], "sample_count": 50, - "sampled_time_ns": 5022273879 + "sampled_time_ns": 5300693082 }, { "fixture": "without_address", "operation": "tdbin_encode_bare", - "median_ns": 71.24464772900714, - "confidence_interval_ns": [68.64370619864668, 76.5652242700573], + "median_ns": 61.10147673968285, + "confidence_interval_ns": [60.79277205346075, 61.426855614918935], "sample_count": 50, - "sampled_time_ns": 3879382917 + "sampled_time_ns": 4706953498 }, { "fixture": "without_address", "operation": "tdbin_encode_framed", - "median_ns": 66.25831852841006, - "confidence_interval_ns": [65.79774411033351, 67.19337824124857], + "median_ns": 65.75884592216104, + "confidence_interval_ns": [65.4871198094578, 66.16730661122021], "sample_count": 50, - "sampled_time_ns": 3292058962 + "sampled_time_ns": 4976057336 }, { "fixture": "without_address", "operation": "tdbin_encode_packed_framed", - "median_ns": 177.17926415033156, - "confidence_interval_ns": [164.1018320066218, 202.99525894855802], + "median_ns": 141.91882372169204, + "confidence_interval_ns": [141.3504438183821, 142.9386974602703], "sample_count": 50, - "sampled_time_ns": 5970711499 + "sampled_time_ns": 4880894293 }, { "fixture": "without_address", "operation": "protobuf_encode", - "median_ns": 37.65441511081784, - "confidence_interval_ns": [35.141182362713096, 41.79245157410987], + "median_ns": 32.67926390634088, + "confidence_interval_ns": [32.5972015414703, 32.85549012971483], "sample_count": 50, - "sampled_time_ns": 5080759545 + "sampled_time_ns": 4975979456 }, { "fixture": "without_address", "operation": "msgpack_encode", - "median_ns": 343.282520848006, - "confidence_interval_ns": [302.4747483949332, 362.5696381883094], + "median_ns": 228.8395579615137, + "confidence_interval_ns": [228.1282434252914, 230.05866963431538], "sample_count": 50, - "sampled_time_ns": 5402274792 + "sampled_time_ns": 4988959708 }, { "fixture": "without_address", "operation": "tdbin_decode_bare", - "median_ns": 76.7984900027673, - "confidence_interval_ns": [71.65277745794114, 89.58418560757144], + "median_ns": 69.0706918471579, + "confidence_interval_ns": [68.85039126359696, 69.63825338770167], "sample_count": 50, - "sampled_time_ns": 6711175121 + "sampled_time_ns": 4982202250 }, { "fixture": "without_address", "operation": "tdbin_decode_framed", - "median_ns": 70.87808074538835, - "confidence_interval_ns": [70.59974120550089, 71.67373181120585], + "median_ns": 71.52131062190932, + "confidence_interval_ns": [71.3568695077877, 71.79698316672076], "sample_count": 50, - "sampled_time_ns": 5069348543 + "sampled_time_ns": 4985773829 }, { "fixture": "without_address", "operation": "tdbin_decode_packed_framed", - "median_ns": 503.91951086281813, - "confidence_interval_ns": [492.48392302917443, 563.7603335745704], + "median_ns": 482.7027420343137, + "confidence_interval_ns": [481.6115469082673, 485.379877102971], "sample_count": 50, - "sampled_time_ns": 7010311829 + "sampled_time_ns": 5005362500 }, { "fixture": "without_address", "operation": "protobuf_decode", - "median_ns": 59.88025371587054, - "confidence_interval_ns": [55.95282852373003, 72.98078501016434], + "median_ns": 50.66371098202555, + "confidence_interval_ns": [50.46992794103637, 50.904488323117846], "sample_count": 50, - "sampled_time_ns": 3556799747 + "sampled_time_ns": 4982155086 }, { "fixture": "without_address", "operation": "msgpack_decode", - "median_ns": 112.9894571248827, - "confidence_interval_ns": [112.01803022592519, 113.97490657130497], + "median_ns": 109.679979869954, + "confidence_interval_ns": [108.39510820680536, 110.71079638752053], "sample_count": 50, - "sampled_time_ns": 3502350879 + "sampled_time_ns": 5011578539 }, { "fixture": "metric_batch", "operation": "tdbin_encode_bare", - "median_ns": 11588.577888257576, - "confidence_interval_ns": [11444.932142857142, 11738.95706845238], + "median_ns": 11343.527180261834, + "confidence_interval_ns": [11198.346712703209, 11548.181268882176], "sample_count": 50, - "sampled_time_ns": 5056392958 + "sampled_time_ns": 4895851168 }, { "fixture": "metric_batch", "operation": "tdbin_encode_framed", - "median_ns": 11883.646245338543, - "confidence_interval_ns": [11631.997353436522, 12106.371909000989], + "median_ns": 11636.921146953406, + "confidence_interval_ns": [11441.998574237954, 11789.938053097345], "sample_count": 50, - "sampled_time_ns": 5063612791 + "sampled_time_ns": 5053565166 }, { "fixture": "metric_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 22957.34709072978, - "confidence_interval_ns": [22782.916478820327, 23148.726418378003], + "median_ns": 23042.410392300197, + "confidence_interval_ns": [22953.947368421053, 23097.775406985933], "sample_count": 50, - "sampled_time_ns": 4999250163 + "sampled_time_ns": 5027355088 }, { "fixture": "metric_batch", "operation": "protobuf_encode", - "median_ns": 45932.99861363178, - "confidence_interval_ns": [45139.31545338442, 46773.46767241379], + "median_ns": 45467.38587615025, + "confidence_interval_ns": [45165.31696273076, 46029.982880899974], "sample_count": 50, - "sampled_time_ns": 5910333299 + "sampled_time_ns": 5015355831 }, { "fixture": "metric_batch", "operation": "msgpack_encode", - "median_ns": 55672.97346230158, - "confidence_interval_ns": [52642.68783068783, 67791.4203443391], + "median_ns": 43944.26374716553, + "confidence_interval_ns": [43875.907333333336, 44050.09920634921], "sample_count": 50, - "sampled_time_ns": 6259996379 + "sampled_time_ns": 5058800291 }, { "fixture": "metric_batch", "operation": "tdbin_decode_bare", - "median_ns": 6470.843999896817, - "confidence_interval_ns": [6401.489143192488, 7546.199711087035], + "median_ns": 6262.635812745288, + "confidence_interval_ns": [6237.152478105096, 6275.781771307249], "sample_count": 50, - "sampled_time_ns": 3685052081 + "sampled_time_ns": 4999074499 }, { "fixture": "metric_batch", "operation": "tdbin_decode_framed", - "median_ns": 10620.987818830337, - "confidence_interval_ns": [8260.747885680956, 13678.352041101245], + "median_ns": 6251.415367766835, + "confidence_interval_ns": [6237.527484078082, 6260.328445763284], "sample_count": 50, - "sampled_time_ns": 7127277080 + "sampled_time_ns": 4963736041 }, { "fixture": "metric_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 39007.977904647436, - "confidence_interval_ns": [37516.5625, 43444.448979591834], + "median_ns": 29941.821112040132, + "confidence_interval_ns": [29837.204142011833, 30011.81217948718], "sample_count": 50, - "sampled_time_ns": 4056282081 + "sampled_time_ns": 4985412460 }, { "fixture": "metric_batch", "operation": "protobuf_decode", - "median_ns": 37773.42722039473, - "confidence_interval_ns": [35668.45745614035, 40497.11518379282], + "median_ns": 31316.763854518184, + "confidence_interval_ns": [30981.85108989036, 31566.36670416198], "sample_count": 50, - "sampled_time_ns": 2731351209 + "sampled_time_ns": 5056750127 }, { "fixture": "metric_batch", "operation": "msgpack_decode", - "median_ns": 51038.85957792208, - "confidence_interval_ns": [50881.19191919192, 51327.96376114705], + "median_ns": 50869.594322344325, + "confidence_interval_ns": [50666.834045584044, 50998.54506903353], "sample_count": 50, - "sampled_time_ns": 5559499837 + "sampled_time_ns": 5055011662 }, { "fixture": "person_batch", "operation": "tdbin_encode_bare", - "median_ns": 15290.80782274836, - "confidence_interval_ns": [13817.653687943262, 20269.366489361702], + "median_ns": 11592.643813533607, + "confidence_interval_ns": [11549.569822485208, 11661.946688666363], "sample_count": 50, - "sampled_time_ns": 6342106667 + "sampled_time_ns": 5018668294 }, { "fixture": "person_batch", "operation": "tdbin_encode_framed", - "median_ns": 11869.493530701755, - "confidence_interval_ns": [11735.392543859649, 11972.639453748006], + "median_ns": 11586.773543924148, + "confidence_interval_ns": [11551.83498366013, 11623.709234429065], "sample_count": 50, - "sampled_time_ns": 3470387204 + "sampled_time_ns": 5040314081 }, { "fixture": "person_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 19652.53206736889, - "confidence_interval_ns": [18662.184752747253, 27378.83910960834], + "median_ns": 15048.325823695535, + "confidence_interval_ns": [14982.277564102566, 15098.607292183624], "sample_count": 50, - "sampled_time_ns": 6760689083 + "sampled_time_ns": 5023850540 }, { "fixture": "person_batch", "operation": "protobuf_encode", - "median_ns": 19272.036319340332, - "confidence_interval_ns": [18868.090282131663, 19450.256786500366], + "median_ns": 18194.800138888888, + "confidence_interval_ns": [18038.995511831276, 18355.82729468599], "sample_count": 50, - "sampled_time_ns": 3617596330 + "sampled_time_ns": 5011984002 }, { "fixture": "person_batch", "operation": "msgpack_encode", - "median_ns": 51123.48028906956, - "confidence_interval_ns": [49243.72666666667, 60302.65625], + "median_ns": 48224.15000354252, + "confidence_interval_ns": [48004.14737654321, 48296.620594023974], "sample_count": 50, - "sampled_time_ns": 4209903958 + "sampled_time_ns": 5034277459 }, { "fixture": "person_batch", "operation": "tdbin_decode_bare", - "median_ns": 36895.09718319064, - "confidence_interval_ns": [36678.90182743658, 37988.8310676862], + "median_ns": 37126.50757350757, + "confidence_interval_ns": [36968.29494759183, 37348.43441814596], "sample_count": 50, - "sampled_time_ns": 5816982128 + "sampled_time_ns": 4953235625 }, { "fixture": "person_batch", "operation": "tdbin_decode_framed", - "median_ns": 46003.40461060678, - "confidence_interval_ns": [44472.876442307694, 49309.483235785956], + "median_ns": 36786.00741614256, + "confidence_interval_ns": [36715.63669618791, 36994.496630727765], "sample_count": 50, - "sampled_time_ns": 4643514870 + "sampled_time_ns": 4999731338 }, { "fixture": "person_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 41893.51758028793, - "confidence_interval_ns": [41422.78309165526, 42697.86843853821], + "median_ns": 41346.43828667413, + "confidence_interval_ns": [41170.889164086686, 41454.2500907441], "sample_count": 50, - "sampled_time_ns": 2513662209 + "sampled_time_ns": 5002426833 }, { "fixture": "person_batch", "operation": "protobuf_decode", - "median_ns": 61638.01116352201, - "confidence_interval_ns": [59350.70927993839, 64847.251076581575], + "median_ns": 57952.18742622467, + "confidence_interval_ns": [57728.19852941176, 58288.60294117647], "sample_count": 50, - "sampled_time_ns": 4309451709 + "sampled_time_ns": 5043928333 }, { "fixture": "person_batch", "operation": "msgpack_decode", - "median_ns": 89633.009939985, - "confidence_interval_ns": [89044.76998545646, 91726.98185483871], + "median_ns": 87827.8536643026, + "confidence_interval_ns": [87503.0105105105, 88480.63333333333], "sample_count": 50, - "sampled_time_ns": 3926919333 + "sampled_time_ns": 5043932832 }, { "fixture": "contact_batch", "operation": "tdbin_encode_bare", - "median_ns": 9266.606272211855, - "confidence_interval_ns": [9140.026663192635, 9452.01530986432], + "median_ns": 9007.822950954574, + "confidence_interval_ns": [8978.605727452272, 9035.88474560331], "sample_count": 50, - "sampled_time_ns": 3857291625 + "sampled_time_ns": 4979172379 }, { "fixture": "contact_batch", "operation": "tdbin_encode_framed", - "median_ns": 9234.68080699262, - "confidence_interval_ns": [9169.02528223883, 9297.520203927492], + "median_ns": 9024.667090069284, + "confidence_interval_ns": [8978.926096997691, 9050.118359027423], "sample_count": 50, - "sampled_time_ns": 3977619040 + "sampled_time_ns": 5001160789 }, { "fixture": "contact_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 14903.249021526419, - "confidence_interval_ns": [14180.89584960106, 16108.447488584476], + "median_ns": 11986.280761916918, + "confidence_interval_ns": [11966.281572337128, 12027.820730452675], "sample_count": 50, - "sampled_time_ns": 4533762125 + "sampled_time_ns": 4967119750 }, { "fixture": "contact_batch", "operation": "protobuf_encode", - "median_ns": 32440.645961369624, - "confidence_interval_ns": [29305.34825870647, 36745.66439232409], + "median_ns": 24956.69557977332, + "confidence_interval_ns": [24745.874870967742, 25115.466666666667], "sample_count": 50, - "sampled_time_ns": 3809607088 + "sampled_time_ns": 4944457955 }, { "fixture": "contact_batch", "operation": "msgpack_encode", - "median_ns": 87267.0824929972, - "confidence_interval_ns": [80904.33904761905, 106057.5457912458], + "median_ns": 69630.3850046116, + "confidence_interval_ns": [69408.10934311224, 69994.2330357143], "sample_count": 50, - "sampled_time_ns": 6489778831 + "sampled_time_ns": 4982157338 }, { "fixture": "contact_batch", "operation": "tdbin_decode_bare", - "median_ns": 33600.724575163404, - "confidence_interval_ns": [31363.31495098039, 38207.81099744246], + "median_ns": 31082.99929696288, + "confidence_interval_ns": [30977.353000368093, 31204.85520426968], "sample_count": 50, - "sampled_time_ns": 4708315081 + "sampled_time_ns": 5058548874 }, { "fixture": "contact_batch", "operation": "tdbin_decode_framed", - "median_ns": 31002.54762079152, - "confidence_interval_ns": [30748.072439024392, 31164.442985199083], + "median_ns": 31045.49037999038, + "confidence_interval_ns": [30864.738095238095, 31138.47204968944], "sample_count": 50, - "sampled_time_ns": 4892351754 + "sampled_time_ns": 4989841710 }, { "fixture": "contact_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 33539.338768664245, - "confidence_interval_ns": [33423.61557568674, 33741.69632768362], + "median_ns": 33269.70507785586, + "confidence_interval_ns": [33134.3334860284, 33520.2051294796], "sample_count": 50, - "sampled_time_ns": 5169715916 + "sampled_time_ns": 5039519039 }, { "fixture": "contact_batch", "operation": "protobuf_decode", - "median_ns": 80232.23790343205, - "confidence_interval_ns": [75230.9550960735, 96771.17678571428], + "median_ns": 61433.54985119047, + "confidence_interval_ns": [61210.43682715311, 61662.36480978261], "sample_count": 50, - "sampled_time_ns": 5430855792 + "sampled_time_ns": 5049477378 }, { "fixture": "contact_batch", "operation": "msgpack_decode", - "median_ns": 126633.81546296296, - "confidence_interval_ns": [112108.15954081039, 139730.67871840094], + "median_ns": 95622.82949308756, + "confidence_interval_ns": [95163.38048780488, 96002.9981707317], "sample_count": 50, - "sampled_time_ns": 4300871043 + "sampled_time_ns": 5005629750 }, { "fixture": "diagram_document", "operation": "tdbin_encode_bare", - "median_ns": 16328.420353403142, - "confidence_interval_ns": [15171.863720357253, 21075.388241710294], + "median_ns": 12351.921268640948, + "confidence_interval_ns": [12306.946741694377, 12392.45363492974], "sample_count": 50, - "sampled_time_ns": 5535814005 + "sampled_time_ns": 5027730535 }, { "fixture": "diagram_document", "operation": "tdbin_encode_framed", - "median_ns": 15389.819857561793, - "confidence_interval_ns": [14421.718637992832, 17625.37107725873], + "median_ns": 12340.496081761994, + "confidence_interval_ns": [12319.80141955836, 12386.922211069688], "sample_count": 50, - "sampled_time_ns": 5265309128 + "sampled_time_ns": 5002333541 }, { "fixture": "diagram_document", "operation": "tdbin_encode_packed_framed", - "median_ns": 19980.98353297734, - "confidence_interval_ns": [19798.90711282114, 20305.381940019215], + "median_ns": 19374.47514438944, + "confidence_interval_ns": [19309.01060820368, 19403.882929959662], "sample_count": 50, - "sampled_time_ns": 5193874708 + "sampled_time_ns": 5016867918 }, { "fixture": "diagram_document", "operation": "protobuf_encode", - "median_ns": 23326.629345443158, - "confidence_interval_ns": [22851.457153451996, 23895.02920001312], + "median_ns": 21809.475008464535, + "confidence_interval_ns": [21677.33214829863, 21979.914764565045], "sample_count": 50, - "sampled_time_ns": 3087903331 + "sampled_time_ns": 5003104038 }, { "fixture": "diagram_document", "operation": "msgpack_encode", - "median_ns": 56192.35987394958, - "confidence_interval_ns": [55766.18405923345, 56471.612087912086], + "median_ns": 55735, + "confidence_interval_ns": [55356.770535714284, 55915.070779220776], "sample_count": 50, - "sampled_time_ns": 5022687330 + "sampled_time_ns": 4975531665 }, { "fixture": "diagram_document", "operation": "tdbin_decode_bare", - "median_ns": 71630.12437229438, - "confidence_interval_ns": [71195.04601525876, 72026.69862258954], + "median_ns": 71659.45137741047, + "confidence_interval_ns": [71301.74825174826, 72031.07822410148], "sample_count": 50, - "sampled_time_ns": 5034613038 + "sampled_time_ns": 5022720875 }, { "fixture": "diagram_document", "operation": "tdbin_decode_framed", - "median_ns": 86172.74286987522, - "confidence_interval_ns": [80225.20238095238, 94685.40481411864], + "median_ns": 71705.15459848485, + "confidence_interval_ns": [71323.41060606061, 71949.3519138756], "sample_count": 50, - "sampled_time_ns": 6169789835 + "sampled_time_ns": 5033498996 }, { "fixture": "diagram_document", "operation": "tdbin_decode_packed_framed", - "median_ns": 100677.4273823764, - "confidence_interval_ns": [95732.52523219814, 105802.72240497076], + "median_ns": 80233.1297077922, + "confidence_interval_ns": [79980.72534013605, 80512.85714285714], "sample_count": 50, - "sampled_time_ns": 5425883205 + "sampled_time_ns": 5018654338 }, { "fixture": "diagram_document", "operation": "protobuf_decode", - "median_ns": 144501.27328431373, - "confidence_interval_ns": [122208.66524256761, 156521.6603660486], + "median_ns": 110586.76271885523, + "confidence_interval_ns": [110144.95992063492, 110847.85126262627], "sample_count": 50, - "sampled_time_ns": 3193202378 + "sampled_time_ns": 5078462339 }, { "fixture": "diagram_document", "operation": "msgpack_decode", - "median_ns": 141899.19012374687, - "confidence_interval_ns": [136311.68878718535, 152190.8447368421], + "median_ns": 134249.99215686275, + "confidence_interval_ns": [133630.25205128206, 134904.0184397163], "sample_count": 50, - "sampled_time_ns": 3682530081 + "sampled_time_ns": 5147938708 }, { "fixture": "event_batch", "operation": "tdbin_encode_bare", - "median_ns": 39326.42825396826, - "confidence_interval_ns": [38628.17156462585, 40455.19549382717], + "median_ns": 35478.442650128134, + "confidence_interval_ns": [35418.262733887736, 35511.23238623238], "sample_count": 50, - "sampled_time_ns": 5842968294 + "sampled_time_ns": 5035723793 }, { "fixture": "event_batch", "operation": "tdbin_encode_framed", - "median_ns": 36347.520224873166, - "confidence_interval_ns": [36062.61426303476, 36593.46193771626], + "median_ns": 35490.47264175941, + "confidence_interval_ns": [35370.747747747744, 35636.02211302211], "sample_count": 50, - "sampled_time_ns": 3370017125 + "sampled_time_ns": 5039176834 }, { "fixture": "event_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 52323.53520017993, - "confidence_interval_ns": [52166.07444444444, 52560.782222222224], + "median_ns": 51961.03733766233, + "confidence_interval_ns": [51840.161483253585, 52081.76691729324], "sample_count": 50, - "sampled_time_ns": 5031736957 + "sampled_time_ns": 5034662963 }, { "fixture": "event_batch", "operation": "protobuf_encode", - "median_ns": 81632.32540937324, - "confidence_interval_ns": [79429.96108534322, 86326.80872630044], + "median_ns": 66930.34093596588, + "confidence_interval_ns": [66714.00224704674, 67227.43644067796], "sample_count": 50, - "sampled_time_ns": 6664293203 + "sampled_time_ns": 5040797329 }, { "fixture": "event_batch", "operation": "msgpack_encode", - "median_ns": 174449.58301357733, - "confidence_interval_ns": [161878.3208041958, 197442.28468899522], + "median_ns": 138808.93690654673, + "confidence_interval_ns": [138563.4540229885, 139068.02262931035], "sample_count": 50, - "sampled_time_ns": 5853691048 + "sampled_time_ns": 5148309004 }, { "fixture": "event_batch", "operation": "tdbin_decode_bare", - "median_ns": 262856.50532800535, - "confidence_interval_ns": [239518.29268292684, 319617.43163265305], + "median_ns": 201637.53387973816, + "confidence_interval_ns": [199726.57213438736, 202658.1616448985], "sample_count": 50, - "sampled_time_ns": 5652190832 + "sampled_time_ns": 5649775703 }, { "fixture": "event_batch", "operation": "tdbin_decode_framed", - "median_ns": 229887.3074252137, - "confidence_interval_ns": [216824.5625, 282880.2083333333], + "median_ns": 201602.0534090909, + "confidence_interval_ns": [200463.21770334928, 202969.2135416667], "sample_count": 50, - "sampled_time_ns": 3370820623 + "sampled_time_ns": 5636485580 }, { "fixture": "event_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 207470.8923225999, - "confidence_interval_ns": [206502.86510416667, 208507.31891891893], + "median_ns": 207266.5981359649, + "confidence_interval_ns": [206924.74715909088, 207594.95967741936], "sample_count": 50, - "sampled_time_ns": 5315300083 + "sampled_time_ns": 5281910791 }, { "fixture": "event_batch", "operation": "protobuf_decode", - "median_ns": 282270.6351190476, - "confidence_interval_ns": [279410.26785714284, 283315.6894409938], + "median_ns": 279421.9536275094, + "confidence_interval_ns": [277026.81947431306, 281490.26666666666], "sample_count": 50, - "sampled_time_ns": 5034756794 + "sampled_time_ns": 5343109961 }, { "fixture": "event_batch", "operation": "msgpack_decode", - "median_ns": 389899.22987012984, - "confidence_interval_ns": [387274.1196172249, 413053.7909090909], + "median_ns": 387002.15777338605, + "confidence_interval_ns": [385202.4034965035, 388430.0944055944], "sample_count": 50, - "sampled_time_ns": 6327182500 + "sampled_time_ns": 5426402624 } ] } diff --git a/docs/reports/tdbin-bench-report.md b/docs/reports/tdbin-bench-report.md index 47cf6b8..60da3e3 100644 --- a/docs/reports/tdbin-bench-report.md +++ b/docs/reports/tdbin-bench-report.md @@ -3,77 +3,31 @@ > GENERATED FILE. Source: `scripts/tdbin-bench-report.mjs` and `docs/reports/tdbin-bench-data.json`. > Every value and verdict is computed from machine-readable Criterion and encoder output. No benchmark result is entered manually. -Generated: 2026-07-12T11:07:21.191Z +Generated: 2026-07-13T22:00:16.855Z -Raw data SHA-256: `20ace5953e08d909efb3b6cebd65b8a0111e05a727b72687af594ba1e225e843` +Raw data SHA-256: `134d351db228a6f2338f73f6682333eaa671b8aa6d688c8c074285126819f5fe` ## Result -**Specification gate ([TDBIN-BENCH-CORPUS] committed workloads): FAIL.** 2 of 3 corpus fixtures have a production wire mode that is simultaneously smaller than Protobuf AND at least 1.50x faster on both encode and decode. Stress rows: 1 of 4 pass the same bar. +**Specification gate ([TDBIN-BENCH-CORPUS] committed workloads): FAIL.** 2 of 3 corpus fixtures have a production wire mode that is simultaneously smaller than Protobuf AND at least 1.50x faster on both encode and decode. Stress rows: 2 of 4 pass the same bar. -Qualifying modes: `with_address` = none, `without_address` = none, `metric_batch` = framed, `person_batch` = none, `contact_batch` = framed & packed framed, `diagram_document` = framed, `event_batch` = none. +Qualifying modes: `with_address` = none, `without_address` = none, `metric_batch` = framed, `person_batch` = framed, `contact_batch` = framed & packed framed, `diagram_document` = framed, `event_batch` = none. The release gate ([TDBIN-BENCH-GATE]) requires, for every corpus entry — the committed realistic schemas in `docs/benchmarks/tdbin-corpus.{td,proto}` (record-heavy document, union-heavy event stream, list-heavy dataset) — that at least one self-describing production wire mode (framed, or packed framed; the frame's PACKED flag makes the two interchangeable to every decoder) beats Protobuf on size and by 1.50x on both encode and decode simultaneously. Both modes are always measured and published below. Stress rows (marked) are reported against the identical bar; the tiny single-message rows carry a fixed 12-byte frame plus pointer-per-string overhead that no fixed-layout format recovers at sub-100-byte payloads (research §2.2), so they are not corpus entries. -## At a Glance +## Size and Speed -One table per fixture. Rows are the three protocols; columns are the bench types (size, encode speed, decode speed). `tdbin` is the **framed** wire mode — the self-describing production peer of `msgpack` (struct-as-map, via `rmp-serde`) and `protobuf`. Lower is better everywhere. The full multi-mode breakdown (TDBIN bare/framed/packed and every Criterion statistic) is in the detailed tables further down. +The headline comparison — one row per test, all three self-describing formats side by side. TDBIN is its **framed** production mode; MessagePack is struct-as-map (via `rmp-serde`). Sizes are bytes; serialize is the full ADT→binary conversion, deserialize the full binary→ADT conversion. Lower is better everywhere. -### `with_address` — tiny nested record and union (stress, 1 items) - -| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | -| -------------- | -----------: | ----------: | --------------: | --------------: | -| tdbin (framed) | 172 | +117.7% | 94.85 ns | 166.09 ns | -| protobuf | 79 | same | 48.14 ns | 157.46 ns | -| msgpack | 142 | +79.7% | 269.46 ns | 219.07 ns | - -### `without_address` — tiny sparse record and union (stress, 1 items) - -| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | -| -------------- | -----------: | ----------: | --------------: | --------------: | -| tdbin (framed) | 124 | +300.0% | 66.26 ns | 70.88 ns | -| protobuf | 31 | same | 37.65 ns | 59.88 ns | -| msgpack | 100 | +222.6% | 343.28 ns | 112.99 ns | - -### `metric_batch` — list-heavy telemetry (corpus, 4,096 items) - -| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | -| -------------- | -----------: | ----------: | --------------: | --------------: | -| tdbin (framed) | 43,788 | -48.0% | 11.884 us | 10.621 us | -| protobuf | 84,149 | same | 45.933 us | 37.773 us | -| msgpack | 90,440 | +7.5% | 55.673 us | 51.039 us | - -### `person_batch` — repeated records (stress, 512 items) - -| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | -| -------------- | -----------: | ----------: | --------------: | --------------: | -| tdbin (framed) | 22,988 | -21.2% | 11.869 us | 46.003 us | -| protobuf | 29,184 | same | 19.272 us | 61.638 us | -| msgpack | 61,963 | +112.3% | 51.123 us | 89.633 us | - -### `contact_batch` — repeated unions (stress, 2,048 items) - -| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | -| -------------- | -----------: | ----------: | --------------: | --------------: | -| tdbin (framed) | 23,156 | -34.3% | 9.235 us | 31.003 us | -| protobuf | 35,221 | same | 32.441 us | 80.232 us | -| msgpack | 80,162 | +127.6% | 87.267 us | 126.634 us | - -### `diagram_document` — record-heavy diagram document (corpus, 768 items) - -| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | -| -------------- | -----------: | ----------: | --------------: | --------------: | -| tdbin (framed) | 45,172 | -11.1% | 15.390 us | 86.173 us | -| protobuf | 50,788 | same | 23.327 us | 144.501 us | -| msgpack | 77,410 | +52.4% | 56.192 us | 141.899 us | - -### `event_batch` — union-heavy event stream (corpus, 2,048 items) - -| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | -| -------------- | -----------: | ----------: | --------------: | --------------: | -| tdbin (framed) | 116,372 | -11.7% | 36.348 us | 229.887 us | -| protobuf | 131,744 | same | 81.632 us | 282.271 us | -| msgpack | 230,620 | +75.1% | 174.450 us | 389.899 us | +| Test | typeDiagram Size | Protobuf Size | MessagePack Size | typeDiagram Serialize | Protobuf Serialize | MessagePack Serialize | typeDiagram Deserialize | Protobuf Deserialize | MessagePack Deserialize | +| ------------------ | ---------------: | ------------: | ---------------: | --------------------: | -----------------: | --------------------: | ----------------------: | -------------------: | ----------------------: | +| `with_address` | 172 | 79 | 142 | 87.51 ns | 46.84 ns | 252.33 ns | 153.21 ns | 152.49 ns | 195.31 ns | +| `without_address` | 124 | 31 | 100 | 65.76 ns | 32.68 ns | 228.84 ns | 71.52 ns | 50.66 ns | 109.68 ns | +| `metric_batch` | 43,788 | 84,149 | 90,440 | 11.637 us | 45.467 us | 43.944 us | 6.251 us | 31.317 us | 50.870 us | +| `person_batch` | 22,988 | 29,184 | 61,963 | 11.587 us | 18.195 us | 48.224 us | 36.786 us | 57.952 us | 87.828 us | +| `contact_batch` | 23,156 | 35,221 | 80,162 | 9.025 us | 24.957 us | 69.630 us | 31.045 us | 61.434 us | 95.623 us | +| `diagram_document` | 45,172 | 50,788 | 77,410 | 12.340 us | 21.809 us | 55.735 us | 71.705 us | 110.587 us | 134.250 us | +| `event_batch` | 116,372 | 131,744 | 230,620 | 35.490 us | 66.930 us | 138.809 us | 201.602 us | 279.422 us | 387.002 us | ## Environment @@ -113,78 +67,80 @@ All sizes are bytes. Percentage columns are relative to Protobuf; negative is sm ## Criterion Medians +Each row is one **individual** operation — a complete serialize _or_ deserialize, not a round-trip. The operation name encodes the direction (`encode` = ADT→binary, `decode` = binary→ADT) and the wire mode (`bare`, `framed`, or `packed_framed` for TDBIN). "Median" is the per-call time (what to compare); "Sampled time" is only Criterion's total measurement budget for that row. Sum a fixture's `encode` and `decode` rows to get the round-trip totals above. + | Fixture | Operation | Samples | Sampled time | Median | CI lower | CI upper | | ------------------ | ---------------------------- | ------: | -----------: | ---------: | ---------: | ---------: | -| `with_address` | `tdbin_encode_bare` | 50 | 4708.488 ms | 90.06 ns | 87.32 ns | 101.16 ns | -| `with_address` | `tdbin_encode_framed` | 50 | 4164.282 ms | 94.85 ns | 91.56 ns | 105.54 ns | -| `with_address` | `tdbin_encode_packed_framed` | 50 | 3260.576 ms | 185.09 ns | 181.68 ns | 189.35 ns | -| `with_address` | `protobuf_encode` | 50 | 3512.653 ms | 48.14 ns | 47.51 ns | 48.83 ns | -| `with_address` | `msgpack_encode` | 50 | 4988.903 ms | 269.46 ns | 267.69 ns | 271.46 ns | -| `with_address` | `tdbin_decode_bare` | 50 | 4914.774 ms | 154.58 ns | 154.04 ns | 155.69 ns | -| `with_address` | `tdbin_decode_framed` | 50 | 5029.047 ms | 166.09 ns | 159.90 ns | 188.14 ns | -| `with_address` | `tdbin_decode_packed_framed` | 50 | 6760.547 ms | 622.64 ns | 589.33 ns | 699.50 ns | -| `with_address` | `protobuf_decode` | 50 | 5433.692 ms | 157.46 ns | 155.60 ns | 165.73 ns | -| `with_address` | `msgpack_decode` | 50 | 5022.274 ms | 219.07 ns | 209.53 ns | 242.46 ns | -| `without_address` | `tdbin_encode_bare` | 50 | 3879.383 ms | 71.24 ns | 68.64 ns | 76.57 ns | -| `without_address` | `tdbin_encode_framed` | 50 | 3292.059 ms | 66.26 ns | 65.80 ns | 67.19 ns | -| `without_address` | `tdbin_encode_packed_framed` | 50 | 5970.711 ms | 177.18 ns | 164.10 ns | 203.00 ns | -| `without_address` | `protobuf_encode` | 50 | 5080.760 ms | 37.65 ns | 35.14 ns | 41.79 ns | -| `without_address` | `msgpack_encode` | 50 | 5402.275 ms | 343.28 ns | 302.47 ns | 362.57 ns | -| `without_address` | `tdbin_decode_bare` | 50 | 6711.175 ms | 76.80 ns | 71.65 ns | 89.58 ns | -| `without_address` | `tdbin_decode_framed` | 50 | 5069.349 ms | 70.88 ns | 70.60 ns | 71.67 ns | -| `without_address` | `tdbin_decode_packed_framed` | 50 | 7010.312 ms | 503.92 ns | 492.48 ns | 563.76 ns | -| `without_address` | `protobuf_decode` | 50 | 3556.800 ms | 59.88 ns | 55.95 ns | 72.98 ns | -| `without_address` | `msgpack_decode` | 50 | 3502.351 ms | 112.99 ns | 112.02 ns | 113.97 ns | -| `metric_batch` | `tdbin_encode_bare` | 50 | 5056.393 ms | 11.589 us | 11.445 us | 11.739 us | -| `metric_batch` | `tdbin_encode_framed` | 50 | 5063.613 ms | 11.884 us | 11.632 us | 12.106 us | -| `metric_batch` | `tdbin_encode_packed_framed` | 50 | 4999.250 ms | 22.957 us | 22.783 us | 23.149 us | -| `metric_batch` | `protobuf_encode` | 50 | 5910.333 ms | 45.933 us | 45.139 us | 46.773 us | -| `metric_batch` | `msgpack_encode` | 50 | 6259.996 ms | 55.673 us | 52.643 us | 67.791 us | -| `metric_batch` | `tdbin_decode_bare` | 50 | 3685.052 ms | 6.471 us | 6.401 us | 7.546 us | -| `metric_batch` | `tdbin_decode_framed` | 50 | 7127.277 ms | 10.621 us | 8.261 us | 13.678 us | -| `metric_batch` | `tdbin_decode_packed_framed` | 50 | 4056.282 ms | 39.008 us | 37.517 us | 43.444 us | -| `metric_batch` | `protobuf_decode` | 50 | 2731.351 ms | 37.773 us | 35.668 us | 40.497 us | -| `metric_batch` | `msgpack_decode` | 50 | 5559.500 ms | 51.039 us | 50.881 us | 51.328 us | -| `person_batch` | `tdbin_encode_bare` | 50 | 6342.107 ms | 15.291 us | 13.818 us | 20.269 us | -| `person_batch` | `tdbin_encode_framed` | 50 | 3470.387 ms | 11.869 us | 11.735 us | 11.973 us | -| `person_batch` | `tdbin_encode_packed_framed` | 50 | 6760.689 ms | 19.653 us | 18.662 us | 27.379 us | -| `person_batch` | `protobuf_encode` | 50 | 3617.596 ms | 19.272 us | 18.868 us | 19.450 us | -| `person_batch` | `msgpack_encode` | 50 | 4209.904 ms | 51.123 us | 49.244 us | 60.303 us | -| `person_batch` | `tdbin_decode_bare` | 50 | 5816.982 ms | 36.895 us | 36.679 us | 37.989 us | -| `person_batch` | `tdbin_decode_framed` | 50 | 4643.515 ms | 46.003 us | 44.473 us | 49.309 us | -| `person_batch` | `tdbin_decode_packed_framed` | 50 | 2513.662 ms | 41.894 us | 41.423 us | 42.698 us | -| `person_batch` | `protobuf_decode` | 50 | 4309.452 ms | 61.638 us | 59.351 us | 64.847 us | -| `person_batch` | `msgpack_decode` | 50 | 3926.919 ms | 89.633 us | 89.045 us | 91.727 us | -| `contact_batch` | `tdbin_encode_bare` | 50 | 3857.292 ms | 9.267 us | 9.140 us | 9.452 us | -| `contact_batch` | `tdbin_encode_framed` | 50 | 3977.619 ms | 9.235 us | 9.169 us | 9.298 us | -| `contact_batch` | `tdbin_encode_packed_framed` | 50 | 4533.762 ms | 14.903 us | 14.181 us | 16.108 us | -| `contact_batch` | `protobuf_encode` | 50 | 3809.607 ms | 32.441 us | 29.305 us | 36.746 us | -| `contact_batch` | `msgpack_encode` | 50 | 6489.779 ms | 87.267 us | 80.904 us | 106.058 us | -| `contact_batch` | `tdbin_decode_bare` | 50 | 4708.315 ms | 33.601 us | 31.363 us | 38.208 us | -| `contact_batch` | `tdbin_decode_framed` | 50 | 4892.352 ms | 31.003 us | 30.748 us | 31.164 us | -| `contact_batch` | `tdbin_decode_packed_framed` | 50 | 5169.716 ms | 33.539 us | 33.424 us | 33.742 us | -| `contact_batch` | `protobuf_decode` | 50 | 5430.856 ms | 80.232 us | 75.231 us | 96.771 us | -| `contact_batch` | `msgpack_decode` | 50 | 4300.871 ms | 126.634 us | 112.108 us | 139.731 us | -| `diagram_document` | `tdbin_encode_bare` | 50 | 5535.814 ms | 16.328 us | 15.172 us | 21.075 us | -| `diagram_document` | `tdbin_encode_framed` | 50 | 5265.309 ms | 15.390 us | 14.422 us | 17.625 us | -| `diagram_document` | `tdbin_encode_packed_framed` | 50 | 5193.875 ms | 19.981 us | 19.799 us | 20.305 us | -| `diagram_document` | `protobuf_encode` | 50 | 3087.903 ms | 23.327 us | 22.851 us | 23.895 us | -| `diagram_document` | `msgpack_encode` | 50 | 5022.687 ms | 56.192 us | 55.766 us | 56.472 us | -| `diagram_document` | `tdbin_decode_bare` | 50 | 5034.613 ms | 71.630 us | 71.195 us | 72.027 us | -| `diagram_document` | `tdbin_decode_framed` | 50 | 6169.790 ms | 86.173 us | 80.225 us | 94.685 us | -| `diagram_document` | `tdbin_decode_packed_framed` | 50 | 5425.883 ms | 100.677 us | 95.733 us | 105.803 us | -| `diagram_document` | `protobuf_decode` | 50 | 3193.202 ms | 144.501 us | 122.209 us | 156.522 us | -| `diagram_document` | `msgpack_decode` | 50 | 3682.530 ms | 141.899 us | 136.312 us | 152.191 us | -| `event_batch` | `tdbin_encode_bare` | 50 | 5842.968 ms | 39.326 us | 38.628 us | 40.455 us | -| `event_batch` | `tdbin_encode_framed` | 50 | 3370.017 ms | 36.348 us | 36.063 us | 36.593 us | -| `event_batch` | `tdbin_encode_packed_framed` | 50 | 5031.737 ms | 52.324 us | 52.166 us | 52.561 us | -| `event_batch` | `protobuf_encode` | 50 | 6664.293 ms | 81.632 us | 79.430 us | 86.327 us | -| `event_batch` | `msgpack_encode` | 50 | 5853.691 ms | 174.450 us | 161.878 us | 197.442 us | -| `event_batch` | `tdbin_decode_bare` | 50 | 5652.191 ms | 262.857 us | 239.518 us | 319.617 us | -| `event_batch` | `tdbin_decode_framed` | 50 | 3370.821 ms | 229.887 us | 216.825 us | 282.880 us | -| `event_batch` | `tdbin_decode_packed_framed` | 50 | 5315.300 ms | 207.471 us | 206.503 us | 208.507 us | -| `event_batch` | `protobuf_decode` | 50 | 5034.757 ms | 282.271 us | 279.410 us | 283.316 us | -| `event_batch` | `msgpack_decode` | 50 | 6327.182 ms | 389.899 us | 387.274 us | 413.054 us | +| `with_address` | `tdbin_encode_bare` | 50 | 4979.331 ms | 83.62 ns | 83.24 ns | 84.05 ns | +| `with_address` | `tdbin_encode_framed` | 50 | 4680.046 ms | 87.51 ns | 87.22 ns | 88.42 ns | +| `with_address` | `tdbin_encode_packed_framed` | 50 | 5033.556 ms | 184.41 ns | 183.63 ns | 185.68 ns | +| `with_address` | `protobuf_encode` | 50 | 4875.876 ms | 46.84 ns | 46.66 ns | 47.05 ns | +| `with_address` | `msgpack_encode` | 50 | 4900.558 ms | 252.33 ns | 250.67 ns | 254.03 ns | +| `with_address` | `tdbin_decode_bare` | 50 | 5004.358 ms | 151.84 ns | 151.29 ns | 153.13 ns | +| `with_address` | `tdbin_decode_framed` | 50 | 5015.032 ms | 153.21 ns | 152.62 ns | 153.56 ns | +| `with_address` | `tdbin_decode_packed_framed` | 50 | 5018.607 ms | 571.43 ns | 570.17 ns | 573.83 ns | +| `with_address` | `protobuf_decode` | 50 | 5014.850 ms | 152.49 ns | 151.72 ns | 153.26 ns | +| `with_address` | `msgpack_decode` | 50 | 5300.693 ms | 195.31 ns | 194.47 ns | 196.07 ns | +| `without_address` | `tdbin_encode_bare` | 50 | 4706.953 ms | 61.10 ns | 60.79 ns | 61.43 ns | +| `without_address` | `tdbin_encode_framed` | 50 | 4976.057 ms | 65.76 ns | 65.49 ns | 66.17 ns | +| `without_address` | `tdbin_encode_packed_framed` | 50 | 4880.894 ms | 141.92 ns | 141.35 ns | 142.94 ns | +| `without_address` | `protobuf_encode` | 50 | 4975.979 ms | 32.68 ns | 32.60 ns | 32.86 ns | +| `without_address` | `msgpack_encode` | 50 | 4988.960 ms | 228.84 ns | 228.13 ns | 230.06 ns | +| `without_address` | `tdbin_decode_bare` | 50 | 4982.202 ms | 69.07 ns | 68.85 ns | 69.64 ns | +| `without_address` | `tdbin_decode_framed` | 50 | 4985.774 ms | 71.52 ns | 71.36 ns | 71.80 ns | +| `without_address` | `tdbin_decode_packed_framed` | 50 | 5005.363 ms | 482.70 ns | 481.61 ns | 485.38 ns | +| `without_address` | `protobuf_decode` | 50 | 4982.155 ms | 50.66 ns | 50.47 ns | 50.90 ns | +| `without_address` | `msgpack_decode` | 50 | 5011.579 ms | 109.68 ns | 108.40 ns | 110.71 ns | +| `metric_batch` | `tdbin_encode_bare` | 50 | 4895.851 ms | 11.344 us | 11.198 us | 11.548 us | +| `metric_batch` | `tdbin_encode_framed` | 50 | 5053.565 ms | 11.637 us | 11.442 us | 11.790 us | +| `metric_batch` | `tdbin_encode_packed_framed` | 50 | 5027.355 ms | 23.042 us | 22.954 us | 23.098 us | +| `metric_batch` | `protobuf_encode` | 50 | 5015.356 ms | 45.467 us | 45.165 us | 46.030 us | +| `metric_batch` | `msgpack_encode` | 50 | 5058.800 ms | 43.944 us | 43.876 us | 44.050 us | +| `metric_batch` | `tdbin_decode_bare` | 50 | 4999.074 ms | 6.263 us | 6.237 us | 6.276 us | +| `metric_batch` | `tdbin_decode_framed` | 50 | 4963.736 ms | 6.251 us | 6.238 us | 6.260 us | +| `metric_batch` | `tdbin_decode_packed_framed` | 50 | 4985.412 ms | 29.942 us | 29.837 us | 30.012 us | +| `metric_batch` | `protobuf_decode` | 50 | 5056.750 ms | 31.317 us | 30.982 us | 31.566 us | +| `metric_batch` | `msgpack_decode` | 50 | 5055.012 ms | 50.870 us | 50.667 us | 50.999 us | +| `person_batch` | `tdbin_encode_bare` | 50 | 5018.668 ms | 11.593 us | 11.550 us | 11.662 us | +| `person_batch` | `tdbin_encode_framed` | 50 | 5040.314 ms | 11.587 us | 11.552 us | 11.624 us | +| `person_batch` | `tdbin_encode_packed_framed` | 50 | 5023.851 ms | 15.048 us | 14.982 us | 15.099 us | +| `person_batch` | `protobuf_encode` | 50 | 5011.984 ms | 18.195 us | 18.039 us | 18.356 us | +| `person_batch` | `msgpack_encode` | 50 | 5034.277 ms | 48.224 us | 48.004 us | 48.297 us | +| `person_batch` | `tdbin_decode_bare` | 50 | 4953.236 ms | 37.127 us | 36.968 us | 37.348 us | +| `person_batch` | `tdbin_decode_framed` | 50 | 4999.731 ms | 36.786 us | 36.716 us | 36.994 us | +| `person_batch` | `tdbin_decode_packed_framed` | 50 | 5002.427 ms | 41.346 us | 41.171 us | 41.454 us | +| `person_batch` | `protobuf_decode` | 50 | 5043.928 ms | 57.952 us | 57.728 us | 58.289 us | +| `person_batch` | `msgpack_decode` | 50 | 5043.933 ms | 87.828 us | 87.503 us | 88.481 us | +| `contact_batch` | `tdbin_encode_bare` | 50 | 4979.172 ms | 9.008 us | 8.979 us | 9.036 us | +| `contact_batch` | `tdbin_encode_framed` | 50 | 5001.161 ms | 9.025 us | 8.979 us | 9.050 us | +| `contact_batch` | `tdbin_encode_packed_framed` | 50 | 4967.120 ms | 11.986 us | 11.966 us | 12.028 us | +| `contact_batch` | `protobuf_encode` | 50 | 4944.458 ms | 24.957 us | 24.746 us | 25.115 us | +| `contact_batch` | `msgpack_encode` | 50 | 4982.157 ms | 69.630 us | 69.408 us | 69.994 us | +| `contact_batch` | `tdbin_decode_bare` | 50 | 5058.549 ms | 31.083 us | 30.977 us | 31.205 us | +| `contact_batch` | `tdbin_decode_framed` | 50 | 4989.842 ms | 31.045 us | 30.865 us | 31.138 us | +| `contact_batch` | `tdbin_decode_packed_framed` | 50 | 5039.519 ms | 33.270 us | 33.134 us | 33.520 us | +| `contact_batch` | `protobuf_decode` | 50 | 5049.477 ms | 61.434 us | 61.210 us | 61.662 us | +| `contact_batch` | `msgpack_decode` | 50 | 5005.630 ms | 95.623 us | 95.163 us | 96.003 us | +| `diagram_document` | `tdbin_encode_bare` | 50 | 5027.731 ms | 12.352 us | 12.307 us | 12.392 us | +| `diagram_document` | `tdbin_encode_framed` | 50 | 5002.334 ms | 12.340 us | 12.320 us | 12.387 us | +| `diagram_document` | `tdbin_encode_packed_framed` | 50 | 5016.868 ms | 19.374 us | 19.309 us | 19.404 us | +| `diagram_document` | `protobuf_encode` | 50 | 5003.104 ms | 21.809 us | 21.677 us | 21.980 us | +| `diagram_document` | `msgpack_encode` | 50 | 4975.532 ms | 55.735 us | 55.357 us | 55.915 us | +| `diagram_document` | `tdbin_decode_bare` | 50 | 5022.721 ms | 71.659 us | 71.302 us | 72.031 us | +| `diagram_document` | `tdbin_decode_framed` | 50 | 5033.499 ms | 71.705 us | 71.323 us | 71.949 us | +| `diagram_document` | `tdbin_decode_packed_framed` | 50 | 5018.654 ms | 80.233 us | 79.981 us | 80.513 us | +| `diagram_document` | `protobuf_decode` | 50 | 5078.462 ms | 110.587 us | 110.145 us | 110.848 us | +| `diagram_document` | `msgpack_decode` | 50 | 5147.939 ms | 134.250 us | 133.630 us | 134.904 us | +| `event_batch` | `tdbin_encode_bare` | 50 | 5035.724 ms | 35.478 us | 35.418 us | 35.511 us | +| `event_batch` | `tdbin_encode_framed` | 50 | 5039.177 ms | 35.490 us | 35.371 us | 35.636 us | +| `event_batch` | `tdbin_encode_packed_framed` | 50 | 5034.663 ms | 51.961 us | 51.840 us | 52.082 us | +| `event_batch` | `protobuf_encode` | 50 | 5040.797 ms | 66.930 us | 66.714 us | 67.227 us | +| `event_batch` | `msgpack_encode` | 50 | 5148.309 ms | 138.809 us | 138.563 us | 139.068 us | +| `event_batch` | `tdbin_decode_bare` | 50 | 5649.776 ms | 201.638 us | 199.727 us | 202.658 us | +| `event_batch` | `tdbin_decode_framed` | 50 | 5636.486 ms | 201.602 us | 200.463 us | 202.969 us | +| `event_batch` | `tdbin_decode_packed_framed` | 50 | 5281.911 ms | 207.267 us | 206.925 us | 207.595 us | +| `event_batch` | `protobuf_decode` | 50 | 5343.110 ms | 279.422 us | 277.027 us | 281.490 us | +| `event_batch` | `msgpack_decode` | 50 | 5426.403 ms | 387.002 us | 385.202 us | 388.430 us | ## Same-Mode Comparison @@ -192,29 +148,29 @@ Ratios are Protobuf median / TDBIN median; values above 1.00x favor TDBIN. The g | Fixture | TDBIN mode | Size winner | Encode ratio | Decode ratio | Gate | | ------------------ | ------------- | ----------- | -----------: | -----------: | ---- | -| `with_address` | bare | Protobuf | 0.53x | 1.02x | FAIL | -| `with_address` | framed | Protobuf | 0.51x | 0.95x | FAIL | -| `with_address` | packed framed | Protobuf | 0.26x | 0.25x | FAIL | -| `without_address` | bare | Protobuf | 0.53x | 0.78x | FAIL | -| `without_address` | framed | Protobuf | 0.57x | 0.84x | FAIL | -| `without_address` | packed framed | Protobuf | 0.21x | 0.12x | FAIL | -| `metric_batch` | bare | TDBIN | 3.96x | 5.84x | PASS | -| `metric_batch` | framed | TDBIN | 3.87x | 3.56x | PASS | -| `metric_batch` | packed framed | TDBIN | 2.00x | 0.97x | FAIL | -| `person_batch` | bare | TDBIN | 1.26x | 1.67x | FAIL | -| `person_batch` | framed | TDBIN | 1.62x | 1.34x | FAIL | -| `person_batch` | packed framed | TDBIN | 0.98x | 1.47x | FAIL | -| `contact_batch` | bare | TDBIN | 3.50x | 2.39x | PASS | -| `contact_batch` | framed | TDBIN | 3.51x | 2.59x | PASS | -| `contact_batch` | packed framed | TDBIN | 2.18x | 2.39x | PASS | -| `diagram_document` | bare | TDBIN | 1.43x | 2.02x | FAIL | -| `diagram_document` | framed | TDBIN | 1.52x | 1.68x | PASS | -| `diagram_document` | packed framed | TDBIN | 1.17x | 1.44x | FAIL | -| `event_batch` | bare | TDBIN | 2.08x | 1.07x | FAIL | -| `event_batch` | framed | TDBIN | 2.25x | 1.23x | FAIL | -| `event_batch` | packed framed | TDBIN | 1.56x | 1.36x | FAIL | - -Passing fixture/mode combinations: 6 of 21. +| `with_address` | bare | Protobuf | 0.56x | 1.00x | FAIL | +| `with_address` | framed | Protobuf | 0.54x | 1.00x | FAIL | +| `with_address` | packed framed | Protobuf | 0.25x | 0.27x | FAIL | +| `without_address` | bare | Protobuf | 0.53x | 0.73x | FAIL | +| `without_address` | framed | Protobuf | 0.50x | 0.71x | FAIL | +| `without_address` | packed framed | Protobuf | 0.23x | 0.10x | FAIL | +| `metric_batch` | bare | TDBIN | 4.01x | 5.00x | PASS | +| `metric_batch` | framed | TDBIN | 3.91x | 5.01x | PASS | +| `metric_batch` | packed framed | TDBIN | 1.97x | 1.05x | FAIL | +| `person_batch` | bare | TDBIN | 1.57x | 1.56x | PASS | +| `person_batch` | framed | TDBIN | 1.57x | 1.58x | PASS | +| `person_batch` | packed framed | TDBIN | 1.21x | 1.40x | FAIL | +| `contact_batch` | bare | TDBIN | 2.77x | 1.98x | PASS | +| `contact_batch` | framed | TDBIN | 2.77x | 1.98x | PASS | +| `contact_batch` | packed framed | TDBIN | 2.08x | 1.85x | PASS | +| `diagram_document` | bare | TDBIN | 1.77x | 1.54x | PASS | +| `diagram_document` | framed | TDBIN | 1.77x | 1.54x | PASS | +| `diagram_document` | packed framed | TDBIN | 1.13x | 1.38x | FAIL | +| `event_batch` | bare | TDBIN | 1.89x | 1.39x | FAIL | +| `event_batch` | framed | TDBIN | 1.89x | 1.39x | FAIL | +| `event_batch` | packed framed | TDBIN | 1.29x | 1.35x | FAIL | + +Passing fixture/mode combinations: 9 of 21. This secondary table exposes unpacked tradeoffs; it does not replace the packed-framed specification gate above. diff --git a/docs/specs/tdbin-benchmarks.md b/docs/specs/tdbin-benchmarks.md index cf5deb5..2a082a5 100644 --- a/docs/specs/tdbin-benchmarks.md +++ b/docs/specs/tdbin-benchmarks.md @@ -1,55 +1,55 @@ # TDBIN Benchmarks -TDBIN is the compact binary codec typeDiagram generates for algebraic data types. This page compares it against two widely used serialization formats — **Protocol Buffers** and **MessagePack** — on realistic workloads, so you can judge what the format buys you. +TDBIN is typeDiagram's compact binary codec for algebraic data types, measured here against **Protocol Buffers** and **MessagePack** on realistic workloads. Every number is data-derived — produced by `scripts/tdbin-bench-report.mjs` from Criterion timings and exact encoder output — and regenerates on each benchmark run. -Numbers below are measured, not estimated: they are produced by `scripts/tdbin-bench-report.mjs` from Criterion timings and exact encoder output, and regenerate whenever the benchmark runs. For the full breakdown — all wire modes, every fixture, confidence intervals — see the generated benchmark report at `docs/reports/tdbin-bench-report.md`. +## The three formats -## What each format is for +**TDBIN (framed)** is a self-describing frame around typeDiagram's columnar layout; it drops the per-field tags the others repeat on every record, so on batches of the same shape — telemetry, event streams, repeated records — it is both the smallest and the fastest here, and it is the format to reach for when you control both ends and your data is list- or record-heavy. -- **TDBIN (framed)** — a self-describing frame around typeDiagram's columnar layout. It shines on _batches of the same shape_ (telemetry rows, event streams, repeated records): the columnar layout lets it skip the per-field tags every other format repeats, so it is both the smallest and the fastest here. Reach for it when you control both ends and your data is list- or record-heavy. -- **Protocol Buffers** — schema-driven, tag-per-field. Extremely compact and fast on _small, sparse messages_ (a single record with optional fields), and the industry default for cross-language RPC with a shared `.proto`. Reach for it when messages are small, schemas are shared, and you need the broadest ecosystem. -- **MessagePack** — schemaless, self-describing (`struct-as-map`, via `rmp-serde`). It carries field names on the wire, so it is the largest and slowest of the three, but it needs _no schema at all_ and any language can read it. Reach for it for loosely-typed interchange, config blobs, or when a schema is impractical. +**Protocol Buffers** is schema-driven with a tag per field, extremely compact and fast on small sparse messages and the industry default for cross-language RPC over a shared `.proto`, so reach for it when messages are small, schemas are shared, and you need the broadest ecosystem. + +**MessagePack** is schemaless and self-describing (`struct-as-map`, via `rmp-serde`); it carries field names on the wire so it is the largest and slowest of the three, but it needs no schema at all and any language can read it, which makes it the pick for loosely-typed interchange, config blobs, or wherever a schema is impractical. ## Results -The tables use typeDiagram's committed **corpus** workloads — the realistic schemas the release gate is defined on. TDBIN is shown in its **framed** production mode, the self-describing peer of the other two. +Each table is one committed **corpus** workload. Rows are the three formats; columns are the encoded size and the whole-operation timings. TDBIN is shown in its **framed** production mode — the self-describing peer of the other two. ### list-heavy telemetry -4,096 items. Lower is better in every column; **1.00x** marks the winner of that column. +4,096 items. Lower is better in every column. "Serialize" is the whole ADT→binary conversion, "Deserialize" the whole binary→ADT conversion, and "Round-trip" the two summed. -| Format | Size (bytes) | Size | Encode | Decode | -| -------------- | -----------: | ----: | -----: | -----: | -| tdbin (framed) | 43,788 | 1.00x | 1.00x | 1.00x | -| protobuf | 84,149 | 1.92x | 3.87x | 3.56x | -| msgpack | 90,440 | 2.07x | 4.68x | 4.81x | +| Format | Size (bytes) | Serialize | Deserialize | Round-trip | +| -------------- | -----------: | --------: | ----------: | ---------: | +| tdbin (framed) | 43,788 | 11.637 us | 6.251 us | 17.888 us | +| protobuf | 84,149 | 45.467 us | 31.317 us | 76.784 us | +| msgpack | 90,440 | 43.944 us | 50.870 us | 94.814 us | ### record-heavy diagram document -768 items. Lower is better in every column; **1.00x** marks the winner of that column. +768 items. Lower is better in every column. "Serialize" is the whole ADT→binary conversion, "Deserialize" the whole binary→ADT conversion, and "Round-trip" the two summed. -| Format | Size (bytes) | Size | Encode | Decode | -| -------------- | -----------: | ----: | -----: | -----: | -| tdbin (framed) | 45,172 | 1.00x | 1.00x | 1.00x | -| protobuf | 50,788 | 1.12x | 1.52x | 1.68x | -| msgpack | 77,410 | 1.71x | 3.65x | 1.65x | +| Format | Size (bytes) | Serialize | Deserialize | Round-trip | +| -------------- | -----------: | --------: | ----------: | ---------: | +| tdbin (framed) | 45,172 | 12.340 us | 71.705 us | 84.046 us | +| protobuf | 50,788 | 21.809 us | 110.587 us | 132.396 us | +| msgpack | 77,410 | 55.735 us | 134.250 us | 189.985 us | ### union-heavy event stream -2,048 items. Lower is better in every column; **1.00x** marks the winner of that column. +2,048 items. Lower is better in every column. "Serialize" is the whole ADT→binary conversion, "Deserialize" the whole binary→ADT conversion, and "Round-trip" the two summed. -| Format | Size (bytes) | Size | Encode | Decode | -| -------------- | -----------: | ----: | -----: | -----: | -| tdbin (framed) | 116,372 | 1.00x | 1.00x | 1.00x | -| protobuf | 131,744 | 1.13x | 2.25x | 1.23x | -| msgpack | 230,620 | 1.98x | 4.80x | 1.70x | +| Format | Size (bytes) | Serialize | Deserialize | Round-trip | +| -------------- | -----------: | ---------: | ----------: | ---------: | +| tdbin (framed) | 116,372 | 35.490 us | 201.602 us | 237.093 us | +| protobuf | 131,744 | 66.930 us | 279.422 us | 346.352 us | +| msgpack | 230,620 | 138.809 us | 387.002 us | 525.811 us | ## Methodology - **Same values, three encoders.** Every fixture builds one logical value; the exact same value is fed to the TDBIN codec, to a hand-written Protobuf mirror (`prost`), and — via `serde` derives on that same mirror — to MessagePack (`rmp-serde`). No format gets a different or more favorable input. - **Self-describing modes compared.** TDBIN _framed_, Protobuf, and MessagePack _struct-as-map_ are all self-describing, so the comparison is like-for-like. (TDBIN's smaller _packed_ mode and the tiny single-message stress fixtures appear in the full report, not here.) - **Sizes are exact byte counts** from each encoder — not estimates. -- **Timings are Criterion medians** over 50 samples per operation on the environment below; each measured value flows through `black_box` so the optimizer cannot elide the work. +- **Timings are Criterion medians** over 50 samples per operation on the environment below; each measured value flows through `black_box` so the optimizer cannot elide the work. Round-trip is the encode median plus the decode median. - **Reproduce it yourself** with the commands below; the page and its numbers regenerate together. ### Environment diff --git a/scripts/tdbin-bench-report.mjs b/scripts/tdbin-bench-report.mjs index 0dbf76d..e7befe3 100644 --- a/scripts/tdbin-bench-report.mjs +++ b/scripts/tdbin-bench-report.mjs @@ -125,14 +125,11 @@ const modeRows = sizes.fixtures ) .join("\n"); -// [TDBIN-BENCH-PIVOT] Reader-facing summary: protocol on the Y axis, bench type -// (size / encode speed / decode speed) on the X axis. `tdbin` uses the framed -// wire mode — the self-describing production peer of msgpack (struct-as-map) and -// Protobuf, so all three rows compare like-for-like. The detailed multi-mode -// tables below retain bare/framed/packed and every Criterion statistic. +// [TDBIN-BENCH-PIVOT] The three self-describing formats compared like-for-like. +// `tdbin` uses the framed wire mode — the production peer of msgpack +// (struct-as-map) and Protobuf. The detailed multi-mode tables below retain +// TDBIN bare/framed/packed and every Criterion statistic. const median = (fixture, operation) => estimate(fixture, operation).median_ns; -const sizeVsPb = (bytes, protobuf) => - bytes === protobuf ? "same" : `${bytes < protobuf ? "" : "+"}${percent(bytes, protobuf)}`; const protocols = [ { label: "tdbin (framed)", @@ -143,21 +140,17 @@ const protocols = [ { label: "protobuf", size: (f) => f.protobuf, encode: "protobuf_encode", decode: "protobuf_decode" }, { label: "msgpack", size: (f) => f.msgpack, encode: "msgpack_encode", decode: "msgpack_decode" }, ]; -const protocolTables = sizes.fixtures +// [TDBIN-BENCH-ROUNDTRIP] One row per test: all three formats' sizes, then all +// three formats' serialize/deserialize speeds, straight from the measured data. +const roundTripRows = sizes.fixtures .map((fixture) => { - const rows = protocols - .map((protocol) => { - const bytes = protocol.size(fixture); - return `| ${protocol.label} | ${bytes.toLocaleString()} | ${sizeVsPb(bytes, fixture.protobuf)} | ${duration(median(fixture.name, protocol.encode))} | ${duration(median(fixture.name, protocol.decode))} |`; - }) - .join("\n"); - return `### \`${fixture.name}\` — ${fixture.shape} (${fixture.corpus ? "corpus" : "stress"}, ${fixture.logical_items.toLocaleString()} items) - -| Protocol | Size (bytes) | vs Protobuf | Encode (median) | Decode (median) | -| --- | ---: | ---: | ---: | ---: | -${rows}`; + const size = (p) => p.size(fixture).toLocaleString(); + const enc = (p) => duration(median(fixture.name, p.encode)); + const dec = (p) => duration(median(fixture.name, p.decode)); + const [td, pb, mp] = protocols; + return `| \`${fixture.name}\` | ${size(td)} | ${size(pb)} | ${size(mp)} | ${enc(td)} | ${enc(pb)} | ${enc(mp)} | ${dec(td)} | ${dec(pb)} | ${dec(mp)} |`; }) - .join("\n\n"); + .join("\n"); const passCount = modeRows.split("\n").filter((row) => row.endsWith(" PASS |")).length; const modeQualifies = (fixture, bytes, encodeOp, decodeOp) => @@ -201,11 +194,13 @@ Qualifying modes: ${releaseResults.map((row) => "`" + row.fixture + "` = " + row The release gate ([TDBIN-BENCH-GATE]) requires, for every corpus entry — the committed realistic schemas in \`docs/benchmarks/tdbin-corpus.{td,proto}\` (record-heavy document, union-heavy event stream, list-heavy dataset) — that at least one self-describing production wire mode (framed, or packed framed; the frame's PACKED flag makes the two interchangeable to every decoder) beats Protobuf on size and by ${data.gate.encode_speed_ratio_min.toFixed(2)}x on both encode and decode simultaneously. Both modes are always measured and published below. Stress rows (marked) are reported against the identical bar; the tiny single-message rows carry a fixed 12-byte frame plus pointer-per-string overhead that no fixed-layout format recovers at sub-100-byte payloads (research §2.2), so they are not corpus entries. -## At a Glance +## Size and Speed -One table per fixture. Rows are the three protocols; columns are the bench types (size, encode speed, decode speed). \`tdbin\` is the **framed** wire mode — the self-describing production peer of \`msgpack\` (struct-as-map, via \`rmp-serde\`) and \`protobuf\`. Lower is better everywhere. The full multi-mode breakdown (TDBIN bare/framed/packed and every Criterion statistic) is in the detailed tables further down. +The headline comparison — one row per test, all three self-describing formats side by side. TDBIN is its **framed** production mode; MessagePack is struct-as-map (via \`rmp-serde\`). Sizes are bytes; serialize is the full ADT→binary conversion, deserialize the full binary→ADT conversion. Lower is better everywhere. -${protocolTables} +| Test | typeDiagram Size | Protobuf Size | MessagePack Size | typeDiagram Serialize | Protobuf Serialize | MessagePack Serialize | typeDiagram Deserialize | Protobuf Deserialize | MessagePack Deserialize | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +${roundTripRows} ## Environment @@ -234,6 +229,8 @@ ${sizeRows} ## Criterion Medians +Each row is one **individual** operation — a complete serialize *or* deserialize, not a round-trip. The operation name encodes the direction (\`encode\` = ADT→binary, \`decode\` = binary→ADT) and the wire mode (\`bare\`, \`framed\`, or \`packed_framed\` for TDBIN). "Median" is the per-call time (what to compare); "Sampled time" is only Criterion's total measurement budget for that row. Sum a fixture's \`encode\` and \`decode\` rows to get the round-trip totals above. + | Fixture | Operation | Samples | Sampled time | Median | CI lower | CI upper | | --- | --- | ---: | ---: | ---: | ---: | ---: | ${timingRows} @@ -263,32 +260,26 @@ writeFileSync(reportPath, await format(report, { ...prettierOptions, filepath: r // [TDBIN-BENCH-WEBSITE] Simplified, reader-facing benchmark page for the docs // site. Same measured JSON as the full report — never hand-typed — but reduced -// to the committed corpus workloads and framed to give a non-expert a sense of -// each format's strengths. `x` columns are multiples of the best value in the -// row group (1.00x = winner; higher = larger/slower). +// to the committed corpus workloads: a plain three-way comparison of the raw +// measured size and whole-operation speed for each format, with no deltas or +// ratios. Serialize = the full ADT→binary encode; deserialize = the full +// binary→ADT decode; round-trip = the two summed (encode + decode). const websitePath = join(root, "docs/specs/tdbin-benchmarks.md"); const corpusFixtures = sizes.fixtures.filter((fixture) => fixture.corpus); -const relSize = (bytes, best) => `${(bytes / best).toFixed(2)}x`; -const relTime = (ns, best) => `${(ns / best).toFixed(2)}x`; const websiteTables = corpusFixtures .map((fixture) => { - const sizeValues = protocols.map((p) => p.size(fixture)); - const encodeValues = protocols.map((p) => median(fixture.name, p.encode)); - const decodeValues = protocols.map((p) => median(fixture.name, p.decode)); - const bestSize = Math.min(...sizeValues); - const bestEncode = Math.min(...encodeValues); - const bestDecode = Math.min(...decodeValues); const rows = protocols - .map((p, i) => { - const bytes = sizeValues[i]; - return `| ${p.label} | ${bytes.toLocaleString()} | ${relSize(bytes, bestSize)} | ${relTime(encodeValues[i], bestEncode)} | ${relTime(decodeValues[i], bestDecode)} |`; + .map((p) => { + const serialize = median(fixture.name, p.encode); + const deserialize = median(fixture.name, p.decode); + return `| ${p.label} | ${p.size(fixture).toLocaleString()} | ${duration(serialize)} | ${duration(deserialize)} | ${duration(serialize + deserialize)} |`; }) .join("\n"); return `### ${fixture.shape} -${fixture.logical_items.toLocaleString()} items. Lower is better in every column; **1.00x** marks the winner of that column. +${fixture.logical_items.toLocaleString()} items. Lower is better in every column. "Serialize" is the whole ADT→binary conversion, "Deserialize" the whole binary→ADT conversion, and "Round-trip" the two summed. -| Format | Size (bytes) | Size | Encode | Decode | +| Format | Size (bytes) | Serialize | Deserialize | Round-trip | | --- | ---: | ---: | ---: | ---: | ${rows}`; }) @@ -296,19 +287,19 @@ ${rows}`; const website = `# TDBIN Benchmarks -TDBIN is the compact binary codec typeDiagram generates for algebraic data types. This page compares it against two widely used serialization formats — **Protocol Buffers** and **MessagePack** — on realistic workloads, so you can judge what the format buys you. +TDBIN is typeDiagram's compact binary codec for algebraic data types, measured here against **Protocol Buffers** and **MessagePack** on realistic workloads. Every number is data-derived — produced by \`scripts/tdbin-bench-report.mjs\` from Criterion timings and exact encoder output — and regenerates on each benchmark run. + +## The three formats -Numbers below are measured, not estimated: they are produced by \`scripts/tdbin-bench-report.mjs\` from Criterion timings and exact encoder output, and regenerate whenever the benchmark runs. For the full breakdown — all wire modes, every fixture, confidence intervals — see the generated benchmark report at \`docs/reports/tdbin-bench-report.md\`. +**TDBIN (framed)** is a self-describing frame around typeDiagram's columnar layout; it drops the per-field tags the others repeat on every record, so on batches of the same shape — telemetry, event streams, repeated records — it is both the smallest and the fastest here, and it is the format to reach for when you control both ends and your data is list- or record-heavy. -## What each format is for +**Protocol Buffers** is schema-driven with a tag per field, extremely compact and fast on small sparse messages and the industry default for cross-language RPC over a shared \`.proto\`, so reach for it when messages are small, schemas are shared, and you need the broadest ecosystem. -- **TDBIN (framed)** — a self-describing frame around typeDiagram's columnar layout. It shines on *batches of the same shape* (telemetry rows, event streams, repeated records): the columnar layout lets it skip the per-field tags every other format repeats, so it is both the smallest and the fastest here. Reach for it when you control both ends and your data is list- or record-heavy. -- **Protocol Buffers** — schema-driven, tag-per-field. Extremely compact and fast on *small, sparse messages* (a single record with optional fields), and the industry default for cross-language RPC with a shared \`.proto\`. Reach for it when messages are small, schemas are shared, and you need the broadest ecosystem. -- **MessagePack** — schemaless, self-describing (\`struct-as-map\`, via \`rmp-serde\`). It carries field names on the wire, so it is the largest and slowest of the three, but it needs *no schema at all* and any language can read it. Reach for it for loosely-typed interchange, config blobs, or when a schema is impractical. +**MessagePack** is schemaless and self-describing (\`struct-as-map\`, via \`rmp-serde\`); it carries field names on the wire so it is the largest and slowest of the three, but it needs no schema at all and any language can read it, which makes it the pick for loosely-typed interchange, config blobs, or wherever a schema is impractical. ## Results -The tables use typeDiagram's committed **corpus** workloads — the realistic schemas the release gate is defined on. TDBIN is shown in its **framed** production mode, the self-describing peer of the other two. +Each table is one committed **corpus** workload. Rows are the three formats; columns are the encoded size and the whole-operation timings. TDBIN is shown in its **framed** production mode — the self-describing peer of the other two. ${websiteTables} @@ -317,7 +308,7 @@ ${websiteTables} - **Same values, three encoders.** Every fixture builds one logical value; the exact same value is fed to the TDBIN codec, to a hand-written Protobuf mirror (\`prost\`), and — via \`serde\` derives on that same mirror — to MessagePack (\`rmp-serde\`). No format gets a different or more favorable input. - **Self-describing modes compared.** TDBIN *framed*, Protobuf, and MessagePack *struct-as-map* are all self-describing, so the comparison is like-for-like. (TDBIN's smaller *packed* mode and the tiny single-message stress fixtures appear in the full report, not here.) - **Sizes are exact byte counts** from each encoder — not estimates. -- **Timings are Criterion medians** over ${data.benchmarks[0]?.sample_count ?? 50} samples per operation on the environment below; each measured value flows through \`black_box\` so the optimizer cannot elide the work. +- **Timings are Criterion medians** over ${data.benchmarks[0]?.sample_count ?? 50} samples per operation on the environment below; each measured value flows through \`black_box\` so the optimizer cannot elide the work. Round-trip is the encode median plus the decode median. - **Reproduce it yourself** with the commands below; the page and its numbers regenerate together. ### Environment From 2bd27637dbc0c398063ef3106860852e03c28833 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:07:35 +1000 Subject: [PATCH 08/14] Bench --- docs/reports/tdbin-bench-data.json | 2 +- docs/reports/tdbin-bench-report.md | 4 +- docs/specs/tdbin-benchmarks.md | 87 +++++++++++++--------------- scripts/tdbin-bench-report.mjs | 91 +++++++++++++++++++----------- 4 files changed, 99 insertions(+), 85 deletions(-) diff --git a/docs/reports/tdbin-bench-data.json b/docs/reports/tdbin-bench-data.json index 386c588..584b14a 100644 --- a/docs/reports/tdbin-bench-data.json +++ b/docs/reports/tdbin-bench-data.json @@ -1,6 +1,6 @@ { "format_version": 1, - "generated_at": "2026-07-13T22:00:16.855Z", + "generated_at": "2026-07-13T22:05:40.298Z", "commands": ["cargo bench -p tdbin --bench gate -- --noplot", "node scripts/tdbin-bench-report.mjs"], "corpus_schemas": ["docs/benchmarks/tdbin-corpus.td", "docs/benchmarks/tdbin-corpus.proto"], "gate": { diff --git a/docs/reports/tdbin-bench-report.md b/docs/reports/tdbin-bench-report.md index 60da3e3..80dec58 100644 --- a/docs/reports/tdbin-bench-report.md +++ b/docs/reports/tdbin-bench-report.md @@ -3,9 +3,9 @@ > GENERATED FILE. Source: `scripts/tdbin-bench-report.mjs` and `docs/reports/tdbin-bench-data.json`. > Every value and verdict is computed from machine-readable Criterion and encoder output. No benchmark result is entered manually. -Generated: 2026-07-13T22:00:16.855Z +Generated: 2026-07-13T22:05:40.298Z -Raw data SHA-256: `134d351db228a6f2338f73f6682333eaa671b8aa6d688c8c074285126819f5fe` +Raw data SHA-256: `c8d15f8911c0ec857d5e9d4c8950fd8b01095e8c22ba5828b57c8308666000c0` ## Result diff --git a/docs/specs/tdbin-benchmarks.md b/docs/specs/tdbin-benchmarks.md index 2a082a5..6f1c3ce 100644 --- a/docs/specs/tdbin-benchmarks.md +++ b/docs/specs/tdbin-benchmarks.md @@ -1,66 +1,55 @@ # TDBIN Benchmarks -TDBIN is typeDiagram's compact binary codec for algebraic data types, measured here against **Protocol Buffers** and **MessagePack** on realistic workloads. Every number is data-derived — produced by `scripts/tdbin-bench-report.mjs` from Criterion timings and exact encoder output — and regenerates on each benchmark run. +TDBIN is typeDiagram's compact binary codec for algebraic data types, measured here against **Protocol Buffers** and **MessagePack**. Every number below is data-derived — produced by [`scripts/tdbin-bench-report.mjs`](https://github.com/Nimblesite/typeDiagram/blob/main/scripts/tdbin-bench-report.mjs) from Criterion timings and exact encoder output — and regenerates on each benchmark run. -## The three formats +## Size and Speed -**TDBIN (framed)** is a self-describing frame around typeDiagram's columnar layout; it drops the per-field tags the others repeat on every record, so on batches of the same shape — telemetry, event streams, repeated records — it is both the smallest and the fastest here, and it is the format to reach for when you control both ends and your data is list- or record-heavy. +One row per test. Sizes are exact encoded bytes; "Serialize" is the whole ADT→binary conversion and "Deserialize" the whole binary→ADT conversion, each a Criterion median. typeDiagram is its **framed** production wire mode; MessagePack is struct-as-map (via `rmp-serde`). Lower is better in every column. -**Protocol Buffers** is schema-driven with a tag per field, extremely compact and fast on small sparse messages and the industry default for cross-language RPC over a shared `.proto`, so reach for it when messages are small, schemas are shared, and you need the broadest ecosystem. +| Test | typeDiagram Size | Protobuf Size | MessagePack Size | typeDiagram Serialize | Protobuf Serialize | MessagePack Serialize | typeDiagram Deserialize | Protobuf Deserialize | MessagePack Deserialize | +| ------------------ | ---------------: | ------------: | ---------------: | --------------------: | -----------------: | --------------------: | ----------------------: | -------------------: | ----------------------: | +| `with_address` | 172 | 79 | 142 | 87.51 ns | 46.84 ns | 252.33 ns | 153.21 ns | 152.49 ns | 195.31 ns | +| `without_address` | 124 | 31 | 100 | 65.76 ns | 32.68 ns | 228.84 ns | 71.52 ns | 50.66 ns | 109.68 ns | +| `metric_batch` | 43,788 | 84,149 | 90,440 | 11.637 us | 45.467 us | 43.944 us | 6.251 us | 31.317 us | 50.870 us | +| `person_batch` | 22,988 | 29,184 | 61,963 | 11.587 us | 18.195 us | 48.224 us | 36.786 us | 57.952 us | 87.828 us | +| `contact_batch` | 23,156 | 35,221 | 80,162 | 9.025 us | 24.957 us | 69.630 us | 31.045 us | 61.434 us | 95.623 us | +| `diagram_document` | 45,172 | 50,788 | 77,410 | 12.340 us | 21.809 us | 55.735 us | 71.705 us | 110.587 us | 134.250 us | +| `event_batch` | 116,372 | 131,744 | 230,620 | 35.490 us | 66.930 us | 138.809 us | 201.602 us | 279.422 us | 387.002 us | -**MessagePack** is schemaless and self-describing (`struct-as-map`, via `rmp-serde`); it carries field names on the wire so it is the largest and slowest of the three, but it needs no schema at all and any language can read it, which makes it the pick for loosely-typed interchange, config blobs, or wherever a schema is impractical. +## What the numbers show -## Results +The following are read directly off the table above — each is a comparison of the measured values, nothing more: -Each table is one committed **corpus** workload. Rows are the three formats; columns are the encoded size and the whole-operation timings. TDBIN is shown in its **framed** production mode — the self-describing peer of the other two. - -### list-heavy telemetry - -4,096 items. Lower is better in every column. "Serialize" is the whole ADT→binary conversion, "Deserialize" the whole binary→ADT conversion, and "Round-trip" the two summed. - -| Format | Size (bytes) | Serialize | Deserialize | Round-trip | -| -------------- | -----------: | --------: | ----------: | ---------: | -| tdbin (framed) | 43,788 | 11.637 us | 6.251 us | 17.888 us | -| protobuf | 84,149 | 45.467 us | 31.317 us | 76.784 us | -| msgpack | 90,440 | 43.944 us | 50.870 us | 94.814 us | - -### record-heavy diagram document - -768 items. Lower is better in every column. "Serialize" is the whole ADT→binary conversion, "Deserialize" the whole binary→ADT conversion, and "Round-trip" the two summed. - -| Format | Size (bytes) | Serialize | Deserialize | Round-trip | -| -------------- | -----------: | --------: | ----------: | ---------: | -| tdbin (framed) | 45,172 | 12.340 us | 71.705 us | 84.046 us | -| protobuf | 50,788 | 21.809 us | 110.587 us | 132.396 us | -| msgpack | 77,410 | 55.735 us | 134.250 us | 189.985 us | - -### union-heavy event stream - -2,048 items. Lower is better in every column. "Serialize" is the whole ADT→binary conversion, "Deserialize" the whole binary→ADT conversion, and "Round-trip" the two summed. - -| Format | Size (bytes) | Serialize | Deserialize | Round-trip | -| -------------- | -----------: | ---------: | ----------: | ---------: | -| tdbin (framed) | 116,372 | 35.490 us | 201.602 us | 237.093 us | -| protobuf | 131,744 | 66.930 us | 279.422 us | 346.352 us | -| msgpack | 230,620 | 138.809 us | 387.002 us | 525.811 us | +- **`with_address`** (tiny nested record and union, 1 item): smallest encoding is **Protobuf**; fastest round-trip is **Protobuf**. typeDiagram's encoding is 0.5× the size of Protobuf and 0.8× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`without_address`** (tiny sparse record and union, 1 item): smallest encoding is **Protobuf**; fastest round-trip is **Protobuf**. typeDiagram's encoding is 0.3× the size of Protobuf and 0.8× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`metric_batch`** (list-heavy telemetry, 4,096 items): smallest encoding is **typeDiagram**; fastest round-trip is **typeDiagram**. typeDiagram's encoding is 1.9× the size of Protobuf and 2.1× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`person_batch`** (repeated records, 512 items): smallest encoding is **typeDiagram**; fastest round-trip is **typeDiagram**. typeDiagram's encoding is 1.3× the size of Protobuf and 2.7× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`contact_batch`** (repeated unions, 2,048 items): smallest encoding is **typeDiagram**; fastest round-trip is **typeDiagram**. typeDiagram's encoding is 1.5× the size of Protobuf and 3.5× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`diagram_document`** (record-heavy diagram document, 768 items): smallest encoding is **typeDiagram**; fastest round-trip is **typeDiagram**. typeDiagram's encoding is 1.1× the size of Protobuf and 1.7× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). +- **`event_batch`** (union-heavy event stream, 2,048 items): smallest encoding is **typeDiagram**; fastest round-trip is **typeDiagram**. typeDiagram's encoding is 1.1× the size of Protobuf and 2× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger). ## Methodology -- **Same values, three encoders.** Every fixture builds one logical value; the exact same value is fed to the TDBIN codec, to a hand-written Protobuf mirror (`prost`), and — via `serde` derives on that same mirror — to MessagePack (`rmp-serde`). No format gets a different or more favorable input. -- **Self-describing modes compared.** TDBIN _framed_, Protobuf, and MessagePack _struct-as-map_ are all self-describing, so the comparison is like-for-like. (TDBIN's smaller _packed_ mode and the tiny single-message stress fixtures appear in the full report, not here.) -- **Sizes are exact byte counts** from each encoder — not estimates. -- **Timings are Criterion medians** over 50 samples per operation on the environment below; each measured value flows through `black_box` so the optimizer cannot elide the work. Round-trip is the encode median plus the decode median. -- **Reproduce it yourself** with the commands below; the page and its numbers regenerate together. +- **Same values, three encoders.** Every test builds one logical value and feeds the identical value to the typeDiagram codec, to a hand-written Protobuf mirror (`prost`), and — via `serde` derives on that same mirror — to MessagePack (`rmp-serde`). No format receives a different input. See the fixtures in [`crates/tdbin/tests/support/bench_corpus.rs`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin/tests/support/bench_corpus.rs). +- **Self-describing modes only.** typeDiagram _framed_, Protobuf, and MessagePack _struct-as-map_ all carry enough structure to be decoded without an external schema, so the comparison is like-for-like. +- **Sizes are exact byte counts** emitted by each encoder (see [`crates/tdbin/examples/bench_data.rs`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin/examples/bench_data.rs)) — not estimates. +- **Timings are Criterion medians** over 50 samples per operation; each measured value flows through `black_box` so the optimizer cannot elide the work. The benchmark harness is [`crates/tdbin/benches/gate.rs`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin/benches/gate.rs). +- **Corpus schemas** are committed at [`docs/benchmarks/tdbin-corpus.td`](https://github.com/Nimblesite/typeDiagram/blob/main/docs/benchmarks/tdbin-corpus.td) and [`docs/benchmarks/tdbin-corpus.proto`](https://github.com/Nimblesite/typeDiagram/blob/main/docs/benchmarks/tdbin-corpus.proto). + +## Test machine -### Environment +| Field | Value | +| ------------ | ----------------------------------- | +| Platform | darwin 25.5.0 (arm64) | +| CPU | Apple M4 Max | +| Logical CPUs | 14 | +| Memory | 36.0 GiB | +| Rust | rustc 1.97.0 (2d8144b78 2026-07-07) | +| Cargo | cargo 1.97.0 (c980f4866 2026-06-30) | -| Field | Value | -| -------- | ----------------------------------- | -| Platform | darwin 25.5.0 (arm64) | -| CPU | Apple M4 Max | -| Rust | rustc 1.97.0 (2d8144b78 2026-07-07) | +## Reproduce -### Reproduce +Run the benchmark, then regenerate this page: - `cargo bench -p tdbin --bench gate -- --noplot` - `node scripts/tdbin-bench-report.mjs` diff --git a/scripts/tdbin-bench-report.mjs b/scripts/tdbin-bench-report.mjs index e7befe3..fd6d9b0 100644 --- a/scripts/tdbin-bench-report.mjs +++ b/scripts/tdbin-bench-report.mjs @@ -265,61 +265,86 @@ writeFileSync(reportPath, await format(report, { ...prettierOptions, filepath: r // ratios. Serialize = the full ADT→binary encode; deserialize = the full // binary→ADT decode; round-trip = the two summed (encode + decode). const websitePath = join(root, "docs/specs/tdbin-benchmarks.md"); -const corpusFixtures = sizes.fixtures.filter((fixture) => fixture.corpus); -const websiteTables = corpusFixtures +const repo = "https://github.com/Nimblesite/typeDiagram"; +const ref = "blob/main"; + +// [TDBIN-BENCH-WEBSITE-FACTS] Story lines derived strictly from the measured +// data — every claim is a comparison the table already prints, never a guess. +const round1 = (x) => Math.round(x * 10) / 10; +const compareSize = (fixture) => ({ + tdVsPb: round1(fixture.protobuf / fixture.tdbin_framed), + tdVsMp: round1(fixture.msgpack / fixture.tdbin_framed), +}); +const roundTrip = (fixture, p) => median(fixture.name, p.encode) + median(fixture.name, p.decode); +const [td, pb, mp] = protocols; +const factLines = sizes.fixtures.map((fixture) => { + const s = compareSize(fixture); + const rtTd = roundTrip(fixture, td); + const rtPb = roundTrip(fixture, pb); + const rtMp = roundTrip(fixture, mp); + const smallest = [ + ["typeDiagram", fixture.tdbin_framed], + ["Protobuf", fixture.protobuf], + ["MessagePack", fixture.msgpack], + ].sort((a, b) => a[1] - b[1])[0][0]; + const fastest = [ + ["typeDiagram", rtTd], + ["Protobuf", rtPb], + ["MessagePack", rtMp], + ].sort((a, b) => a[1] - b[1])[0][0]; + const items = `${fixture.logical_items.toLocaleString()} item${fixture.logical_items === 1 ? "" : "s"}`; + return `- **\`${fixture.name}\`** (${fixture.shape}, ${items}): smallest encoding is **${smallest}**; fastest round-trip is **${fastest}**. typeDiagram's encoding is ${s.tdVsPb}× the size of Protobuf and ${s.tdVsMp}× the size of MessagePack here (values <1 mean typeDiagram is smaller, >1 larger).`; +}); + +const mainRows = sizes.fixtures .map((fixture) => { - const rows = protocols - .map((p) => { - const serialize = median(fixture.name, p.encode); - const deserialize = median(fixture.name, p.decode); - return `| ${p.label} | ${p.size(fixture).toLocaleString()} | ${duration(serialize)} | ${duration(deserialize)} | ${duration(serialize + deserialize)} |`; - }) - .join("\n"); - return `### ${fixture.shape} - -${fixture.logical_items.toLocaleString()} items. Lower is better in every column. "Serialize" is the whole ADT→binary conversion, "Deserialize" the whole binary→ADT conversion, and "Round-trip" the two summed. - -| Format | Size (bytes) | Serialize | Deserialize | Round-trip | -| --- | ---: | ---: | ---: | ---: | -${rows}`; + const size = (p) => p.size(fixture).toLocaleString(); + const enc = (p) => duration(median(fixture.name, p.encode)); + const dec = (p) => duration(median(fixture.name, p.decode)); + return `| \`${fixture.name}\` | ${size(td)} | ${size(pb)} | ${size(mp)} | ${enc(td)} | ${enc(pb)} | ${enc(mp)} | ${dec(td)} | ${dec(pb)} | ${dec(mp)} |`; }) - .join("\n\n"); + .join("\n"); const website = `# TDBIN Benchmarks -TDBIN is typeDiagram's compact binary codec for algebraic data types, measured here against **Protocol Buffers** and **MessagePack** on realistic workloads. Every number is data-derived — produced by \`scripts/tdbin-bench-report.mjs\` from Criterion timings and exact encoder output — and regenerates on each benchmark run. +TDBIN is typeDiagram's compact binary codec for algebraic data types, measured here against **Protocol Buffers** and **MessagePack**. Every number below is data-derived — produced by [\`scripts/tdbin-bench-report.mjs\`](${repo}/${ref}/scripts/tdbin-bench-report.mjs) from Criterion timings and exact encoder output — and regenerates on each benchmark run. -## The three formats - -**TDBIN (framed)** is a self-describing frame around typeDiagram's columnar layout; it drops the per-field tags the others repeat on every record, so on batches of the same shape — telemetry, event streams, repeated records — it is both the smallest and the fastest here, and it is the format to reach for when you control both ends and your data is list- or record-heavy. +## Size and Speed -**Protocol Buffers** is schema-driven with a tag per field, extremely compact and fast on small sparse messages and the industry default for cross-language RPC over a shared \`.proto\`, so reach for it when messages are small, schemas are shared, and you need the broadest ecosystem. +One row per test. Sizes are exact encoded bytes; "Serialize" is the whole ADT→binary conversion and "Deserialize" the whole binary→ADT conversion, each a Criterion median. typeDiagram is its **framed** production wire mode; MessagePack is struct-as-map (via \`rmp-serde\`). Lower is better in every column. -**MessagePack** is schemaless and self-describing (\`struct-as-map\`, via \`rmp-serde\`); it carries field names on the wire so it is the largest and slowest of the three, but it needs no schema at all and any language can read it, which makes it the pick for loosely-typed interchange, config blobs, or wherever a schema is impractical. +| Test | typeDiagram Size | Protobuf Size | MessagePack Size | typeDiagram Serialize | Protobuf Serialize | MessagePack Serialize | typeDiagram Deserialize | Protobuf Deserialize | MessagePack Deserialize | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +${mainRows} -## Results +## What the numbers show -Each table is one committed **corpus** workload. Rows are the three formats; columns are the encoded size and the whole-operation timings. TDBIN is shown in its **framed** production mode — the self-describing peer of the other two. +The following are read directly off the table above — each is a comparison of the measured values, nothing more: -${websiteTables} +${factLines.join("\n")} ## Methodology -- **Same values, three encoders.** Every fixture builds one logical value; the exact same value is fed to the TDBIN codec, to a hand-written Protobuf mirror (\`prost\`), and — via \`serde\` derives on that same mirror — to MessagePack (\`rmp-serde\`). No format gets a different or more favorable input. -- **Self-describing modes compared.** TDBIN *framed*, Protobuf, and MessagePack *struct-as-map* are all self-describing, so the comparison is like-for-like. (TDBIN's smaller *packed* mode and the tiny single-message stress fixtures appear in the full report, not here.) -- **Sizes are exact byte counts** from each encoder — not estimates. -- **Timings are Criterion medians** over ${data.benchmarks[0]?.sample_count ?? 50} samples per operation on the environment below; each measured value flows through \`black_box\` so the optimizer cannot elide the work. Round-trip is the encode median plus the decode median. -- **Reproduce it yourself** with the commands below; the page and its numbers regenerate together. +- **Same values, three encoders.** Every test builds one logical value and feeds the identical value to the typeDiagram codec, to a hand-written Protobuf mirror (\`prost\`), and — via \`serde\` derives on that same mirror — to MessagePack (\`rmp-serde\`). No format receives a different input. See the fixtures in [\`crates/tdbin/tests/support/bench_corpus.rs\`](${repo}/${ref}/crates/tdbin/tests/support/bench_corpus.rs). +- **Self-describing modes only.** typeDiagram *framed*, Protobuf, and MessagePack *struct-as-map* all carry enough structure to be decoded without an external schema, so the comparison is like-for-like. +- **Sizes are exact byte counts** emitted by each encoder (see [\`crates/tdbin/examples/bench_data.rs\`](${repo}/${ref}/crates/tdbin/examples/bench_data.rs)) — not estimates. +- **Timings are Criterion medians** over ${data.benchmarks[0]?.sample_count ?? 50} samples per operation; each measured value flows through \`black_box\` so the optimizer cannot elide the work. The benchmark harness is [\`crates/tdbin/benches/gate.rs\`](${repo}/${ref}/crates/tdbin/benches/gate.rs). +- **Corpus schemas** are committed at [\`docs/benchmarks/tdbin-corpus.td\`](${repo}/${ref}/docs/benchmarks/tdbin-corpus.td) and [\`docs/benchmarks/tdbin-corpus.proto\`](${repo}/${ref}/docs/benchmarks/tdbin-corpus.proto). -### Environment +## Test machine | Field | Value | | --- | --- | | Platform | ${data.environment.platform} ${data.environment.release} (${data.environment.architecture}) | | CPU | ${data.environment.cpu} | +| Logical CPUs | ${data.environment.logical_cpus} | +| Memory | ${(data.environment.memory_bytes / 1_073_741_824).toFixed(1)} GiB | | Rust | ${data.environment.rustc} | +| Cargo | ${data.environment.cargo} | + +## Reproduce -### Reproduce +Run the benchmark, then regenerate this page: ${data.commands.map((command) => `- \`${command}\``).join("\n")} `; From 234c4cc177706958f305c7f7c3ccf604ccccd371 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:26:20 +1000 Subject: [PATCH 09/14] Fixes --- crates/tdbin/benches/gate.rs | 10 +- crates/tdbin/src/intblock.rs | 123 +++++++++------- crates/tdbin/src/pack.rs | 132 +++++++----------- crates/tdbin/src/reader_lists.rs | 11 +- crates/tdbin/src/writer.rs | 41 ++++-- crates/tdbin/tests/support/batch_corpus.rs | 12 +- crates/tdbin/tests/support/bench_corpus.rs | 21 +-- crates/tdbin/tests/support/document_corpus.rs | 15 +- crates/tdbin/tests/support/event_corpus.rs | 27 ++-- docs/reports/tdbin-bench-data.json | 2 +- docs/reports/tdbin-bench-report.md | 4 +- docs/specs/tdbin-benchmarks.md | 3 + scripts/tdbin-bench-report.mjs | 3 + 13 files changed, 199 insertions(+), 205 deletions(-) diff --git a/crates/tdbin/benches/gate.rs b/crates/tdbin/benches/gate.rs index 120e870..0397854 100644 --- a/crates/tdbin/benches/gate.rs +++ b/crates/tdbin/benches/gate.rs @@ -120,13 +120,9 @@ where }); }, ); - let _ = group.bench_with_input( - BenchmarkId::new("msgpack_encode", label), - pb, - |b, value| { - b.iter(|| rmp_serde::to_vec_named(black_box(value))); - }, - ); + let _ = group.bench_with_input(BenchmarkId::new("msgpack_encode", label), pb, |b, value| { + b.iter(|| rmp_serde::to_vec_named(black_box(value))); + }); let _ = group.bench_with_input( BenchmarkId::new("tdbin_decode_bare", label), &bare, diff --git a/crates/tdbin/src/intblock.rs b/crates/tdbin/src/intblock.rs index 255d03e..f5ac8e7 100644 --- a/crates/tdbin/src/intblock.rs +++ b/crates/tdbin/src/intblock.rs @@ -36,22 +36,27 @@ fn unzigzag(value: u64) -> i64 { pub(crate) fn encode(values: &[i64]) -> Result, EncodeError> { let count = u32::try_from(values.len()).map_err(|_| EncodeError::LimitExceeded)?; let first = values.first().copied().unwrap_or_default(); - let deltas = zigzag_deltas(values); - let floor = deltas.iter().copied().min().unwrap_or_default(); - let width = deltas - .iter() - .map(|delta| WORD_BITS.wrapping_sub(delta.wrapping_sub(floor).leading_zeros())) - .max() - .unwrap_or_default(); + let (floor, width) = delta_stats(values); let mut out = Vec::with_capacity(block_len(values.len(), width)?); out.extend_from_slice(&count.to_le_bytes()); out.extend_from_slice(&first.to_le_bytes()); out.extend_from_slice(&floor.to_le_bytes()); out.push(u8::try_from(width).map_err(|_| EncodeError::LimitExceeded)?); - pack_bits(&deltas, floor, width, &mut out); + pack_deltas(values, floor, width, &mut out); Ok(out) } +/// The minimum zigzag delta and the minimal bit width covering the range, +/// computed in two streaming passes with no intermediate buffer. +fn delta_stats(values: &[i64]) -> (u64, u32) { + let floor = deltas(values).min().unwrap_or_default(); + let width = deltas(values) + .map(|delta| WORD_BITS.wrapping_sub(delta.wrapping_sub(floor).leading_zeros())) + .max() + .unwrap_or_default(); + (floor, width) +} + /// Read a block's declared logical count without decoding it. /// /// # Errors @@ -103,40 +108,38 @@ pub(crate) fn decode(block: &[u8]) -> Result, DecodeError> { } /// Zigzagged wrapping deltas between consecutive values. -fn zigzag_deltas(values: &[i64]) -> Vec { - values - .windows(2) - .map(|pair| { - let previous = pair.first().copied().unwrap_or_default(); - let next = pair.get(1).copied().unwrap_or_default(); - zigzag(next.wrapping_sub(previous)) - }) - .collect() -} - -/// Append `width`-bit values (relative to `floor`) as a little-endian stream. -fn pack_bits(deltas: &[u64], floor: u64, width: u32, out: &mut Vec) { +fn deltas(values: &[i64]) -> impl Iterator + '_ { + values.windows(2).map(|pair| { + let previous = pair.first().copied().unwrap_or_default(); + let next = pair.get(1).copied().unwrap_or_default(); + zigzag(next.wrapping_sub(previous)) + }) +} + +/// Append `width`-bit deltas (relative to `floor`) as a little-endian stream, +/// flushing whole 64-bit words instead of single bytes. +fn pack_deltas(values: &[i64], floor: u64, width: u32, out: &mut Vec) { let mut acc = 0_u64; let mut filled = 0_u32; - for delta in deltas { - let mut value = delta.wrapping_sub(floor); - let mut remaining = width; - while remaining > 0 { - let take = remaining.min(WORD_BITS.wrapping_sub(filled)); - acc |= (value & mask_of(take)).wrapping_shl(filled); - filled = filled.wrapping_add(take); - value = shr_full(value, take); - remaining = remaining.wrapping_sub(take); - while filled >= 8 { - out.push(u8::try_from(acc & 0xFF).unwrap_or(0)); - acc = acc.wrapping_shr(8); - filled = filled.wrapping_sub(8); + for delta in deltas(values) { + let value = delta.wrapping_sub(floor) & mask_of(width); + acc |= value.wrapping_shl(filled); + match filled.checked_add(width) { + Some(next) if next < WORD_BITS => filled = next, + _ => { + out.extend_from_slice(&acc.to_le_bytes()); + acc = shr_full(value, WORD_BITS.wrapping_sub(filled)); + filled = filled.wrapping_add(width).wrapping_sub(WORD_BITS); } } } - if filled > 0 { - out.push(u8::try_from(acc & 0xFF).unwrap_or(0)); - } + flush_partial(acc, filled, out); +} + +/// Flush the trailing partial word: only the bytes carrying `filled` bits. +fn flush_partial(acc: u64, filled: u32, out: &mut Vec) { + let bytes = usize::try_from(filled.div_ceil(8)).unwrap_or(8); + out.extend_from_slice(acc.to_le_bytes().get(..bytes).unwrap_or_default()); } /// Shift right by up to 64 bits (a 64-bit shift yields zero). @@ -161,25 +164,43 @@ impl<'a> BitReader<'a> { Self { bytes, bit: 0 } } - /// Read the next `width` bits (little-endian), zero-padded past the end. + /// Read the next `width` bits (little-endian), zero-padded past the end: + /// one unaligned word load covers every width up to 57 bits, and a second + /// load stitches the rare wide read that crosses the first word's end. fn read(&mut self, width: u32) -> u64 { - let mut value = 0_u64; - let mut got = 0_u32; - while got < width { - let byte_index = self.bit / 8; - let bit_offset = u32::try_from(self.bit % 8).unwrap_or(0); - let available = 8_u32.wrapping_sub(bit_offset); - let take = available.min(width.wrapping_sub(got)); - let byte = u64::from(self.bytes.get(byte_index).copied().unwrap_or(0)); - let mask = mask_of(take); - value |= (byte.wrapping_shr(bit_offset) & mask).wrapping_shl(got); - got = got.wrapping_add(take); - self.bit = self.bit.wrapping_add(usize::try_from(take).unwrap_or(0)); - } + let index = self.bit / 8; + let shift = u32::try_from(self.bit % 8).unwrap_or(0); + let have = WORD_BITS.wrapping_sub(shift); + let lo = load_padded(self.bytes, index).wrapping_shr(shift); + let value = if width <= have { + lo & mask_of(width) + } else { + let hi = load_padded(self.bytes, index.wrapping_add(8)); + (lo | hi.wrapping_shl(have)) & mask_of(width) + }; + self.bit = self.bit.wrapping_add(usize::try_from(width).unwrap_or(0)); value } } +/// Load the little-endian word at byte `index`, zero-padded past the end. +fn load_padded(bytes: &[u8], index: usize) -> u64 { + bytes + .get(index..index.wrapping_add(8)) + .and_then(|slice| <[u8; 8]>::try_from(slice).ok()) + .map_or_else(|| tail_word(bytes, index), u64::from_le_bytes) +} + +/// Little-endian fold of the partial tail starting at `index`. +fn tail_word(bytes: &[u8], index: usize) -> u64 { + bytes + .get(index..) + .unwrap_or_default() + .iter() + .rev() + .fold(0_u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte)) +} + /// Read a little-endian u64 at `offset`. fn read_u64(block: &[u8], offset: usize) -> Result { block diff --git a/crates/tdbin/src/pack.rs b/crates/tdbin/src/pack.rs index 6101bc2..d9a5096 100644 --- a/crates/tdbin/src/pack.rs +++ b/crates/tdbin/src/pack.rs @@ -57,17 +57,11 @@ pub fn encode_into(body: &[u8], out: &mut Vec) -> Result<(), EncodeError> { .is_multiple_of(WORD_BYTES) .then_some(()) .ok_or(EncodeError::BadLength)?; - let base = out.len(); - let worst = base - .checked_add(encode_capacity(body.len())?) - .ok_or(EncodeError::LimitExceeded)?; - out.resize(worst, 0); + out.reserve(encode_capacity(body.len())?); let mut offset = 0; - let mut cursor = base; while let Some(word) = read_word(body, offset) { - (offset, cursor) = encode_word(body, offset, word, out, cursor)?; + offset = encode_word(body, offset, word, out)?; } - out.truncate(cursor); Ok(()) } @@ -142,20 +136,20 @@ fn decode_chunk( Ok((cursor, written)) } -/// Branchless sparse expansion: a constant eight-lane pass selects each -/// output byte from the compacted source, so the loop never carries a -/// data-dependent branch (the variable-trip alternative mispredicts once per -/// word on mixed tags). +/// Branchless sparse expansion: each lane independently locates its source +/// byte by prefix-popcount, so the eight lanes carry no data-dependent branch +/// and no serial register dependency (the previous shift-chain version +/// serialized every lane on the compacted source register). fn expand_word(tag: u8, src: u64) -> u64 { let mut word = 0_u64; - let mut rest = src; let mut lane = 0_u32; while lane < 8 { - let take = (u64::from(tag) >> lane) & 1; - word |= (rest & 0xFF) - .wrapping_mul(take) + let present = u64::from(tag).wrapping_shr(lane) & 1; + let below = u32::from(tag) & 1_u32.wrapping_shl(lane).wrapping_sub(1); + let byte = src.wrapping_shr(below.count_ones().wrapping_mul(8)) & 0xFF; + word |= byte + .wrapping_mul(present) .wrapping_shl(lane.wrapping_mul(8)); - rest = rest.wrapping_shr(u32::try_from(take.wrapping_mul(8)).unwrap_or(0)); lane = lane.wrapping_add(1); } word @@ -169,39 +163,30 @@ fn encode_capacity(body_len: usize) -> Result { .ok_or(EncodeError::LimitExceeded) } -/// Encode one word, returning the next input offset and output cursor. +/// Encode one word, appending to `out` and returning the next input offset. fn encode_word( body: &[u8], offset: usize, word: u64, - out: &mut [u8], - cursor: usize, -) -> Result<(usize, usize), EncodeError> { + out: &mut Vec, +) -> Result { match tag_of(word) { - ZERO_RUN_TAG => encode_zero_run(body, offset, out, cursor), - DENSE_RUN_TAG => encode_dense_run(body, offset, word, out, cursor), - sparse => encode_sparse_word(offset, word, sparse, out, cursor), + ZERO_RUN_TAG => encode_zero_run(body, offset, out), + DENSE_RUN_TAG => encode_dense_run(body, offset, word, out), + sparse => encode_sparse_word(offset, word, sparse, out), } } /// Encode a run of all-zero words. -fn encode_zero_run( - body: &[u8], - offset: usize, - out: &mut [u8], - cursor: usize, -) -> Result<(usize, usize), EncodeError> { +fn encode_zero_run(body: &[u8], offset: usize, out: &mut Vec) -> Result { let first_extra = advance_encode(offset, WORD_BYTES)?; let extra = run_extras(body, first_extra, |word| word == 0)?; let count = u8::try_from(extra).map_err(|_| EncodeError::LimitExceeded)?; - store(out, cursor, &[ZERO_RUN_TAG, count])?; + out.extend_from_slice(&[ZERO_RUN_TAG, count]); let extra_bytes = extra .checked_mul(WORD_BYTES) .ok_or(EncodeError::LimitExceeded)?; - Ok(( - advance_encode(first_extra, extra_bytes)?, - advance_encode(cursor, 2)?, - )) + advance_encode(first_extra, extra_bytes) } /// Encode a dense passthrough run beginning with `word`. @@ -209,56 +194,59 @@ fn encode_dense_run( body: &[u8], offset: usize, word: u64, - out: &mut [u8], - cursor: usize, -) -> Result<(usize, usize), EncodeError> { + out: &mut Vec, +) -> Result { let start = advance_encode(offset, WORD_BYTES)?; let extra = run_extras(body, start, |word| { tag_of(word).count_ones() >= DENSE_NONZERO_BYTES })?; let count = u8::try_from(extra).map_err(|_| EncodeError::LimitExceeded)?; - store(out, cursor, &[DENSE_RUN_TAG])?; - store(out, advance_encode(cursor, 1)?, &word.to_le_bytes())?; - store(out, advance_encode(cursor, 9)?, &[count])?; let extra_bytes = extra .checked_mul(WORD_BYTES) .ok_or(EncodeError::LimitExceeded)?; let end = advance_encode(start, extra_bytes)?; let raw = body.get(start..end).ok_or(EncodeError::LimitExceeded)?; - store(out, advance_encode(cursor, FAST_WINDOW)?, raw)?; - Ok(( - end, - advance_encode(cursor, FAST_WINDOW.wrapping_add(raw.len()))?, - )) + out.extend_from_slice(&[DENSE_RUN_TAG]); + out.extend_from_slice(&word.to_le_bytes()); + out.extend_from_slice(&[count]); + out.extend_from_slice(raw); + Ok(end) } -/// Encode a sparse word: the tag byte then only its nonzero bytes. +/// Encode a sparse word: the tag byte then only its nonzero bytes, gathered +/// into one register and appended with a single bounded copy (the previous +/// per-byte stores paid a bounds check per nonzero byte). fn encode_sparse_word( offset: usize, word: u64, tag: u8, - out: &mut [u8], - cursor: usize, -) -> Result<(usize, usize), EncodeError> { - let end = advance_encode(cursor, 9)?; - let dst = out.get_mut(cursor..end).ok_or(EncodeError::LimitExceeded)?; - if let Some(cell) = dst.first_mut() { - *cell = tag; - } - let mut len = 1_usize; + out: &mut Vec, +) -> Result { + let mut buf = [0_u8; 9]; + let (head, tail) = buf.split_first_mut().ok_or(EncodeError::LimitExceeded)?; + *head = tag; + tail.copy_from_slice(&compact_word(word, tag).to_le_bytes()); + let len = usize::try_from(tag.count_ones()) + .map_err(|_| EncodeError::LimitExceeded)? + .wrapping_add(1); + let bytes = buf.get(..len).ok_or(EncodeError::LimitExceeded)?; + out.extend_from_slice(bytes); + advance_encode(offset, WORD_BYTES) +} + +/// Gather the nonzero bytes of `word` (selected by `tag`) into the low lanes +/// of one register, in ascending lane order. +fn compact_word(word: u64, tag: u8) -> u64 { + let mut packed = 0_u64; + let mut shift = 0_u32; let mut bits = tag; while bits != 0 { let lane = bits.trailing_zeros(); - if let Some(cell) = dst.get_mut(len) { - *cell = extract_lane(word, lane); - } - len = len.wrapping_add(1); + packed |= (word.wrapping_shr(lane.wrapping_mul(8)) & 0xFF).wrapping_shl(shift); + shift = shift.wrapping_add(8); bits &= bits.wrapping_sub(1); } - Ok(( - advance_encode(offset, WORD_BYTES)?, - advance_encode(cursor, len)?, - )) + packed } /// Count extra words matching `predicate`, capped by the one-byte run count. @@ -434,22 +422,6 @@ fn tag_of(word: u64) -> u8 { u8::try_from(lanes.wrapping_mul(LANE_GATHER).wrapping_shr(56)).unwrap_or(0) } -/// Extract byte `lane` (0-7) of a word. -fn extract_lane(word: u64, lane: u32) -> u8 { - u8::try_from(word.wrapping_shr(lane.wrapping_mul(8)) & 0xFF).unwrap_or(0) -} - -/// Copy `src` into `dst` at `offset` (encode-side store). -fn store(dst: &mut [u8], offset: usize, src: &[u8]) -> Result<(), EncodeError> { - let end = offset - .checked_add(src.len()) - .ok_or(EncodeError::LimitExceeded)?; - dst.get_mut(offset..end) - .ok_or(EncodeError::LimitExceeded)? - .copy_from_slice(src); - Ok(()) -} - /// Append bytes while enforcing the unpacked output cap. fn append_bytes(out: &mut Vec, bytes: &[u8]) -> Result<(), DecodeError> { checked_output_len(out.len(), bytes.len()).map(|_| ())?; diff --git a/crates/tdbin/src/reader_lists.rs b/crates/tdbin/src/reader_lists.rs index d6bd268..4950556 100644 --- a/crates/tdbin/src/reader_lists.rs +++ b/crates/tdbin/src/reader_lists.rs @@ -408,13 +408,12 @@ impl<'a> Reader<'a> { } } -/// Append the requested low bits from one packed Bool word. +/// Append the requested low bits from one packed Bool word: an exact-size +/// range extend, so the reserve and per-element capacity checks vanish and +/// each lane's shift is independent of the previous one. fn append_bool_word(out: &mut Vec, word: u64, count: usize) { - let mut mask = 1_u64; - for _ in 0..count { - out.push(word & mask != 0); - mask = mask.rotate_left(1); - } + let take = u32::try_from(count).unwrap_or(64).min(64); + out.extend((0..take).map(|bit| word.wrapping_shr(bit) & 1 != 0)); } /// Convert an exact 8-byte chunk to an array (total for `chunks_exact` output). diff --git a/crates/tdbin/src/writer.rs b/crates/tdbin/src/writer.rs index dfafff1..32dc251 100644 --- a/crates/tdbin/src/writer.rs +++ b/crates/tdbin/src/writer.rs @@ -145,10 +145,11 @@ impl Writer { } /// Grow capacity ahead of `end` aggressively so bulk encodes do not pay - /// repeated doubling copies. + /// repeated doubling copies: ×8 keeps total growth-copy traffic under a + /// seventh of the final body instead of a third at ×4. fn grow_for(&mut self, end: usize) { if end > self.body.capacity() { - let ahead = end.max(self.body.capacity().wrapping_mul(4)); + let ahead = end.max(self.body.capacity().wrapping_mul(8)); self.body.reserve(ahead.saturating_sub(self.body.len())); } } @@ -303,7 +304,9 @@ impl Writer { self.set(ptr_word, ptr) } - /// Pack bits little-endian into already-reserved words at `start`. + /// Pack bits little-endian into already-reserved words at `start`: one + /// 64-bit accumulator flush per word instead of a bounds-checked + /// read-modify-write per bit. pub(crate) fn pack_bits( &mut self, start: usize, @@ -317,12 +320,23 @@ impl Writer { .body .get_mut(byte_start..) .ok_or(EncodeError::LimitExceeded)?; - for (i, value) in values.enumerate() { - let cell = dst.get_mut(i / 8).ok_or(EncodeError::LimitExceeded)?; - let mask = 1_u8.wrapping_shl(u32::try_from(i % 8).unwrap_or(0)); - *cell |= mask & u8::from(value).wrapping_neg(); + let mut words = dst.chunks_exact_mut(WORD_BYTES); + let (mut acc, mut filled) = (0_u64, 0_u32); + for value in values { + acc |= u64::from(value).wrapping_shl(filled); + filled = filled.wrapping_add(1); + (acc, filled) = match filled { + 64 => { + flush_bit_word(&mut words, acc)?; + (0, 0) + } + _ => (acc, filled), + }; + } + match filled { + 0 => Ok(()), + _ => flush_bit_word(&mut words, acc), } - Ok(()) } /// Run one nested struct write with the pointer-depth budget decremented. @@ -338,6 +352,17 @@ impl Writer { } } +/// Write one packed bit word into the next reserved 8-byte chunk. +fn flush_bit_word( + words: &mut core::slice::ChunksExactMut<'_, u8>, + acc: u64, +) -> Result<(), EncodeError> { + words + .next() + .ok_or(EncodeError::LimitExceeded) + .map(|chunk| chunk.copy_from_slice(&acc.to_le_bytes())) +} + /// Read a word back out of a mutable cell. fn word_of(cell: &[u8]) -> Result { <[u8; WORD_BYTES]>::try_from(cell) diff --git a/crates/tdbin/tests/support/batch_corpus.rs b/crates/tdbin/tests/support/batch_corpus.rs index 9d69125..5a878fa 100644 --- a/crates/tdbin/tests/support/batch_corpus.rs +++ b/crates/tdbin/tests/support/batch_corpus.rs @@ -13,8 +13,7 @@ pub const PERSON_COUNT: usize = 512; pub const CONTACT_COUNT: usize = 2_048; /// Protobuf record-heavy batch. -#[derive(serde::Serialize, serde::Deserialize)] -#[derive(Clone, PartialEq, Message)] +#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Message)] pub struct PbPersonBatch { /// Repeated records. #[prost(message, repeated, tag = "1")] @@ -22,8 +21,7 @@ pub struct PbPersonBatch { } /// Protobuf union-heavy batch. -#[derive(serde::Serialize, serde::Deserialize)] -#[derive(Clone, PartialEq, Message)] +#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Message)] pub struct PbContactBatch { /// Repeated oneof envelopes. #[prost(message, repeated, tag = "1")] @@ -31,8 +29,7 @@ pub struct PbContactBatch { } /// Protobuf message envelope required around each repeated oneof value. -#[derive(serde::Serialize, serde::Deserialize)] -#[derive(Clone, PartialEq, Message)] +#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Message)] pub struct PbContactEnvelope { /// Contact payload. #[prost(oneof = "PbContact", tags = "1, 2")] @@ -40,8 +37,7 @@ pub struct PbContactEnvelope { } /// Protobuf mirror of the generated Contact union. -#[derive(serde::Serialize, serde::Deserialize)] -#[derive(Clone, PartialEq, prost::Oneof)] +#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Oneof)] pub enum PbContact { /// Email contact. #[prost(message, tag = "1")] diff --git a/crates/tdbin/tests/support/bench_corpus.rs b/crates/tdbin/tests/support/bench_corpus.rs index 0d42f03..85f538d 100644 --- a/crates/tdbin/tests/support/bench_corpus.rs +++ b/crates/tdbin/tests/support/bench_corpus.rs @@ -83,8 +83,7 @@ pub mod corpus { /// size and speed comparison is fair. pub mod pb { /// The `Address` message: `string street = 1; int64 zip = 2;`. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct Address { /// The street line. #[prost(string, tag = "1")] @@ -95,8 +94,7 @@ pub mod corpus { } /// The `EmailContact` message: `string addr = 1;`. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct EmailContact { /// The email address. #[prost(string, tag = "1")] @@ -104,8 +102,7 @@ pub mod corpus { } /// The `PhoneContact` message: `int64 number = 1; int64 country = 2;`. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct PhoneContact { /// The subscriber number. #[prost(int64, tag = "1")] @@ -116,8 +113,7 @@ pub mod corpus { } /// The `contact` oneof: `EmailContact email = 7 | PhoneContact phone = 8`. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Oneof)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Oneof)] pub enum Contact { /// The email-contact variant. #[prost(message, tag = "7")] @@ -128,8 +124,7 @@ pub mod corpus { } /// The top-level `Person` message mirroring the TDBIN `Person` record. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct Person { /// The full name. #[prost(string, tag = "1")] @@ -155,8 +150,7 @@ pub mod corpus { } /// A metric batch with list-heavy, fixed-width data. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchMetricBatch { /// Batch identifier. #[prost(string, tag = "1")] @@ -182,8 +176,7 @@ pub mod corpus { } /// A named repeated-double metric column. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchMetricColumn { /// Column name. #[prost(string, tag = "1")] diff --git a/crates/tdbin/tests/support/document_corpus.rs b/crates/tdbin/tests/support/document_corpus.rs index 8e4a74d..e0c77c7 100644 --- a/crates/tdbin/tests/support/document_corpus.rs +++ b/crates/tdbin/tests/support/document_corpus.rs @@ -14,8 +14,7 @@ const META_COUNT: usize = 16; /// Protobuf mirror types for the document fixture. pub mod pb { /// Protobuf diagram document. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchDocument { /// Stable document identifier. #[prost(string, tag = "1")] @@ -41,8 +40,7 @@ pub mod pb { } /// Protobuf diagram node. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchNode { /// Stable node identifier. #[prost(string, tag = "1")] @@ -74,8 +72,7 @@ pub mod pb { } /// Protobuf diagram edge. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchEdge { /// Stable edge identifier. #[prost(string, tag = "1")] @@ -98,8 +95,7 @@ pub mod pb { } /// Protobuf style rule. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchStyle { /// Selector expression. #[prost(string, tag = "1")] @@ -119,8 +115,7 @@ pub mod pb { } /// Protobuf metadata key/value pair. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchMeta { /// Metadata key. #[prost(string, tag = "1")] diff --git a/crates/tdbin/tests/support/event_corpus.rs b/crates/tdbin/tests/support/event_corpus.rs index 9dd5636..6d40576 100644 --- a/crates/tdbin/tests/support/event_corpus.rs +++ b/crates/tdbin/tests/support/event_corpus.rs @@ -14,8 +14,7 @@ pub mod pb { use super::documents; /// Protobuf event-stream envelope. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchEventBatch { /// Ordered diagram events. #[prost(message, repeated, tag = "1")] @@ -23,8 +22,7 @@ pub mod pb { } /// Protobuf envelope for one event union. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchEventEnvelope { /// Event payload. #[prost(oneof = "BenchEvent", tags = "1, 2, 3, 4, 5, 6")] @@ -32,8 +30,7 @@ pub mod pb { } /// Protobuf event oneof. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Oneof)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Oneof)] pub enum BenchEvent { /// A node was created. #[prost(message, tag = "1")] @@ -56,8 +53,7 @@ pub mod pb { } /// Protobuf node-created payload. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchNodeCreated { /// Owning document identifier. #[prost(string, tag = "1")] @@ -68,8 +64,7 @@ pub mod pb { } /// Protobuf node-moved payload. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchNodeMoved { /// Owning document identifier. #[prost(string, tag = "1")] @@ -86,8 +81,7 @@ pub mod pb { } /// Protobuf edge-added payload. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchEdgeAdded { /// Owning document identifier. #[prost(string, tag = "1")] @@ -98,8 +92,7 @@ pub mod pb { } /// Protobuf selection-changed payload. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchSelectionChanged { /// Owning document identifier. #[prost(string, tag = "1")] @@ -113,8 +106,7 @@ pub mod pb { } /// Protobuf view-changed payload. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, prost::Message)] pub struct BenchViewChanged { /// Owning document identifier. #[prost(string, tag = "1")] @@ -131,8 +123,7 @@ pub mod pb { } /// Protobuf empty heartbeat payload. - #[derive(serde::Serialize, serde::Deserialize)] - #[derive(Clone, Copy, PartialEq, prost::Message)] + #[derive(serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, prost::Message)] pub struct BenchHeartbeat {} } diff --git a/docs/reports/tdbin-bench-data.json b/docs/reports/tdbin-bench-data.json index 584b14a..babdf8b 100644 --- a/docs/reports/tdbin-bench-data.json +++ b/docs/reports/tdbin-bench-data.json @@ -1,6 +1,6 @@ { "format_version": 1, - "generated_at": "2026-07-13T22:05:40.298Z", + "generated_at": "2026-07-13T22:10:03.872Z", "commands": ["cargo bench -p tdbin --bench gate -- --noplot", "node scripts/tdbin-bench-report.mjs"], "corpus_schemas": ["docs/benchmarks/tdbin-corpus.td", "docs/benchmarks/tdbin-corpus.proto"], "gate": { diff --git a/docs/reports/tdbin-bench-report.md b/docs/reports/tdbin-bench-report.md index 80dec58..635d1de 100644 --- a/docs/reports/tdbin-bench-report.md +++ b/docs/reports/tdbin-bench-report.md @@ -3,9 +3,9 @@ > GENERATED FILE. Source: `scripts/tdbin-bench-report.mjs` and `docs/reports/tdbin-bench-data.json`. > Every value and verdict is computed from machine-readable Criterion and encoder output. No benchmark result is entered manually. -Generated: 2026-07-13T22:05:40.298Z +Generated: 2026-07-13T22:10:03.872Z -Raw data SHA-256: `c8d15f8911c0ec857d5e9d4c8950fd8b01095e8c22ba5828b57c8308666000c0` +Raw data SHA-256: `454d6147fa804971e06c1a77407cc7f86797c15c91db9a108df83c6cceb03b32` ## Result diff --git a/docs/specs/tdbin-benchmarks.md b/docs/specs/tdbin-benchmarks.md index 6f1c3ce..490c301 100644 --- a/docs/specs/tdbin-benchmarks.md +++ b/docs/specs/tdbin-benchmarks.md @@ -2,6 +2,8 @@ TDBIN is typeDiagram's compact binary codec for algebraic data types, measured here against **Protocol Buffers** and **MessagePack**. Every number below is data-derived — produced by [`scripts/tdbin-bench-report.mjs`](https://github.com/Nimblesite/typeDiagram/blob/main/scripts/tdbin-bench-report.mjs) from Criterion timings and exact encoder output — and regenerates on each benchmark run. +> **Scope: these figures are for the Rust implementation only.** Both the encoded sizes and the speeds are measured against the Rust `tdbin` codec crate ([`crates/tdbin`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin)), the Rust `prost` Protobuf encoder, and the Rust `rmp-serde` MessagePack encoder. The TDBIN **wire format** and its byte sizes are language-neutral, but serialize/deserialize **speeds** depend on each language's implementation — typeDiagram's other codec targets, and other Protobuf/MessagePack libraries, will differ, sometimes substantially. + ## Size and Speed One row per test. Sizes are exact encoded bytes; "Serialize" is the whole ADT→binary conversion and "Deserialize" the whole binary→ADT conversion, each a Criterion median. typeDiagram is its **framed** production wire mode; MessagePack is struct-as-map (via `rmp-serde`). Lower is better in every column. @@ -30,6 +32,7 @@ The following are read directly off the table above — each is a comparison of ## Methodology +- **Rust implementations.** Every timing is for a Rust codec: typeDiagram's [`tdbin`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin) crate, Protobuf via `prost`, and MessagePack via `rmp-serde`. Encoded sizes are a property of the wire format and hold across languages; speeds do not — typeDiagram's other language targets and other Protobuf/MessagePack libraries will produce different timings. - **Same values, three encoders.** Every test builds one logical value and feeds the identical value to the typeDiagram codec, to a hand-written Protobuf mirror (`prost`), and — via `serde` derives on that same mirror — to MessagePack (`rmp-serde`). No format receives a different input. See the fixtures in [`crates/tdbin/tests/support/bench_corpus.rs`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin/tests/support/bench_corpus.rs). - **Self-describing modes only.** typeDiagram _framed_, Protobuf, and MessagePack _struct-as-map_ all carry enough structure to be decoded without an external schema, so the comparison is like-for-like. - **Sizes are exact byte counts** emitted by each encoder (see [`crates/tdbin/examples/bench_data.rs`](https://github.com/Nimblesite/typeDiagram/blob/main/crates/tdbin/examples/bench_data.rs)) — not estimates. diff --git a/scripts/tdbin-bench-report.mjs b/scripts/tdbin-bench-report.mjs index fd6d9b0..fc8e432 100644 --- a/scripts/tdbin-bench-report.mjs +++ b/scripts/tdbin-bench-report.mjs @@ -309,6 +309,8 @@ const website = `# TDBIN Benchmarks TDBIN is typeDiagram's compact binary codec for algebraic data types, measured here against **Protocol Buffers** and **MessagePack**. Every number below is data-derived — produced by [\`scripts/tdbin-bench-report.mjs\`](${repo}/${ref}/scripts/tdbin-bench-report.mjs) from Criterion timings and exact encoder output — and regenerates on each benchmark run. +> **Scope: these figures are for the Rust implementation only.** Both the encoded sizes and the speeds are measured against the Rust \`tdbin\` codec crate ([\`crates/tdbin\`](${repo}/${ref}/crates/tdbin)), the Rust \`prost\` Protobuf encoder, and the Rust \`rmp-serde\` MessagePack encoder. The TDBIN **wire format** and its byte sizes are language-neutral, but serialize/deserialize **speeds** depend on each language's implementation — typeDiagram's other codec targets, and other Protobuf/MessagePack libraries, will differ, sometimes substantially. + ## Size and Speed One row per test. Sizes are exact encoded bytes; "Serialize" is the whole ADT→binary conversion and "Deserialize" the whole binary→ADT conversion, each a Criterion median. typeDiagram is its **framed** production wire mode; MessagePack is struct-as-map (via \`rmp-serde\`). Lower is better in every column. @@ -325,6 +327,7 @@ ${factLines.join("\n")} ## Methodology +- **Rust implementations.** Every timing is for a Rust codec: typeDiagram's [\`tdbin\`](${repo}/${ref}/crates/tdbin) crate, Protobuf via \`prost\`, and MessagePack via \`rmp-serde\`. Encoded sizes are a property of the wire format and hold across languages; speeds do not — typeDiagram's other language targets and other Protobuf/MessagePack libraries will produce different timings. - **Same values, three encoders.** Every test builds one logical value and feeds the identical value to the typeDiagram codec, to a hand-written Protobuf mirror (\`prost\`), and — via \`serde\` derives on that same mirror — to MessagePack (\`rmp-serde\`). No format receives a different input. See the fixtures in [\`crates/tdbin/tests/support/bench_corpus.rs\`](${repo}/${ref}/crates/tdbin/tests/support/bench_corpus.rs). - **Self-describing modes only.** typeDiagram *framed*, Protobuf, and MessagePack *struct-as-map* all carry enough structure to be decoded without an external schema, so the comparison is like-for-like. - **Sizes are exact byte counts** emitted by each encoder (see [\`crates/tdbin/examples/bench_data.rs\`](${repo}/${ref}/crates/tdbin/examples/bench_data.rs)) — not estimates. From 583309719989ddebcab262373095e8e5ce9376d2 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:12:49 +1000 Subject: [PATCH 10/14] fixes --- crates/tdbin/src/intblock.rs | 101 ++++--- crates/tdbin/src/pack.rs | 162 ++++++----- crates/tdbin/src/writer.rs | 15 +- docs/reports/tdbin-bench-data.json | 422 ++++++++++++++--------------- docs/reports/tdbin-bench-report.md | 200 +++++++------- docs/specs/tdbin-benchmarks.md | 14 +- pyproject.toml | 6 + 7 files changed, 480 insertions(+), 440 deletions(-) create mode 100644 pyproject.toml diff --git a/crates/tdbin/src/intblock.rs b/crates/tdbin/src/intblock.rs index f5ac8e7..134c21a 100644 --- a/crates/tdbin/src/intblock.rs +++ b/crates/tdbin/src/intblock.rs @@ -36,27 +36,22 @@ fn unzigzag(value: u64) -> i64 { pub(crate) fn encode(values: &[i64]) -> Result, EncodeError> { let count = u32::try_from(values.len()).map_err(|_| EncodeError::LimitExceeded)?; let first = values.first().copied().unwrap_or_default(); - let (floor, width) = delta_stats(values); + let deltas = zigzag_deltas(values); + let floor = deltas.iter().copied().min().unwrap_or_default(); + let width = deltas + .iter() + .map(|delta| WORD_BITS.wrapping_sub(delta.wrapping_sub(floor).leading_zeros())) + .max() + .unwrap_or_default(); let mut out = Vec::with_capacity(block_len(values.len(), width)?); out.extend_from_slice(&count.to_le_bytes()); out.extend_from_slice(&first.to_le_bytes()); out.extend_from_slice(&floor.to_le_bytes()); out.push(u8::try_from(width).map_err(|_| EncodeError::LimitExceeded)?); - pack_deltas(values, floor, width, &mut out); + pack_deltas(&deltas, floor, width, &mut out); Ok(out) } -/// The minimum zigzag delta and the minimal bit width covering the range, -/// computed in two streaming passes with no intermediate buffer. -fn delta_stats(values: &[i64]) -> (u64, u32) { - let floor = deltas(values).min().unwrap_or_default(); - let width = deltas(values) - .map(|delta| WORD_BITS.wrapping_sub(delta.wrapping_sub(floor).leading_zeros())) - .max() - .unwrap_or_default(); - (floor, width) -} - /// Read a block's declared logical count without decoding it. /// /// # Errors @@ -96,40 +91,78 @@ pub(crate) fn decode(block: &[u8]) -> Result, DecodeError> { return Err(DecodeError::MalformedColumn); } let mut out = Vec::with_capacity(count); - let mut acc = i64::from_le_bytes(first.to_le_bytes()); + let acc = i64::from_le_bytes(first.to_le_bytes()); out.push(acc); + match width { + 0 => fill_constant(&mut out, acc, unzigzag(floor), count), + _ => read_deltas(&mut out, block, acc, floor, width, count), + } + Ok(out) +} + +/// Accumulate the remaining values from a constant `step` delta: a width of +/// zero packs no bits at all, so monotonic columns skip the bit reader. +fn fill_constant(out: &mut Vec, mut acc: i64, step: i64, count: usize) { + for _ in 1..count { + acc = acc.wrapping_add(step); + out.push(acc); + } +} + +/// Accumulate the remaining values from the packed delta stream. +fn read_deltas( + out: &mut Vec, + block: &[u8], + mut acc: i64, + floor: u64, + width: u32, + count: usize, +) { let mut bits = BitReader::new(block.get(HEADER_BYTES..).unwrap_or_default()); for _ in 1..count { let delta = unzigzag(bits.read(width).wrapping_add(floor)); acc = acc.wrapping_add(delta); out.push(acc); } - Ok(out) } -/// Zigzagged wrapping deltas between consecutive values. -fn deltas(values: &[i64]) -> impl Iterator + '_ { - values.windows(2).map(|pair| { - let previous = pair.first().copied().unwrap_or_default(); - let next = pair.get(1).copied().unwrap_or_default(); - zigzag(next.wrapping_sub(previous)) - }) +/// Zigzagged wrapping deltas between consecutive values, materialized once: +/// the floor scan, width scan, and packing pass all reuse this buffer instead +/// of re-deriving deltas per pass. +fn zigzag_deltas(values: &[i64]) -> Vec { + values + .windows(2) + .map(|pair| { + let previous = pair.first().copied().unwrap_or_default(); + let next = pair.get(1).copied().unwrap_or_default(); + zigzag(next.wrapping_sub(previous)) + }) + .collect() } -/// Append `width`-bit deltas (relative to `floor`) as a little-endian stream, -/// flushing whole 64-bit words instead of single bytes. -fn pack_deltas(values: &[i64], floor: u64, width: u32, out: &mut Vec) { +/// Append `width`-bit deltas (relative to `floor`) as a little-endian stream. +/// +/// Keep the byte-at-a-time pusher: a manual 64-bit-accumulator variant that +/// flushed whole words measured 20-30% SLOWER on the metric and contact +/// corpora (Apple Silicon, criterion vs pinned baseline) — LLVM optimizes +/// this shape better than the hand-rolled word flush with its +/// data-dependent flush branch. +fn pack_deltas(deltas: &[u64], floor: u64, width: u32, out: &mut Vec) { let mut acc = 0_u64; let mut filled = 0_u32; - for delta in deltas(values) { - let value = delta.wrapping_sub(floor) & mask_of(width); - acc |= value.wrapping_shl(filled); - match filled.checked_add(width) { - Some(next) if next < WORD_BITS => filled = next, - _ => { - out.extend_from_slice(&acc.to_le_bytes()); - acc = shr_full(value, WORD_BITS.wrapping_sub(filled)); - filled = filled.wrapping_add(width).wrapping_sub(WORD_BITS); + for delta in deltas { + let mut value = delta.wrapping_sub(floor); + let mut remaining = width; + while remaining > 0 { + let take = remaining.min(WORD_BITS.wrapping_sub(filled)); + acc |= (value & mask_of(take)).wrapping_shl(filled); + filled = filled.wrapping_add(take); + value = shr_full(value, take); + remaining = remaining.wrapping_sub(take); + while filled >= 8 { + out.push(u8::try_from(acc & 0xFF).unwrap_or(0)); + acc = acc.wrapping_shr(8); + filled = filled.wrapping_sub(8); } } } diff --git a/crates/tdbin/src/pack.rs b/crates/tdbin/src/pack.rs index d9a5096..ee31a70 100644 --- a/crates/tdbin/src/pack.rs +++ b/crates/tdbin/src/pack.rs @@ -1,13 +1,14 @@ //! Cap'n Proto word packing for TDBIN bodies ([TDBIN-PACK]). //! //! Hot-path shape ([TDBIN-PACK-WORD], [TDBIN-PACK-RUNS]): whole words are -//! classified with a branch-free SWAR nonzero-byte mask, both directions -//! write through cursors into preallocated buffers (no per-byte growth -//! checks), zero runs cost two bytes to emit and a cursor bump to consume -//! (the output arrives pre-zeroed), and sparse words touch only their -//! nonzero bytes. Output is byte-identical to the reference algorithm: a -//! zero run starts on an all-zero word, a dense run starts on an all-nonzero -//! word and extends over words with at least seven nonzero bytes. +//! classified with a branch-free SWAR nonzero-byte mask, the encoder appends +//! constant-size chunks onto a capacity-reserved vector (no zero-fill pass, +//! no per-byte stores), the decoder writes through cursors into a pre-zeroed +//! buffer, zero runs cost two bytes to emit and a cursor bump to consume, +//! and sparse words touch only their nonzero bytes. Output is byte-identical +//! to the reference algorithm: a zero run starts on an all-zero word, a +//! dense run starts on an all-nonzero word and extends over words with at +//! least seven nonzero bytes. use crate::error::{DecodeError, EncodeError}; @@ -100,8 +101,9 @@ fn grow_chunk(out: &mut Vec, written: usize) -> bool { } /// Decode fast elements into the pre-zeroed slice until the headroom or the -/// input window runs out; the sparse path is fully inlined with no per-word -/// slice construction, so the loop is bounded by the tag-load/popcount chain. +/// input window runs out; `cursor <= fast_end` guarantees a full window at +/// every iteration, so the per-element helpers read whole words unchecked by +/// construction. The caller guarantees `ELEMENT_ROOM` headroom. fn decode_chunk( packed: &[u8], mut cursor: usize, @@ -113,43 +115,69 @@ fn decode_chunk( let dst = out.as_mut_slice(); while cursor <= fast_end && written <= limit { let tag = packed.get(cursor).copied().unwrap_or(0); - if tag == ZERO_RUN_TAG || tag == DENSE_RUN_TAG { - let Some(window) = window_at(packed, cursor) else { - break; - }; - (cursor, written) = decode_fast_element(packed, window, cursor, dst, written)?; - } else { - let end = cursor.wrapping_add(9); - let src = packed - .get(cursor.wrapping_add(1)..end) - .and_then(|slice| <[u8; WORD_BYTES]>::try_from(slice).ok()) - .ok_or(DecodeError::PackedTruncated)?; - let word = expand_word(tag, u64::from_le_bytes(src)); - if let Some(cell) = dst.get_mut(written..written.wrapping_add(WORD_BYTES)) { - cell.copy_from_slice(&word.to_le_bytes()); - } - let taken = usize::try_from(tag.count_ones()).unwrap_or(WORD_BYTES); - cursor = cursor.wrapping_add(taken.wrapping_add(1)); - written = written.wrapping_add(WORD_BYTES); - } + (cursor, written) = match tag { + ZERO_RUN_TAG => decode_fast_zero(packed, cursor, written)?, + DENSE_RUN_TAG => decode_fast_dense(packed, cursor, dst, written)?, + sparse => decode_fast_sparse(sparse, packed, cursor, dst, written)?, + }; } Ok((cursor, written)) } -/// Branchless sparse expansion: each lane independently locates its source -/// byte by prefix-popcount, so the eight lanes carry no data-dependent branch -/// and no serial register dependency (the previous shift-chain version -/// serialized every lane on the compacted source register). +/// Skip a zero-word run in the fast path: the output is pre-zeroed, so the +/// run costs one count load and two cursor bumps. +fn decode_fast_zero( + packed: &[u8], + cursor: usize, + written: usize, +) -> Result<(usize, usize), DecodeError> { + let extra = usize::from(packed.get(cursor.wrapping_add(1)).copied().unwrap_or(0)); + let bytes = extra + .wrapping_add(1) + .checked_mul(WORD_BYTES) + .ok_or(DecodeError::LimitExceeded)?; + Ok((advance(cursor, 2)?, advance(written, bytes)?)) +} + +/// Expand one sparse word in the fast path with unconditional whole-word +/// reads and writes. +fn decode_fast_sparse( + tag: u8, + packed: &[u8], + cursor: usize, + dst: &mut [u8], + written: usize, +) -> Result<(usize, usize), DecodeError> { + let src = packed + .get(cursor.wrapping_add(1)..cursor.wrapping_add(9)) + .and_then(|slice| <[u8; WORD_BYTES]>::try_from(slice).ok()) + .ok_or(DecodeError::PackedTruncated)?; + let word = expand_word(tag, u64::from_le_bytes(src)); + if let Some(cell) = dst.get_mut(written..written.wrapping_add(WORD_BYTES)) { + cell.copy_from_slice(&word.to_le_bytes()); + } + let taken = usize::try_from(tag.count_ones()).unwrap_or(WORD_BYTES); + Ok(( + advance(cursor, taken.wrapping_add(1))?, + advance(written, WORD_BYTES)?, + )) +} + +/// Branchless sparse expansion: a constant eight-lane pass selects each +/// output byte from the compacted source. The serial shift-chain beats a +/// prefix-popcount lane-independent variant here: expansions of consecutive +/// words are already independent, so the CPU overlaps their chains, and this +/// shape is fewer ops per lane (measured on Apple Silicon). fn expand_word(tag: u8, src: u64) -> u64 { let mut word = 0_u64; + let mut rest = src; let mut lane = 0_u32; while lane < 8 { - let present = u64::from(tag).wrapping_shr(lane) & 1; - let below = u32::from(tag) & 1_u32.wrapping_shl(lane).wrapping_sub(1); - let byte = src.wrapping_shr(below.count_ones().wrapping_mul(8)) & 0xFF; - word |= byte - .wrapping_mul(present) + let take = u64::from(tag).wrapping_shr(lane) & 1; + word |= (rest & 0xFF) + .wrapping_mul(take) .wrapping_shl(lane.wrapping_mul(8)); + rest = rest.wrapping_shr(u32::try_from(take.wrapping_mul(8)).unwrap_or(0)); lane = lane.wrapping_add(1); } word @@ -214,8 +242,10 @@ fn encode_dense_run( } /// Encode a sparse word: the tag byte then only its nonzero bytes, gathered -/// into one register and appended with a single bounded copy (the previous -/// per-byte stores paid a bounds check per nonzero byte). +/// into one register. The whole 9-byte buffer is appended as one +/// constant-size copy (which the compiler inlines, unlike a variable-length +/// copy that lowers to a `memcpy` call per word) and the garbage tail is +/// truncated off — a sparse tag has 1-7 set bits, so the tag byte survives. fn encode_sparse_word( offset: usize, word: u64, @@ -226,11 +256,10 @@ fn encode_sparse_word( let (head, tail) = buf.split_first_mut().ok_or(EncodeError::LimitExceeded)?; *head = tag; tail.copy_from_slice(&compact_word(word, tag).to_le_bytes()); - let len = usize::try_from(tag.count_ones()) - .map_err(|_| EncodeError::LimitExceeded)? - .wrapping_add(1); - let bytes = buf.get(..len).ok_or(EncodeError::LimitExceeded)?; - out.extend_from_slice(bytes); + out.extend_from_slice(&buf); + let garbage = 8_usize + .wrapping_sub(usize::try_from(tag.count_ones()).map_err(|_| EncodeError::LimitExceeded)?); + out.truncate(out.len().wrapping_sub(garbage)); advance_encode(offset, WORD_BYTES) } @@ -269,57 +298,20 @@ fn run_extras( Ok(count) } -/// Decode one element with unconditional whole-word reads: zero runs bump the -/// cursor over pre-zeroed output, dense runs bulk-copy, sparse words write -/// only their nonzero bytes. The caller guarantees `ELEMENT_ROOM` headroom. -fn decode_fast_element( - packed: &[u8], - window: &[u8], - cursor: usize, - dst: &mut [u8], - written: usize, -) -> Result<(usize, usize), DecodeError> { - let tag = window.first().copied().unwrap_or(0); - match tag { - ZERO_RUN_TAG => { - let extra = usize::from(window.get(1).copied().unwrap_or(0)); - let bytes = extra - .wrapping_add(1) - .checked_mul(WORD_BYTES) - .ok_or(DecodeError::LimitExceeded)?; - Ok((advance(cursor, 2)?, advance(written, bytes)?)) - } - DENSE_RUN_TAG => decode_fast_dense(packed, window, cursor, dst, written), - sparse => { - let src = window - .get(1..1 + WORD_BYTES) - .and_then(|slice| <[u8; WORD_BYTES]>::try_from(slice).ok()) - .ok_or(DecodeError::PackedTruncated)?; - let word = expand_word(sparse, u64::from_le_bytes(src)); - copy_at(dst, written, &word.to_le_bytes())?; - let taken = - usize::try_from(sparse.count_ones()).map_err(|_| DecodeError::LimitExceeded)?; - Ok(( - advance(cursor, taken.wrapping_add(1))?, - advance(written, WORD_BYTES)?, - )) - } - } -} - /// Decode a dense passthrough run in the fast path. fn decode_fast_dense( packed: &[u8], - window: &[u8], cursor: usize, dst: &mut [u8], written: usize, ) -> Result<(usize, usize), DecodeError> { - let extra = usize::from(window.get(9).copied().unwrap_or(0)); + let extra = usize::from(packed.get(cursor.wrapping_add(9)).copied().unwrap_or(0)); let raw_bytes = extra .checked_mul(WORD_BYTES) .ok_or(DecodeError::LimitExceeded)?; - let word = window.get(1..9).ok_or(DecodeError::PackedTruncated)?; + let word = packed + .get(cursor.wrapping_add(1)..cursor.wrapping_add(9)) + .ok_or(DecodeError::PackedTruncated)?; copy_at(dst, written, word)?; let raw_start = advance(cursor, FAST_WINDOW)?; let raw_end = advance(raw_start, raw_bytes)?; diff --git a/crates/tdbin/src/writer.rs b/crates/tdbin/src/writer.rs index 32dc251..ed1f810 100644 --- a/crates/tdbin/src/writer.rs +++ b/crates/tdbin/src/writer.rs @@ -18,6 +18,9 @@ use crate::{Struct, MAX_DEPTH}; const MAX_WORDS: usize = 1 << 26; /// Initial arena capacity: covers small messages with one allocation. const INITIAL_CAPACITY: usize = 256; +/// Capacity knee above which arena growth falls back from ×8 to ×4, bounding +/// worst-case retained overshoot for large messages at the pre-knee level. +const GROWTH_KNEE_BYTES: usize = 1 << 22; /// Accumulates message body words while encoding a value tree. #[derive(Debug)] @@ -145,11 +148,17 @@ impl Writer { } /// Grow capacity ahead of `end` aggressively so bulk encodes do not pay - /// repeated doubling copies: ×8 keeps total growth-copy traffic under a - /// seventh of the final body instead of a third at ×4. + /// repeated doubling copies: ×8 below the knee keeps growth-copy traffic + /// under a seventh of the final body, and ×4 above it caps the retained + /// overshoot for large messages at the historical bound. fn grow_for(&mut self, end: usize) { if end > self.body.capacity() { - let ahead = end.max(self.body.capacity().wrapping_mul(8)); + let factor = if self.body.capacity() < GROWTH_KNEE_BYTES { + 8 + } else { + 4 + }; + let ahead = end.max(self.body.capacity().wrapping_mul(factor)); self.body.reserve(ahead.saturating_sub(self.body.len())); } } diff --git a/docs/reports/tdbin-bench-data.json b/docs/reports/tdbin-bench-data.json index babdf8b..74d7ef2 100644 --- a/docs/reports/tdbin-bench-data.json +++ b/docs/reports/tdbin-bench-data.json @@ -1,6 +1,6 @@ { "format_version": 1, - "generated_at": "2026-07-13T22:10:03.872Z", + "generated_at": "2026-07-13T23:32:23.773Z", "commands": ["cargo bench -p tdbin --bench gate -- --noplot", "node scripts/tdbin-bench-report.mjs"], "corpus_schemas": ["docs/benchmarks/tdbin-corpus.td", "docs/benchmarks/tdbin-corpus.proto"], "gate": { @@ -106,562 +106,562 @@ { "fixture": "with_address", "operation": "tdbin_encode_bare", - "median_ns": 83.61664436763455, - "confidence_interval_ns": [83.24098977945131, 84.04859171597633], + "median_ns": 80.66253398954024, + "confidence_interval_ns": [80.28346371269271, 81.26352559645052], "sample_count": 50, - "sampled_time_ns": 4979330883 + "sampled_time_ns": 4931599211 }, { "fixture": "with_address", "operation": "tdbin_encode_framed", - "median_ns": 87.50883454804087, - "confidence_interval_ns": [87.21800529214731, 88.4152608677886], + "median_ns": 86.21141242420813, + "confidence_interval_ns": [85.8729337249524, 86.99114180135571], "sample_count": 50, - "sampled_time_ns": 4680046292 + "sampled_time_ns": 5039075251 }, { "fixture": "with_address", "operation": "tdbin_encode_packed_framed", - "median_ns": 184.40744293066552, - "confidence_interval_ns": [183.62941755306687, 185.67707487934024], + "median_ns": 175.18662289905606, + "confidence_interval_ns": [173.6160157941058, 176.25406192750097], "sample_count": 50, - "sampled_time_ns": 5033556040 + "sampled_time_ns": 4964207711 }, { "fixture": "with_address", "operation": "protobuf_encode", - "median_ns": 46.84470689177553, - "confidence_interval_ns": [46.66299905361558, 47.04870937798407], + "median_ns": 46.82040448307977, + "confidence_interval_ns": [46.59288984683352, 47.057366656795814], "sample_count": 50, - "sampled_time_ns": 4875876411 + "sampled_time_ns": 4994310456 }, { "fixture": "with_address", "operation": "msgpack_encode", - "median_ns": 252.32891048765742, - "confidence_interval_ns": [250.6742349757851, 254.03142770998204], + "median_ns": 240.49730175385997, + "confidence_interval_ns": [240.01858527251403, 241.7074734755742], "sample_count": 50, - "sampled_time_ns": 4900557878 + "sampled_time_ns": 4989358003 }, { "fixture": "with_address", "operation": "tdbin_decode_bare", - "median_ns": 151.83659588353746, - "confidence_interval_ns": [151.28891089516742, 153.12560592546632], + "median_ns": 152.29744153338362, + "confidence_interval_ns": [151.4458415610709, 152.90562522349728], "sample_count": 50, - "sampled_time_ns": 5004358211 + "sampled_time_ns": 5030072208 }, { "fixture": "with_address", "operation": "tdbin_decode_framed", - "median_ns": 153.21068381941174, - "confidence_interval_ns": [152.62205970219838, 153.55883282725722], + "median_ns": 157.3196474099544, + "confidence_interval_ns": [155.85379955221916, 161.80085687475307], "sample_count": 50, - "sampled_time_ns": 5015031748 + "sampled_time_ns": 5089967627 }, { "fixture": "with_address", "operation": "tdbin_decode_packed_framed", - "median_ns": 571.4264272647447, - "confidence_interval_ns": [570.1688222596224, 573.8316456774174], + "median_ns": 587.5673992143045, + "confidence_interval_ns": [580.0440643571532, 594.537930398345], "sample_count": 50, - "sampled_time_ns": 5018607129 + "sampled_time_ns": 5106050623 }, { "fixture": "with_address", "operation": "protobuf_decode", - "median_ns": 152.48645794255958, - "confidence_interval_ns": [151.71946830120095, 153.26124395661833], + "median_ns": 150.49882485315788, + "confidence_interval_ns": [149.9105977422589, 151.64202968347988], "sample_count": 50, - "sampled_time_ns": 5014849797 + "sampled_time_ns": 5005128292 }, { "fixture": "with_address", "operation": "msgpack_decode", - "median_ns": 195.3103293218463, - "confidence_interval_ns": [194.46978318778235, 196.06689781766266], + "median_ns": 192.3886321309173, + "confidence_interval_ns": [191.474710163947, 193.24566098715732], "sample_count": 50, - "sampled_time_ns": 5300693082 + "sampled_time_ns": 4969318670 }, { "fixture": "without_address", "operation": "tdbin_encode_bare", - "median_ns": 61.10147673968285, - "confidence_interval_ns": [60.79277205346075, 61.426855614918935], + "median_ns": 59.77926347146385, + "confidence_interval_ns": [59.39425679687683, 60.18328135812989], "sample_count": 50, - "sampled_time_ns": 4706953498 + "sampled_time_ns": 4945151337 }, { "fixture": "without_address", "operation": "tdbin_encode_framed", - "median_ns": 65.75884592216104, - "confidence_interval_ns": [65.4871198094578, 66.16730661122021], + "median_ns": 63.80137492555723, + "confidence_interval_ns": [63.37494250546698, 64.16062978108576], "sample_count": 50, - "sampled_time_ns": 4976057336 + "sampled_time_ns": 4943136116 }, { "fixture": "without_address", "operation": "tdbin_encode_packed_framed", - "median_ns": 141.91882372169204, - "confidence_interval_ns": [141.3504438183821, 142.9386974602703], + "median_ns": 136.46756403621833, + "confidence_interval_ns": [135.27236502492664, 136.99058228932182], "sample_count": 50, - "sampled_time_ns": 4880894293 + "sampled_time_ns": 4916981874 }, { "fixture": "without_address", "operation": "protobuf_encode", - "median_ns": 32.67926390634088, - "confidence_interval_ns": [32.5972015414703, 32.85549012971483], + "median_ns": 32.86292578472172, + "confidence_interval_ns": [32.725095755584995, 32.977483175502], "sample_count": 50, - "sampled_time_ns": 4975979456 + "sampled_time_ns": 4996045202 }, { "fixture": "without_address", "operation": "msgpack_encode", - "median_ns": 228.8395579615137, - "confidence_interval_ns": [228.1282434252914, 230.05866963431538], + "median_ns": 227.40270535766098, + "confidence_interval_ns": [226.84134997969778, 228.35130484160644], "sample_count": 50, - "sampled_time_ns": 4988959708 + "sampled_time_ns": 5037188670 }, { "fixture": "without_address", "operation": "tdbin_decode_bare", - "median_ns": 69.0706918471579, - "confidence_interval_ns": [68.85039126359696, 69.63825338770167], + "median_ns": 67.71712839602927, + "confidence_interval_ns": [67.2218761174968, 67.94533424077434], "sample_count": 50, - "sampled_time_ns": 4982202250 + "sampled_time_ns": 4996015209 }, { "fixture": "without_address", "operation": "tdbin_decode_framed", - "median_ns": 71.52131062190932, - "confidence_interval_ns": [71.3568695077877, 71.79698316672076], + "median_ns": 76.84652405551515, + "confidence_interval_ns": [73.48044180749059, 77.69475199095902], "sample_count": 50, - "sampled_time_ns": 4985773829 + "sampled_time_ns": 5384606170 }, { "fixture": "without_address", "operation": "tdbin_decode_packed_framed", - "median_ns": 482.7027420343137, - "confidence_interval_ns": [481.6115469082673, 485.379877102971], + "median_ns": 500.5722547038896, + "confidence_interval_ns": [489.1262688257622, 507.77569486959715], "sample_count": 50, - "sampled_time_ns": 5005362500 + "sampled_time_ns": 5152076337 }, { "fixture": "without_address", "operation": "protobuf_decode", - "median_ns": 50.66371098202555, - "confidence_interval_ns": [50.46992794103637, 50.904488323117846], + "median_ns": 50.05978659800866, + "confidence_interval_ns": [49.74005818731422, 50.21508152439319], "sample_count": 50, - "sampled_time_ns": 4982155086 + "sampled_time_ns": 4993445744 }, { "fixture": "without_address", "operation": "msgpack_decode", - "median_ns": 109.679979869954, - "confidence_interval_ns": [108.39510820680536, 110.71079638752053], + "median_ns": 108.74915044756817, + "confidence_interval_ns": [107.47195618629236, 109.72661493707173], "sample_count": 50, - "sampled_time_ns": 5011578539 + "sampled_time_ns": 4989619371 }, { "fixture": "metric_batch", "operation": "tdbin_encode_bare", - "median_ns": 11343.527180261834, - "confidence_interval_ns": [11198.346712703209, 11548.181268882176], + "median_ns": 7262.825498310811, + "confidence_interval_ns": [6474.2469594594595, 7471.000564671815], "sample_count": 50, - "sampled_time_ns": 4895851168 + "sampled_time_ns": 5385509836 }, { "fixture": "metric_batch", "operation": "tdbin_encode_framed", - "median_ns": 11636.921146953406, - "confidence_interval_ns": [11441.998574237954, 11789.938053097345], + "median_ns": 6557.218875647044, + "confidence_interval_ns": [6336.129977395236, 6871.107198748044], "sample_count": 50, - "sampled_time_ns": 5053565166 + "sampled_time_ns": 5386488206 }, { "fixture": "metric_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 23042.410392300197, - "confidence_interval_ns": [22953.947368421053, 23097.775406985933], + "median_ns": 20227.95032517095, + "confidence_interval_ns": [19324.312640071716, 20609.79381443299], "sample_count": 50, - "sampled_time_ns": 5027355088 + "sampled_time_ns": 4909780709 }, { "fixture": "metric_batch", "operation": "protobuf_encode", - "median_ns": 45467.38587615025, - "confidence_interval_ns": [45165.31696273076, 46029.982880899974], + "median_ns": 45615.628562995444, + "confidence_interval_ns": [44868.34673659674, 45992.02636363637], "sample_count": 50, - "sampled_time_ns": 5015355831 + "sampled_time_ns": 5047081373 }, { "fixture": "metric_batch", "operation": "msgpack_encode", - "median_ns": 43944.26374716553, - "confidence_interval_ns": [43875.907333333336, 44050.09920634921], + "median_ns": 43618.68538407405, + "confidence_interval_ns": [43455.15967202503, 43703.353937728934], "sample_count": 50, - "sampled_time_ns": 5058800291 + "sampled_time_ns": 5067377420 }, { "fixture": "metric_batch", "operation": "tdbin_decode_bare", - "median_ns": 6262.635812745288, - "confidence_interval_ns": [6237.152478105096, 6275.781771307249], + "median_ns": 5223.054576352292, + "confidence_interval_ns": [5215.887511170688, 5239.024338991629], "sample_count": 50, - "sampled_time_ns": 4999074499 + "sampled_time_ns": 4970291120 }, { "fixture": "metric_batch", "operation": "tdbin_decode_framed", - "median_ns": 6251.415367766835, - "confidence_interval_ns": [6237.527484078082, 6260.328445763284], + "median_ns": 5197.743615877652, + "confidence_interval_ns": [5181.858177809277, 5208.409101377815], "sample_count": 50, - "sampled_time_ns": 4963736041 + "sampled_time_ns": 4964629704 }, { "fixture": "metric_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 29941.821112040132, - "confidence_interval_ns": [29837.204142011833, 30011.81217948718], + "median_ns": 28040.77421140011, + "confidence_interval_ns": [27998.788724122744, 28085.080848288402], "sample_count": 50, - "sampled_time_ns": 4985412460 + "sampled_time_ns": 4979641376 }, { "fixture": "metric_batch", "operation": "protobuf_decode", - "median_ns": 31316.763854518184, - "confidence_interval_ns": [30981.85108989036, 31566.36670416198], + "median_ns": 29664.01679524088, + "confidence_interval_ns": [29391.749119097956, 29918.981427648578], "sample_count": 50, - "sampled_time_ns": 5056750127 + "sampled_time_ns": 4924024207 }, { "fixture": "metric_batch", "operation": "msgpack_decode", - "median_ns": 50869.594322344325, - "confidence_interval_ns": [50666.834045584044, 50998.54506903353], + "median_ns": 52304.24770021645, + "confidence_interval_ns": [51517.22301836095, 53082.47749924494], "sample_count": 50, - "sampled_time_ns": 5055011662 + "sampled_time_ns": 5165279712 }, { "fixture": "person_batch", "operation": "tdbin_encode_bare", - "median_ns": 11592.643813533607, - "confidence_interval_ns": [11549.569822485208, 11661.946688666363], + "median_ns": 9836.756108796577, + "confidence_interval_ns": [9785.976368159205, 9880.072850035536], "sample_count": 50, - "sampled_time_ns": 5018668294 + "sampled_time_ns": 5078211832 }, { "fixture": "person_batch", "operation": "tdbin_encode_framed", - "median_ns": 11586.773543924148, - "confidence_interval_ns": [11551.83498366013, 11623.709234429065], + "median_ns": 9728.463140777594, + "confidence_interval_ns": [9710.947925116267, 9767.3027829602], "sample_count": 50, - "sampled_time_ns": 5040314081 + "sampled_time_ns": 5005247458 }, { "fixture": "person_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 15048.325823695535, - "confidence_interval_ns": [14982.277564102566, 15098.607292183624], + "median_ns": 13544.83958122256, + "confidence_interval_ns": [13456.615691489362, 13572.030141843972], "sample_count": 50, - "sampled_time_ns": 5023850540 + "sampled_time_ns": 5019038915 }, { "fixture": "person_batch", "operation": "protobuf_encode", - "median_ns": 18194.800138888888, - "confidence_interval_ns": [18038.995511831276, 18355.82729468599], + "median_ns": 17986.273732121488, + "confidence_interval_ns": [17813.4601025828, 18161.928230122325], "sample_count": 50, - "sampled_time_ns": 5011984002 + "sampled_time_ns": 5005869541 }, { "fixture": "person_batch", "operation": "msgpack_encode", - "median_ns": 48224.15000354252, - "confidence_interval_ns": [48004.14737654321, 48296.620594023974], + "median_ns": 48368.5060298103, + "confidence_interval_ns": [48262.618808788946, 48658.4828469884], "sample_count": 50, - "sampled_time_ns": 5034277459 + "sampled_time_ns": 5005912204 }, { "fixture": "person_batch", "operation": "tdbin_decode_bare", - "median_ns": 37126.50757350757, - "confidence_interval_ns": [36968.29494759183, 37348.43441814596], + "median_ns": 35363.96979774053, + "confidence_interval_ns": [35191.16377372628, 35485.20992063492], "sample_count": 50, - "sampled_time_ns": 4953235625 + "sampled_time_ns": 5062145252 }, { "fixture": "person_batch", "operation": "tdbin_decode_framed", - "median_ns": 36786.00741614256, - "confidence_interval_ns": [36715.63669618791, 36994.496630727765], + "median_ns": 35157.26087516088, + "confidence_interval_ns": [35062.52638352638, 35251.60875160875], "sample_count": 50, - "sampled_time_ns": 4999731338 + "sampled_time_ns": 5005411708 }, { "fixture": "person_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 41346.43828667413, - "confidence_interval_ns": [41170.889164086686, 41454.2500907441], + "median_ns": 39553.59654786502, + "confidence_interval_ns": [39371.85197308601, 39679.46464646464], "sample_count": 50, - "sampled_time_ns": 5002426833 + "sampled_time_ns": 5006200831 }, { "fixture": "person_batch", "operation": "protobuf_decode", - "median_ns": 57952.18742622467, - "confidence_interval_ns": [57728.19852941176, 58288.60294117647], + "median_ns": 55844.0400928297, + "confidence_interval_ns": [55677.940017728746, 56068.63791079812], "sample_count": 50, - "sampled_time_ns": 5043928333 + "sampled_time_ns": 5059321294 }, { "fixture": "person_batch", "operation": "msgpack_decode", - "median_ns": 87827.8536643026, - "confidence_interval_ns": [87503.0105105105, 88480.63333333333], + "median_ns": 87034.53538868538, + "confidence_interval_ns": [86460.22407407407, 87670.98518518519], "sample_count": 50, - "sampled_time_ns": 5043932832 + "sampled_time_ns": 5000367085 }, { "fixture": "contact_batch", "operation": "tdbin_encode_bare", - "median_ns": 9007.822950954574, - "confidence_interval_ns": [8978.605727452272, 9035.88474560331], + "median_ns": 9181.736340280793, + "confidence_interval_ns": [9147.127758077226, 9261.556855791961], "sample_count": 50, - "sampled_time_ns": 4979172379 + "sampled_time_ns": 4972711332 }, { "fixture": "contact_batch", "operation": "tdbin_encode_framed", - "median_ns": 9024.667090069284, - "confidence_interval_ns": [8978.926096997691, 9050.118359027423], + "median_ns": 9168.753318060531, + "confidence_interval_ns": [9129.980223783503, 9217.280384397965], "sample_count": 50, - "sampled_time_ns": 5001160789 + "sampled_time_ns": 4990231165 }, { "fixture": "contact_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 11986.280761916918, - "confidence_interval_ns": [11966.281572337128, 12027.820730452675], + "median_ns": 11956.549558235114, + "confidence_interval_ns": [11901.623839009288, 12001.212693498452], "sample_count": 50, - "sampled_time_ns": 4967119750 + "sampled_time_ns": 4967010837 }, { "fixture": "contact_batch", "operation": "protobuf_encode", - "median_ns": 24956.69557977332, - "confidence_interval_ns": [24745.874870967742, 25115.466666666667], + "median_ns": 24760.563060989643, + "confidence_interval_ns": [24619.580096027934, 25031.38193768257], "sample_count": 50, - "sampled_time_ns": 4944457955 + "sampled_time_ns": 5012321380 }, { "fixture": "contact_batch", "operation": "msgpack_encode", - "median_ns": 69630.3850046116, - "confidence_interval_ns": [69408.10934311224, 69994.2330357143], + "median_ns": 67893.01915708813, + "confidence_interval_ns": [67626.29503714052, 68132.59280272655], "sample_count": 50, - "sampled_time_ns": 4982157338 + "sampled_time_ns": 5017045296 }, { "fixture": "contact_batch", "operation": "tdbin_decode_bare", - "median_ns": 31082.99929696288, - "confidence_interval_ns": [30977.353000368093, 31204.85520426968], + "median_ns": 30450.98590281717, + "confidence_interval_ns": [30255.75421300977, 30601.00986610289], "sample_count": 50, - "sampled_time_ns": 5058548874 + "sampled_time_ns": 5001396623 }, { "fixture": "contact_batch", "operation": "tdbin_decode_framed", - "median_ns": 31045.49037999038, - "confidence_interval_ns": [30864.738095238095, 31138.47204968944], + "median_ns": 30350.757751937985, + "confidence_interval_ns": [30203.361981799797, 30509.20016149871], "sample_count": 50, - "sampled_time_ns": 4989841710 + "sampled_time_ns": 4993096208 }, { "fixture": "contact_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 33269.70507785586, - "confidence_interval_ns": [33134.3334860284, 33520.2051294796], + "median_ns": 32193.801587756505, + "confidence_interval_ns": [32059.536910235904, 32292.73381147541], "sample_count": 50, - "sampled_time_ns": 5039519039 + "sampled_time_ns": 4995878914 }, { "fixture": "contact_batch", "operation": "protobuf_decode", - "median_ns": 61433.54985119047, - "confidence_interval_ns": [61210.43682715311, 61662.36480978261], + "median_ns": 59680.88003482025, + "confidence_interval_ns": [59468.39898989899, 59851.06319444445], "sample_count": 50, - "sampled_time_ns": 5049477378 + "sampled_time_ns": 5013238585 }, { "fixture": "contact_batch", "operation": "msgpack_decode", - "median_ns": 95622.82949308756, - "confidence_interval_ns": [95163.38048780488, 96002.9981707317], + "median_ns": 92548.30163013242, + "confidence_interval_ns": [92047.74630021142, 93057.6511627907], "sample_count": 50, - "sampled_time_ns": 5005629750 + "sampled_time_ns": 5098326167 }, { "fixture": "diagram_document", "operation": "tdbin_encode_bare", - "median_ns": 12351.921268640948, - "confidence_interval_ns": [12306.946741694377, 12392.45363492974], + "median_ns": 10234.118684383202, + "confidence_interval_ns": [10185.908914163509, 10302.862378901722], "sample_count": 50, - "sampled_time_ns": 5027730535 + "sampled_time_ns": 4987085163 }, { "fixture": "diagram_document", "operation": "tdbin_encode_framed", - "median_ns": 12340.496081761994, - "confidence_interval_ns": [12319.80141955836, 12386.922211069688], + "median_ns": 10258.766919747148, + "confidence_interval_ns": [10222.591756807162, 10348.165361183637], "sample_count": 50, - "sampled_time_ns": 5002333541 + "sampled_time_ns": 5037162586 }, { "fixture": "diagram_document", "operation": "tdbin_encode_packed_framed", - "median_ns": 19374.47514438944, - "confidence_interval_ns": [19309.01060820368, 19403.882929959662], + "median_ns": 17134.32821637427, + "confidence_interval_ns": [17066.328728070177, 17208.192645074225], "sample_count": 50, - "sampled_time_ns": 5016867918 + "sampled_time_ns": 5009037000 }, { "fixture": "diagram_document", "operation": "protobuf_encode", - "median_ns": 21809.475008464535, - "confidence_interval_ns": [21677.33214829863, 21979.914764565045], + "median_ns": 21698.624122405374, + "confidence_interval_ns": [21502.352869352868, 22149.385655512677], "sample_count": 50, - "sampled_time_ns": 5003104038 + "sampled_time_ns": 5082264999 }, { "fixture": "diagram_document", "operation": "msgpack_encode", - "median_ns": 55735, - "confidence_interval_ns": [55356.770535714284, 55915.070779220776], + "median_ns": 57092.554445750095, + "confidence_interval_ns": [56544.90993955518, 57765.89040585554], "sample_count": 50, - "sampled_time_ns": 4975531665 + "sampled_time_ns": 4993632793 }, { "fixture": "diagram_document", "operation": "tdbin_decode_bare", - "median_ns": 71659.45137741047, - "confidence_interval_ns": [71301.74825174826, 72031.07822410148], + "median_ns": 70279.19221735882, + "confidence_interval_ns": [69859.14327485379, 70709.99362041468], "sample_count": 50, - "sampled_time_ns": 5022720875 + "sampled_time_ns": 5130112539 }, { "fixture": "diagram_document", "operation": "tdbin_decode_framed", - "median_ns": 71705.15459848485, - "confidence_interval_ns": [71323.41060606061, 71949.3519138756], + "median_ns": 69248.28776144565, + "confidence_interval_ns": [69010.89122807018, 69658.3820662768], "sample_count": 50, - "sampled_time_ns": 5033498996 + "sampled_time_ns": 5066609711 }, { "fixture": "diagram_document", "operation": "tdbin_decode_packed_framed", - "median_ns": 80233.1297077922, - "confidence_interval_ns": [79980.72534013605, 80512.85714285714], + "median_ns": 78909.65864942528, + "confidence_interval_ns": [78556.875, 79335.08380952381], "sample_count": 50, - "sampled_time_ns": 5018654338 + "sampled_time_ns": 5040853416 }, { "fixture": "diagram_document", "operation": "protobuf_decode", - "median_ns": 110586.76271885523, - "confidence_interval_ns": [110144.95992063492, 110847.85126262627], + "median_ns": 109277.37806372548, + "confidence_interval_ns": [108927.45368867244, 109704.62148238925], "sample_count": 50, - "sampled_time_ns": 5078462339 + "sampled_time_ns": 5019651169 }, { "fixture": "diagram_document", "operation": "msgpack_decode", - "median_ns": 134249.99215686275, - "confidence_interval_ns": [133630.25205128206, 134904.0184397163], + "median_ns": 130848.12651515153, + "confidence_interval_ns": [130323.23333333334, 131115.14227642276], "sample_count": 50, - "sampled_time_ns": 5147938708 + "sampled_time_ns": 4999292417 }, { "fixture": "event_batch", "operation": "tdbin_encode_bare", - "median_ns": 35478.442650128134, - "confidence_interval_ns": [35418.262733887736, 35511.23238623238], + "median_ns": 33937.42363147605, + "confidence_interval_ns": [33888.73922413793, 34061.57183908046], "sample_count": 50, - "sampled_time_ns": 5035723793 + "sampled_time_ns": 5030536122 }, { "fixture": "event_batch", "operation": "tdbin_encode_framed", - "median_ns": 35490.47264175941, - "confidence_interval_ns": [35370.747747747744, 35636.02211302211], + "median_ns": 34150.02697410486, + "confidence_interval_ns": [34000.292482368444, 34333.80434782609], "sample_count": 50, - "sampled_time_ns": 5039176834 + "sampled_time_ns": 5031903248 }, { "fixture": "event_batch", "operation": "tdbin_encode_packed_framed", - "median_ns": 51961.03733766233, - "confidence_interval_ns": [51840.161483253585, 52081.76691729324], + "median_ns": 50871.72338646202, + "confidence_interval_ns": [50690.85237657965, 50989.538095238095], "sample_count": 50, - "sampled_time_ns": 5034662963 + "sampled_time_ns": 5007372207 }, { "fixture": "event_batch", "operation": "protobuf_encode", - "median_ns": 66930.34093596588, - "confidence_interval_ns": [66714.00224704674, 67227.43644067796], + "median_ns": 69852.26488095238, + "confidence_interval_ns": [69613.09280331155, 70240.35714285714], "sample_count": 50, - "sampled_time_ns": 5040797329 + "sampled_time_ns": 4992936368 }, { "fixture": "event_batch", "operation": "msgpack_encode", - "median_ns": 138808.93690654673, - "confidence_interval_ns": [138563.4540229885, 139068.02262931035], + "median_ns": 139986.91543044266, + "confidence_interval_ns": [139563.791392994, 140438.26666666666], "sample_count": 50, - "sampled_time_ns": 5148309004 + "sampled_time_ns": 5178803415 }, { "fixture": "event_batch", "operation": "tdbin_decode_bare", - "median_ns": 201637.53387973816, - "confidence_interval_ns": [199726.57213438736, 202658.1616448985], + "median_ns": 194820.50873390283, + "confidence_interval_ns": [193145.94513662142, 196017.0559006211], "sample_count": 50, - "sampled_time_ns": 5649775703 + "sampled_time_ns": 5595398164 }, { "fixture": "event_batch", "operation": "tdbin_decode_framed", - "median_ns": 201602.0534090909, - "confidence_interval_ns": [200463.21770334928, 202969.2135416667], + "median_ns": 193618.71053109807, + "confidence_interval_ns": [191110.4300800264, 194724.60869565216], "sample_count": 50, - "sampled_time_ns": 5636485580 + "sampled_time_ns": 5537602877 }, { "fixture": "event_batch", "operation": "tdbin_decode_packed_framed", - "median_ns": 207266.5981359649, - "confidence_interval_ns": [206924.74715909088, 207594.95967741936], + "median_ns": 194652.43428912782, + "confidence_interval_ns": [194144.90426587302, 195372.6884920635], "sample_count": 50, - "sampled_time_ns": 5281910791 + "sampled_time_ns": 5223632668 }, { "fixture": "event_batch", "operation": "protobuf_decode", - "median_ns": 279421.9536275094, - "confidence_interval_ns": [277026.81947431306, 281490.26666666666], + "median_ns": 275240.9422003284, + "confidence_interval_ns": [273705.10376344086, 275895.8333333333], "sample_count": 50, - "sampled_time_ns": 5343109961 + "sampled_time_ns": 5232959459 }, { "fixture": "event_batch", "operation": "msgpack_decode", - "median_ns": 387002.15777338605, - "confidence_interval_ns": [385202.4034965035, 388430.0944055944], + "median_ns": 372343.65, + "confidence_interval_ns": [371662.7594451003, 373583.36363636365], "sample_count": 50, - "sampled_time_ns": 5426402624 + "sampled_time_ns": 5230549243 } ] } diff --git a/docs/reports/tdbin-bench-report.md b/docs/reports/tdbin-bench-report.md index 635d1de..bc57852 100644 --- a/docs/reports/tdbin-bench-report.md +++ b/docs/reports/tdbin-bench-report.md @@ -3,9 +3,9 @@ > GENERATED FILE. Source: `scripts/tdbin-bench-report.mjs` and `docs/reports/tdbin-bench-data.json`. > Every value and verdict is computed from machine-readable Criterion and encoder output. No benchmark result is entered manually. -Generated: 2026-07-13T22:10:03.872Z +Generated: 2026-07-13T23:32:23.773Z -Raw data SHA-256: `454d6147fa804971e06c1a77407cc7f86797c15c91db9a108df83c6cceb03b32` +Raw data SHA-256: `46c48e4a8ae7f959b364576d3592682bd96f1257dd266fa8810cbb15c18fb388` ## Result @@ -21,13 +21,13 @@ The headline comparison — one row per test, all three self-describing formats | Test | typeDiagram Size | Protobuf Size | MessagePack Size | typeDiagram Serialize | Protobuf Serialize | MessagePack Serialize | typeDiagram Deserialize | Protobuf Deserialize | MessagePack Deserialize | | ------------------ | ---------------: | ------------: | ---------------: | --------------------: | -----------------: | --------------------: | ----------------------: | -------------------: | ----------------------: | -| `with_address` | 172 | 79 | 142 | 87.51 ns | 46.84 ns | 252.33 ns | 153.21 ns | 152.49 ns | 195.31 ns | -| `without_address` | 124 | 31 | 100 | 65.76 ns | 32.68 ns | 228.84 ns | 71.52 ns | 50.66 ns | 109.68 ns | -| `metric_batch` | 43,788 | 84,149 | 90,440 | 11.637 us | 45.467 us | 43.944 us | 6.251 us | 31.317 us | 50.870 us | -| `person_batch` | 22,988 | 29,184 | 61,963 | 11.587 us | 18.195 us | 48.224 us | 36.786 us | 57.952 us | 87.828 us | -| `contact_batch` | 23,156 | 35,221 | 80,162 | 9.025 us | 24.957 us | 69.630 us | 31.045 us | 61.434 us | 95.623 us | -| `diagram_document` | 45,172 | 50,788 | 77,410 | 12.340 us | 21.809 us | 55.735 us | 71.705 us | 110.587 us | 134.250 us | -| `event_batch` | 116,372 | 131,744 | 230,620 | 35.490 us | 66.930 us | 138.809 us | 201.602 us | 279.422 us | 387.002 us | +| `with_address` | 172 | 79 | 142 | 86.21 ns | 46.82 ns | 240.50 ns | 157.32 ns | 150.50 ns | 192.39 ns | +| `without_address` | 124 | 31 | 100 | 63.80 ns | 32.86 ns | 227.40 ns | 76.85 ns | 50.06 ns | 108.75 ns | +| `metric_batch` | 43,788 | 84,149 | 90,440 | 6.557 us | 45.616 us | 43.619 us | 5.198 us | 29.664 us | 52.304 us | +| `person_batch` | 22,988 | 29,184 | 61,963 | 9.728 us | 17.986 us | 48.369 us | 35.157 us | 55.844 us | 87.035 us | +| `contact_batch` | 23,156 | 35,221 | 80,162 | 9.169 us | 24.761 us | 67.893 us | 30.351 us | 59.681 us | 92.548 us | +| `diagram_document` | 45,172 | 50,788 | 77,410 | 10.259 us | 21.699 us | 57.093 us | 69.248 us | 109.277 us | 130.848 us | +| `event_batch` | 116,372 | 131,744 | 230,620 | 34.150 us | 69.852 us | 139.987 us | 193.619 us | 275.241 us | 372.344 us | ## Environment @@ -71,76 +71,76 @@ Each row is one **individual** operation — a complete serialize _or_ deseriali | Fixture | Operation | Samples | Sampled time | Median | CI lower | CI upper | | ------------------ | ---------------------------- | ------: | -----------: | ---------: | ---------: | ---------: | -| `with_address` | `tdbin_encode_bare` | 50 | 4979.331 ms | 83.62 ns | 83.24 ns | 84.05 ns | -| `with_address` | `tdbin_encode_framed` | 50 | 4680.046 ms | 87.51 ns | 87.22 ns | 88.42 ns | -| `with_address` | `tdbin_encode_packed_framed` | 50 | 5033.556 ms | 184.41 ns | 183.63 ns | 185.68 ns | -| `with_address` | `protobuf_encode` | 50 | 4875.876 ms | 46.84 ns | 46.66 ns | 47.05 ns | -| `with_address` | `msgpack_encode` | 50 | 4900.558 ms | 252.33 ns | 250.67 ns | 254.03 ns | -| `with_address` | `tdbin_decode_bare` | 50 | 5004.358 ms | 151.84 ns | 151.29 ns | 153.13 ns | -| `with_address` | `tdbin_decode_framed` | 50 | 5015.032 ms | 153.21 ns | 152.62 ns | 153.56 ns | -| `with_address` | `tdbin_decode_packed_framed` | 50 | 5018.607 ms | 571.43 ns | 570.17 ns | 573.83 ns | -| `with_address` | `protobuf_decode` | 50 | 5014.850 ms | 152.49 ns | 151.72 ns | 153.26 ns | -| `with_address` | `msgpack_decode` | 50 | 5300.693 ms | 195.31 ns | 194.47 ns | 196.07 ns | -| `without_address` | `tdbin_encode_bare` | 50 | 4706.953 ms | 61.10 ns | 60.79 ns | 61.43 ns | -| `without_address` | `tdbin_encode_framed` | 50 | 4976.057 ms | 65.76 ns | 65.49 ns | 66.17 ns | -| `without_address` | `tdbin_encode_packed_framed` | 50 | 4880.894 ms | 141.92 ns | 141.35 ns | 142.94 ns | -| `without_address` | `protobuf_encode` | 50 | 4975.979 ms | 32.68 ns | 32.60 ns | 32.86 ns | -| `without_address` | `msgpack_encode` | 50 | 4988.960 ms | 228.84 ns | 228.13 ns | 230.06 ns | -| `without_address` | `tdbin_decode_bare` | 50 | 4982.202 ms | 69.07 ns | 68.85 ns | 69.64 ns | -| `without_address` | `tdbin_decode_framed` | 50 | 4985.774 ms | 71.52 ns | 71.36 ns | 71.80 ns | -| `without_address` | `tdbin_decode_packed_framed` | 50 | 5005.363 ms | 482.70 ns | 481.61 ns | 485.38 ns | -| `without_address` | `protobuf_decode` | 50 | 4982.155 ms | 50.66 ns | 50.47 ns | 50.90 ns | -| `without_address` | `msgpack_decode` | 50 | 5011.579 ms | 109.68 ns | 108.40 ns | 110.71 ns | -| `metric_batch` | `tdbin_encode_bare` | 50 | 4895.851 ms | 11.344 us | 11.198 us | 11.548 us | -| `metric_batch` | `tdbin_encode_framed` | 50 | 5053.565 ms | 11.637 us | 11.442 us | 11.790 us | -| `metric_batch` | `tdbin_encode_packed_framed` | 50 | 5027.355 ms | 23.042 us | 22.954 us | 23.098 us | -| `metric_batch` | `protobuf_encode` | 50 | 5015.356 ms | 45.467 us | 45.165 us | 46.030 us | -| `metric_batch` | `msgpack_encode` | 50 | 5058.800 ms | 43.944 us | 43.876 us | 44.050 us | -| `metric_batch` | `tdbin_decode_bare` | 50 | 4999.074 ms | 6.263 us | 6.237 us | 6.276 us | -| `metric_batch` | `tdbin_decode_framed` | 50 | 4963.736 ms | 6.251 us | 6.238 us | 6.260 us | -| `metric_batch` | `tdbin_decode_packed_framed` | 50 | 4985.412 ms | 29.942 us | 29.837 us | 30.012 us | -| `metric_batch` | `protobuf_decode` | 50 | 5056.750 ms | 31.317 us | 30.982 us | 31.566 us | -| `metric_batch` | `msgpack_decode` | 50 | 5055.012 ms | 50.870 us | 50.667 us | 50.999 us | -| `person_batch` | `tdbin_encode_bare` | 50 | 5018.668 ms | 11.593 us | 11.550 us | 11.662 us | -| `person_batch` | `tdbin_encode_framed` | 50 | 5040.314 ms | 11.587 us | 11.552 us | 11.624 us | -| `person_batch` | `tdbin_encode_packed_framed` | 50 | 5023.851 ms | 15.048 us | 14.982 us | 15.099 us | -| `person_batch` | `protobuf_encode` | 50 | 5011.984 ms | 18.195 us | 18.039 us | 18.356 us | -| `person_batch` | `msgpack_encode` | 50 | 5034.277 ms | 48.224 us | 48.004 us | 48.297 us | -| `person_batch` | `tdbin_decode_bare` | 50 | 4953.236 ms | 37.127 us | 36.968 us | 37.348 us | -| `person_batch` | `tdbin_decode_framed` | 50 | 4999.731 ms | 36.786 us | 36.716 us | 36.994 us | -| `person_batch` | `tdbin_decode_packed_framed` | 50 | 5002.427 ms | 41.346 us | 41.171 us | 41.454 us | -| `person_batch` | `protobuf_decode` | 50 | 5043.928 ms | 57.952 us | 57.728 us | 58.289 us | -| `person_batch` | `msgpack_decode` | 50 | 5043.933 ms | 87.828 us | 87.503 us | 88.481 us | -| `contact_batch` | `tdbin_encode_bare` | 50 | 4979.172 ms | 9.008 us | 8.979 us | 9.036 us | -| `contact_batch` | `tdbin_encode_framed` | 50 | 5001.161 ms | 9.025 us | 8.979 us | 9.050 us | -| `contact_batch` | `tdbin_encode_packed_framed` | 50 | 4967.120 ms | 11.986 us | 11.966 us | 12.028 us | -| `contact_batch` | `protobuf_encode` | 50 | 4944.458 ms | 24.957 us | 24.746 us | 25.115 us | -| `contact_batch` | `msgpack_encode` | 50 | 4982.157 ms | 69.630 us | 69.408 us | 69.994 us | -| `contact_batch` | `tdbin_decode_bare` | 50 | 5058.549 ms | 31.083 us | 30.977 us | 31.205 us | -| `contact_batch` | `tdbin_decode_framed` | 50 | 4989.842 ms | 31.045 us | 30.865 us | 31.138 us | -| `contact_batch` | `tdbin_decode_packed_framed` | 50 | 5039.519 ms | 33.270 us | 33.134 us | 33.520 us | -| `contact_batch` | `protobuf_decode` | 50 | 5049.477 ms | 61.434 us | 61.210 us | 61.662 us | -| `contact_batch` | `msgpack_decode` | 50 | 5005.630 ms | 95.623 us | 95.163 us | 96.003 us | -| `diagram_document` | `tdbin_encode_bare` | 50 | 5027.731 ms | 12.352 us | 12.307 us | 12.392 us | -| `diagram_document` | `tdbin_encode_framed` | 50 | 5002.334 ms | 12.340 us | 12.320 us | 12.387 us | -| `diagram_document` | `tdbin_encode_packed_framed` | 50 | 5016.868 ms | 19.374 us | 19.309 us | 19.404 us | -| `diagram_document` | `protobuf_encode` | 50 | 5003.104 ms | 21.809 us | 21.677 us | 21.980 us | -| `diagram_document` | `msgpack_encode` | 50 | 4975.532 ms | 55.735 us | 55.357 us | 55.915 us | -| `diagram_document` | `tdbin_decode_bare` | 50 | 5022.721 ms | 71.659 us | 71.302 us | 72.031 us | -| `diagram_document` | `tdbin_decode_framed` | 50 | 5033.499 ms | 71.705 us | 71.323 us | 71.949 us | -| `diagram_document` | `tdbin_decode_packed_framed` | 50 | 5018.654 ms | 80.233 us | 79.981 us | 80.513 us | -| `diagram_document` | `protobuf_decode` | 50 | 5078.462 ms | 110.587 us | 110.145 us | 110.848 us | -| `diagram_document` | `msgpack_decode` | 50 | 5147.939 ms | 134.250 us | 133.630 us | 134.904 us | -| `event_batch` | `tdbin_encode_bare` | 50 | 5035.724 ms | 35.478 us | 35.418 us | 35.511 us | -| `event_batch` | `tdbin_encode_framed` | 50 | 5039.177 ms | 35.490 us | 35.371 us | 35.636 us | -| `event_batch` | `tdbin_encode_packed_framed` | 50 | 5034.663 ms | 51.961 us | 51.840 us | 52.082 us | -| `event_batch` | `protobuf_encode` | 50 | 5040.797 ms | 66.930 us | 66.714 us | 67.227 us | -| `event_batch` | `msgpack_encode` | 50 | 5148.309 ms | 138.809 us | 138.563 us | 139.068 us | -| `event_batch` | `tdbin_decode_bare` | 50 | 5649.776 ms | 201.638 us | 199.727 us | 202.658 us | -| `event_batch` | `tdbin_decode_framed` | 50 | 5636.486 ms | 201.602 us | 200.463 us | 202.969 us | -| `event_batch` | `tdbin_decode_packed_framed` | 50 | 5281.911 ms | 207.267 us | 206.925 us | 207.595 us | -| `event_batch` | `protobuf_decode` | 50 | 5343.110 ms | 279.422 us | 277.027 us | 281.490 us | -| `event_batch` | `msgpack_decode` | 50 | 5426.403 ms | 387.002 us | 385.202 us | 388.430 us | +| `with_address` | `tdbin_encode_bare` | 50 | 4931.599 ms | 80.66 ns | 80.28 ns | 81.26 ns | +| `with_address` | `tdbin_encode_framed` | 50 | 5039.075 ms | 86.21 ns | 85.87 ns | 86.99 ns | +| `with_address` | `tdbin_encode_packed_framed` | 50 | 4964.208 ms | 175.19 ns | 173.62 ns | 176.25 ns | +| `with_address` | `protobuf_encode` | 50 | 4994.310 ms | 46.82 ns | 46.59 ns | 47.06 ns | +| `with_address` | `msgpack_encode` | 50 | 4989.358 ms | 240.50 ns | 240.02 ns | 241.71 ns | +| `with_address` | `tdbin_decode_bare` | 50 | 5030.072 ms | 152.30 ns | 151.45 ns | 152.91 ns | +| `with_address` | `tdbin_decode_framed` | 50 | 5089.968 ms | 157.32 ns | 155.85 ns | 161.80 ns | +| `with_address` | `tdbin_decode_packed_framed` | 50 | 5106.051 ms | 587.57 ns | 580.04 ns | 594.54 ns | +| `with_address` | `protobuf_decode` | 50 | 5005.128 ms | 150.50 ns | 149.91 ns | 151.64 ns | +| `with_address` | `msgpack_decode` | 50 | 4969.319 ms | 192.39 ns | 191.47 ns | 193.25 ns | +| `without_address` | `tdbin_encode_bare` | 50 | 4945.151 ms | 59.78 ns | 59.39 ns | 60.18 ns | +| `without_address` | `tdbin_encode_framed` | 50 | 4943.136 ms | 63.80 ns | 63.37 ns | 64.16 ns | +| `without_address` | `tdbin_encode_packed_framed` | 50 | 4916.982 ms | 136.47 ns | 135.27 ns | 136.99 ns | +| `without_address` | `protobuf_encode` | 50 | 4996.045 ms | 32.86 ns | 32.73 ns | 32.98 ns | +| `without_address` | `msgpack_encode` | 50 | 5037.189 ms | 227.40 ns | 226.84 ns | 228.35 ns | +| `without_address` | `tdbin_decode_bare` | 50 | 4996.015 ms | 67.72 ns | 67.22 ns | 67.95 ns | +| `without_address` | `tdbin_decode_framed` | 50 | 5384.606 ms | 76.85 ns | 73.48 ns | 77.69 ns | +| `without_address` | `tdbin_decode_packed_framed` | 50 | 5152.076 ms | 500.57 ns | 489.13 ns | 507.78 ns | +| `without_address` | `protobuf_decode` | 50 | 4993.446 ms | 50.06 ns | 49.74 ns | 50.22 ns | +| `without_address` | `msgpack_decode` | 50 | 4989.619 ms | 108.75 ns | 107.47 ns | 109.73 ns | +| `metric_batch` | `tdbin_encode_bare` | 50 | 5385.510 ms | 7.263 us | 6.474 us | 7.471 us | +| `metric_batch` | `tdbin_encode_framed` | 50 | 5386.488 ms | 6.557 us | 6.336 us | 6.871 us | +| `metric_batch` | `tdbin_encode_packed_framed` | 50 | 4909.781 ms | 20.228 us | 19.324 us | 20.610 us | +| `metric_batch` | `protobuf_encode` | 50 | 5047.081 ms | 45.616 us | 44.868 us | 45.992 us | +| `metric_batch` | `msgpack_encode` | 50 | 5067.377 ms | 43.619 us | 43.455 us | 43.703 us | +| `metric_batch` | `tdbin_decode_bare` | 50 | 4970.291 ms | 5.223 us | 5.216 us | 5.239 us | +| `metric_batch` | `tdbin_decode_framed` | 50 | 4964.630 ms | 5.198 us | 5.182 us | 5.208 us | +| `metric_batch` | `tdbin_decode_packed_framed` | 50 | 4979.641 ms | 28.041 us | 27.999 us | 28.085 us | +| `metric_batch` | `protobuf_decode` | 50 | 4924.024 ms | 29.664 us | 29.392 us | 29.919 us | +| `metric_batch` | `msgpack_decode` | 50 | 5165.280 ms | 52.304 us | 51.517 us | 53.082 us | +| `person_batch` | `tdbin_encode_bare` | 50 | 5078.212 ms | 9.837 us | 9.786 us | 9.880 us | +| `person_batch` | `tdbin_encode_framed` | 50 | 5005.247 ms | 9.728 us | 9.711 us | 9.767 us | +| `person_batch` | `tdbin_encode_packed_framed` | 50 | 5019.039 ms | 13.545 us | 13.457 us | 13.572 us | +| `person_batch` | `protobuf_encode` | 50 | 5005.870 ms | 17.986 us | 17.813 us | 18.162 us | +| `person_batch` | `msgpack_encode` | 50 | 5005.912 ms | 48.369 us | 48.263 us | 48.658 us | +| `person_batch` | `tdbin_decode_bare` | 50 | 5062.145 ms | 35.364 us | 35.191 us | 35.485 us | +| `person_batch` | `tdbin_decode_framed` | 50 | 5005.412 ms | 35.157 us | 35.063 us | 35.252 us | +| `person_batch` | `tdbin_decode_packed_framed` | 50 | 5006.201 ms | 39.554 us | 39.372 us | 39.679 us | +| `person_batch` | `protobuf_decode` | 50 | 5059.321 ms | 55.844 us | 55.678 us | 56.069 us | +| `person_batch` | `msgpack_decode` | 50 | 5000.367 ms | 87.035 us | 86.460 us | 87.671 us | +| `contact_batch` | `tdbin_encode_bare` | 50 | 4972.711 ms | 9.182 us | 9.147 us | 9.262 us | +| `contact_batch` | `tdbin_encode_framed` | 50 | 4990.231 ms | 9.169 us | 9.130 us | 9.217 us | +| `contact_batch` | `tdbin_encode_packed_framed` | 50 | 4967.011 ms | 11.957 us | 11.902 us | 12.001 us | +| `contact_batch` | `protobuf_encode` | 50 | 5012.321 ms | 24.761 us | 24.620 us | 25.031 us | +| `contact_batch` | `msgpack_encode` | 50 | 5017.045 ms | 67.893 us | 67.626 us | 68.133 us | +| `contact_batch` | `tdbin_decode_bare` | 50 | 5001.397 ms | 30.451 us | 30.256 us | 30.601 us | +| `contact_batch` | `tdbin_decode_framed` | 50 | 4993.096 ms | 30.351 us | 30.203 us | 30.509 us | +| `contact_batch` | `tdbin_decode_packed_framed` | 50 | 4995.879 ms | 32.194 us | 32.060 us | 32.293 us | +| `contact_batch` | `protobuf_decode` | 50 | 5013.239 ms | 59.681 us | 59.468 us | 59.851 us | +| `contact_batch` | `msgpack_decode` | 50 | 5098.326 ms | 92.548 us | 92.048 us | 93.058 us | +| `diagram_document` | `tdbin_encode_bare` | 50 | 4987.085 ms | 10.234 us | 10.186 us | 10.303 us | +| `diagram_document` | `tdbin_encode_framed` | 50 | 5037.163 ms | 10.259 us | 10.223 us | 10.348 us | +| `diagram_document` | `tdbin_encode_packed_framed` | 50 | 5009.037 ms | 17.134 us | 17.066 us | 17.208 us | +| `diagram_document` | `protobuf_encode` | 50 | 5082.265 ms | 21.699 us | 21.502 us | 22.149 us | +| `diagram_document` | `msgpack_encode` | 50 | 4993.633 ms | 57.093 us | 56.545 us | 57.766 us | +| `diagram_document` | `tdbin_decode_bare` | 50 | 5130.113 ms | 70.279 us | 69.859 us | 70.710 us | +| `diagram_document` | `tdbin_decode_framed` | 50 | 5066.610 ms | 69.248 us | 69.011 us | 69.658 us | +| `diagram_document` | `tdbin_decode_packed_framed` | 50 | 5040.853 ms | 78.910 us | 78.557 us | 79.335 us | +| `diagram_document` | `protobuf_decode` | 50 | 5019.651 ms | 109.277 us | 108.927 us | 109.705 us | +| `diagram_document` | `msgpack_decode` | 50 | 4999.292 ms | 130.848 us | 130.323 us | 131.115 us | +| `event_batch` | `tdbin_encode_bare` | 50 | 5030.536 ms | 33.937 us | 33.889 us | 34.062 us | +| `event_batch` | `tdbin_encode_framed` | 50 | 5031.903 ms | 34.150 us | 34.000 us | 34.334 us | +| `event_batch` | `tdbin_encode_packed_framed` | 50 | 5007.372 ms | 50.872 us | 50.691 us | 50.990 us | +| `event_batch` | `protobuf_encode` | 50 | 4992.936 ms | 69.852 us | 69.613 us | 70.240 us | +| `event_batch` | `msgpack_encode` | 50 | 5178.803 ms | 139.987 us | 139.564 us | 140.438 us | +| `event_batch` | `tdbin_decode_bare` | 50 | 5595.398 ms | 194.821 us | 193.146 us | 196.017 us | +| `event_batch` | `tdbin_decode_framed` | 50 | 5537.603 ms | 193.619 us | 191.110 us | 194.725 us | +| `event_batch` | `tdbin_decode_packed_framed` | 50 | 5223.633 ms | 194.652 us | 194.145 us | 195.373 us | +| `event_batch` | `protobuf_decode` | 50 | 5232.959 ms | 275.241 us | 273.705 us | 275.896 us | +| `event_batch` | `msgpack_decode` | 50 | 5230.549 ms | 372.344 us | 371.663 us | 373.583 us | ## Same-Mode Comparison @@ -148,27 +148,27 @@ Ratios are Protobuf median / TDBIN median; values above 1.00x favor TDBIN. The g | Fixture | TDBIN mode | Size winner | Encode ratio | Decode ratio | Gate | | ------------------ | ------------- | ----------- | -----------: | -----------: | ---- | -| `with_address` | bare | Protobuf | 0.56x | 1.00x | FAIL | -| `with_address` | framed | Protobuf | 0.54x | 1.00x | FAIL | -| `with_address` | packed framed | Protobuf | 0.25x | 0.27x | FAIL | -| `without_address` | bare | Protobuf | 0.53x | 0.73x | FAIL | -| `without_address` | framed | Protobuf | 0.50x | 0.71x | FAIL | -| `without_address` | packed framed | Protobuf | 0.23x | 0.10x | FAIL | -| `metric_batch` | bare | TDBIN | 4.01x | 5.00x | PASS | -| `metric_batch` | framed | TDBIN | 3.91x | 5.01x | PASS | -| `metric_batch` | packed framed | TDBIN | 1.97x | 1.05x | FAIL | -| `person_batch` | bare | TDBIN | 1.57x | 1.56x | PASS | -| `person_batch` | framed | TDBIN | 1.57x | 1.58x | PASS | -| `person_batch` | packed framed | TDBIN | 1.21x | 1.40x | FAIL | -| `contact_batch` | bare | TDBIN | 2.77x | 1.98x | PASS | -| `contact_batch` | framed | TDBIN | 2.77x | 1.98x | PASS | -| `contact_batch` | packed framed | TDBIN | 2.08x | 1.85x | PASS | -| `diagram_document` | bare | TDBIN | 1.77x | 1.54x | PASS | -| `diagram_document` | framed | TDBIN | 1.77x | 1.54x | PASS | -| `diagram_document` | packed framed | TDBIN | 1.13x | 1.38x | FAIL | -| `event_batch` | bare | TDBIN | 1.89x | 1.39x | FAIL | -| `event_batch` | framed | TDBIN | 1.89x | 1.39x | FAIL | -| `event_batch` | packed framed | TDBIN | 1.29x | 1.35x | FAIL | +| `with_address` | bare | Protobuf | 0.58x | 0.99x | FAIL | +| `with_address` | framed | Protobuf | 0.54x | 0.96x | FAIL | +| `with_address` | packed framed | Protobuf | 0.27x | 0.26x | FAIL | +| `without_address` | bare | Protobuf | 0.55x | 0.74x | FAIL | +| `without_address` | framed | Protobuf | 0.52x | 0.65x | FAIL | +| `without_address` | packed framed | Protobuf | 0.24x | 0.10x | FAIL | +| `metric_batch` | bare | TDBIN | 6.28x | 5.68x | PASS | +| `metric_batch` | framed | TDBIN | 6.96x | 5.71x | PASS | +| `metric_batch` | packed framed | TDBIN | 2.26x | 1.06x | FAIL | +| `person_batch` | bare | TDBIN | 1.83x | 1.58x | PASS | +| `person_batch` | framed | TDBIN | 1.85x | 1.59x | PASS | +| `person_batch` | packed framed | TDBIN | 1.33x | 1.41x | FAIL | +| `contact_batch` | bare | TDBIN | 2.70x | 1.96x | PASS | +| `contact_batch` | framed | TDBIN | 2.70x | 1.97x | PASS | +| `contact_batch` | packed framed | TDBIN | 2.07x | 1.85x | PASS | +| `diagram_document` | bare | TDBIN | 2.12x | 1.55x | PASS | +| `diagram_document` | framed | TDBIN | 2.12x | 1.58x | PASS | +| `diagram_document` | packed framed | TDBIN | 1.27x | 1.38x | FAIL | +| `event_batch` | bare | TDBIN | 2.06x | 1.41x | FAIL | +| `event_batch` | framed | TDBIN | 2.05x | 1.42x | FAIL | +| `event_batch` | packed framed | TDBIN | 1.37x | 1.41x | FAIL | Passing fixture/mode combinations: 9 of 21. diff --git a/docs/specs/tdbin-benchmarks.md b/docs/specs/tdbin-benchmarks.md index 490c301..9938c68 100644 --- a/docs/specs/tdbin-benchmarks.md +++ b/docs/specs/tdbin-benchmarks.md @@ -10,13 +10,13 @@ One row per test. Sizes are exact encoded bytes; "Serialize" is the whole ADT→ | Test | typeDiagram Size | Protobuf Size | MessagePack Size | typeDiagram Serialize | Protobuf Serialize | MessagePack Serialize | typeDiagram Deserialize | Protobuf Deserialize | MessagePack Deserialize | | ------------------ | ---------------: | ------------: | ---------------: | --------------------: | -----------------: | --------------------: | ----------------------: | -------------------: | ----------------------: | -| `with_address` | 172 | 79 | 142 | 87.51 ns | 46.84 ns | 252.33 ns | 153.21 ns | 152.49 ns | 195.31 ns | -| `without_address` | 124 | 31 | 100 | 65.76 ns | 32.68 ns | 228.84 ns | 71.52 ns | 50.66 ns | 109.68 ns | -| `metric_batch` | 43,788 | 84,149 | 90,440 | 11.637 us | 45.467 us | 43.944 us | 6.251 us | 31.317 us | 50.870 us | -| `person_batch` | 22,988 | 29,184 | 61,963 | 11.587 us | 18.195 us | 48.224 us | 36.786 us | 57.952 us | 87.828 us | -| `contact_batch` | 23,156 | 35,221 | 80,162 | 9.025 us | 24.957 us | 69.630 us | 31.045 us | 61.434 us | 95.623 us | -| `diagram_document` | 45,172 | 50,788 | 77,410 | 12.340 us | 21.809 us | 55.735 us | 71.705 us | 110.587 us | 134.250 us | -| `event_batch` | 116,372 | 131,744 | 230,620 | 35.490 us | 66.930 us | 138.809 us | 201.602 us | 279.422 us | 387.002 us | +| `with_address` | 172 | 79 | 142 | 86.21 ns | 46.82 ns | 240.50 ns | 157.32 ns | 150.50 ns | 192.39 ns | +| `without_address` | 124 | 31 | 100 | 63.80 ns | 32.86 ns | 227.40 ns | 76.85 ns | 50.06 ns | 108.75 ns | +| `metric_batch` | 43,788 | 84,149 | 90,440 | 6.557 us | 45.616 us | 43.619 us | 5.198 us | 29.664 us | 52.304 us | +| `person_batch` | 22,988 | 29,184 | 61,963 | 9.728 us | 17.986 us | 48.369 us | 35.157 us | 55.844 us | 87.035 us | +| `contact_batch` | 23,156 | 35,221 | 80,162 | 9.169 us | 24.761 us | 67.893 us | 30.351 us | 59.681 us | 92.548 us | +| `diagram_document` | 45,172 | 50,788 | 77,410 | 10.259 us | 21.699 us | 57.093 us | 69.248 us | 109.277 us | 130.848 us | +| `event_batch` | 116,372 | 131,744 | 230,620 | 34.150 us | 69.852 us | 139.987 us | 193.619 us | 275.241 us | 372.344 us | ## What the numbers show diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d71475 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool] + +[tool.basilisk] + +[tool.basilisk.rule-tags] +basilisk = "error" From 97c4e4ae81b4755e7cfb7c12ec2165e53ccd8d81 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:13:10 +1000 Subject: [PATCH 11/14] Stuff --- Claude.md | 2 +- coverage-thresholds.json | 6 +- docs/shared/intro.md | 4 +- package-lock.json | 97 ++-- .../cli/test/__snapshots__/alias-chain.svg | 60 ++- .../cli/test/__snapshots__/all-external.svg | 36 +- .../cli/test/__snapshots__/all-primitives.svg | 39 +- .../__snapshots__/chat-model-render.test.svg | 259 ++++------ .../cli/test/__snapshots__/deep-generics.svg | 70 ++- .../cli/test/__snapshots__/empty-diagram.svg | 6 +- .../cli/test/__snapshots__/long-names.svg | 27 +- .../cli/test/__snapshots__/many-nodes.svg | 136 +++-- .../cli/test/__snapshots__/mixed-union.svg | 40 +- .../cli/test/__snapshots__/multi-generics.svg | 73 ++- packages/cli/test/__snapshots__/self-ref.svg | 28 +- .../cli/test/__snapshots__/single-alias.svg | 24 +- .../cli/test/__snapshots__/single-record.svg | 27 +- .../cli/test/__snapshots__/single-union.svg | 37 +- .../cli/test/__snapshots__/small-example.svg | 130 +++-- .../test/__snapshots__/union-refs-union.svg | 58 +-- packages/typediagram/package.json | 11 +- packages/typediagram/src/editor/controls.ts | 161 ++++++ packages/typediagram/src/editor/effects.ts | 19 + packages/typediagram/src/editor/index.ts | 15 + packages/typediagram/src/editor/inspector.ts | 160 ++++++ .../typediagram/src/editor/source-editor.ts | 269 ++++++++++ packages/typediagram/src/editor/styles.ts | 41 ++ packages/typediagram/src/editor/viewport.ts | 163 ++++++ .../typediagram/src/editor/visual-editor.ts | 464 ++++++++++++++++++ packages/typediagram/src/render-svg/render.ts | 30 +- packages/typediagram/src/render-svg/theme.ts | 54 +- .../test/__snapshots__/render.test.ts.snap | 389 ++++++--------- .../test/converters/typescript-tdbin.test.ts | 2 +- packages/typediagram/test/editor.test.ts | 146 ++++++ packages/typediagram/test/render.test.ts | 4 +- .../typediagram/test/visual-editor.test.ts | 388 +++++++++++++++ packages/vscode/package.json | 4 +- packages/vscode/src/extension.ts | 38 +- packages/vscode/src/preview-panel.ts | 114 ++++- packages/vscode/src/webview-html.ts | 20 +- .../src/webview/interaction-protocol.ts | 80 +++ .../vscode/src/webview/interaction-test.ts | 341 +++++++++++++ packages/vscode/src/webview/main.ts | 168 +++++-- .../test/electron/harness/extension.cjs | 3 + .../vscode/test/electron/harness/package.json | 13 + packages/vscode/test/electron/runner.mjs | 96 +++- .../test/electron/suite/extension.spec.cjs | 106 +++- packages/vscode/test/electron/suite/index.cjs | 10 +- packages/vscode/test/extension.test.ts | 129 ++++- packages/vscode/test/helpers.ts | 1 + packages/vscode/test/vscode-mock.ts | 27 + packages/vscode/test/webview-html.test.ts | 23 + packages/web/e2e/visual-editor.spec.ts | 243 +++++++++ packages/web/eleventy/_data/docs.ts | 9 +- packages/web/eleventy/index.njk | 26 +- packages/web/package.json | 2 +- packages/web/src/playground.ts | 32 +- packages/web/src/render-pane.ts | 10 +- packages/web/src/styles.css | 2 +- packages/web/src/viewport.ts | 190 +------ .../web/test/__snapshots__/alias-chain.svg | 60 ++- .../web/test/__snapshots__/all-external.svg | 36 +- .../web/test/__snapshots__/all-primitives.svg | 39 +- .../__snapshots__/chat-model-render.test.svg | 259 ++++------ .../web/test/__snapshots__/deep-generics.svg | 70 ++- .../web/test/__snapshots__/empty-diagram.svg | 6 +- .../web/test/__snapshots__/long-names.svg | 27 +- .../web/test/__snapshots__/many-nodes.svg | 136 +++-- .../web/test/__snapshots__/mixed-union.svg | 40 +- .../web/test/__snapshots__/multi-generics.svg | 73 ++- packages/web/test/__snapshots__/self-ref.svg | 28 +- .../web/test/__snapshots__/single-alias.svg | 24 +- .../web/test/__snapshots__/single-record.svg | 27 +- .../web/test/__snapshots__/single-union.svg | 37 +- .../web/test/__snapshots__/small-example.svg | 130 +++-- .../test/__snapshots__/union-refs-union.svg | 58 +-- packages/web/test/render-pane.test.ts | 2 +- 77 files changed, 4446 insertions(+), 1768 deletions(-) create mode 100644 packages/typediagram/src/editor/controls.ts create mode 100644 packages/typediagram/src/editor/effects.ts create mode 100644 packages/typediagram/src/editor/index.ts create mode 100644 packages/typediagram/src/editor/inspector.ts create mode 100644 packages/typediagram/src/editor/source-editor.ts create mode 100644 packages/typediagram/src/editor/styles.ts create mode 100644 packages/typediagram/src/editor/viewport.ts create mode 100644 packages/typediagram/src/editor/visual-editor.ts create mode 100644 packages/typediagram/test/editor.test.ts create mode 100644 packages/typediagram/test/visual-editor.test.ts create mode 100644 packages/vscode/src/webview/interaction-protocol.ts create mode 100644 packages/vscode/src/webview/interaction-test.ts create mode 100644 packages/vscode/test/electron/harness/extension.cjs create mode 100644 packages/vscode/test/electron/harness/package.json create mode 100644 packages/web/e2e/visual-editor.spec.ts diff --git a/Claude.md b/Claude.md index 347f79a..d2383e0 100644 --- a/Claude.md +++ b/Claude.md @@ -7,7 +7,7 @@ Read this file in full. Rules below are NON-NEGOTIABLE — violations are reject ⚠️ **TOKEN ECONOMICS DISCIPLINE.** Check file size first. `Grep` over `Read`. Use `offset`/`limit`. Smallest diff that solves the problem. Delete dead code, unused imports, stale comments. Call out irrelevant context before proceeding. Bloat degrades reasoning. ⚠️ -⚠️ ACT AUTONOMOUSLY. DON'T ASK THE USER QUESTIONS. USE YOUR JUDGEMENT. ⚠️ +⚠️ ACT AUTONOMOUSLY. DON'T ASK THE USER QUESTIONS. USE YOUR JUDGEMENT. ⚠️ ⚠️ NEVER KILL ANY VSCODE PROCESS ⚠️ ## Project Overview diff --git a/coverage-thresholds.json b/coverage-thresholds.json index eee580d..606466e 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -4,10 +4,10 @@ "default_threshold": 90, "projects": { "packages/typediagram": { - "statements": 97.05, + "statements": 97.12, "branches": 91.05, "functions": 98.8, - "lines": 96.97 + "lines": 97.09 }, "packages/cli": { "statements": 99, @@ -23,7 +23,7 @@ }, "packages/vscode": { "statements": 99, - "branches": 98.41, + "branches": 99, "functions": 99, "lines": 99 } diff --git a/docs/shared/intro.md b/docs/shared/intro.md index e6dfcdc..cc8f824 100644 --- a/docs/shared/intro.md +++ b/docs/shared/intro.md @@ -3,10 +3,10 @@ typeDiagram is a tiny, language-neutral DSL for describing **algebraic data types** — records, tagged unions, generics, aliases. From one `.td` file, you get: - **Source code** in TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, and Protobuf — DTOs, data classes, discriminated unions, pattern-matchable enums — generated from the same definition, always in sync. -- **SVG diagrams** with automatic orthogonal layout — no dragging, no fiddling, versionable in git. +- **A visual type editor** with direct field editing, draggable nodes, relationship drawing, pan, zoom, auto-layout, and SVG export — backed by source you can version in git. - **Round-trip conversion** from existing TypeScript/Python/Rust/Go/C#/F#/Dart/PHP/Protobuf back to the DSL, so you can retrofit an existing codebase. -This is not a diagramming tool dressed up with a text input like Mermaid or PlantUML. typeDiagram is a **shared schema for your data model** — the diagram is a side effect, not the goal. The primary output is code, in as many languages as you need, kept strictly in sync by construction. +typeDiagram is a **shared schema for your data model with a first-class visual canvas**. The editor and source are two views of the same typed document, so every visual change stays ready for code generation in as many languages as you need. ### Why this matters diff --git a/package-lock.json b/package-lock.json index e092beb..6c921fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -312,6 +312,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -324,6 +325,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -335,6 +337,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -637,6 +640,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, @@ -725,6 +729,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -742,6 +747,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -759,6 +765,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -776,6 +783,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -793,6 +801,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -805,14 +814,12 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -825,14 +832,12 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -845,14 +850,12 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -865,14 +868,12 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -885,14 +886,12 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -905,14 +904,12 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -930,6 +927,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -944,6 +942,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", @@ -966,6 +965,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -983,6 +983,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1126,6 +1127,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1249,13 +1251,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/vscode": { - "version": "1.125.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", - "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -4167,6 +4162,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4188,6 +4184,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4209,6 +4206,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4230,6 +4228,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4251,6 +4250,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4267,14 +4267,12 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4291,14 +4289,12 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4315,14 +4311,12 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4339,14 +4333,12 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4368,6 +4360,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4389,6 +4382,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6695,6 +6689,21 @@ "node": ">=14.17" } }, + "node_modules/typescript-compiler": { + "name": "typescript", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/typescript-eslint": { "version": "8.63.0", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", @@ -7376,6 +7385,7 @@ "esbuild": "^0.28.1", "happy-dom": "^20.10.6", "typescript": "^7.0.2", + "typescript-compiler": "npm:typescript@6.0.3", "vitest": "^4.1.10" } }, @@ -7853,14 +7863,14 @@ "license": "MIT", "dependencies": { "markdown-it": "^14.3.0", - "typediagram-core": "0.9.0" + "typediagram-core": "0.0.0-dev" }, "devDependencies": { "@types/markdown-it": "^14.1.2", "@types/mocha": "^10.0.10", "@types/node": "^26.1.1", "@types/pdfkit": "^0.17.6", - "@types/vscode": "^1.125.0", + "@types/vscode": "1.99.1", "@vscode/test-electron": "^3.0.0", "esbuild": "^0.28.1", "mocha": "^11.7.6", @@ -8266,6 +8276,13 @@ "node": ">=18" } }, + "packages/vscode/node_modules/@types/vscode": { + "version": "1.99.1", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.1.tgz", + "integrity": "sha512-cQlqxHZ040ta6ovZXnXRxs3fJiTmlurkIWOfZVcLSZPcm9J4ikFpXuB7gihofGn5ng+kDVma5EmJIclfk0trPQ==", + "dev": true, + "license": "MIT" + }, "packages/vscode/node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -8348,7 +8365,7 @@ "version": "0.0.0-dev", "dependencies": { "marked": "^18.0.6", - "typediagram-core": "0.9.0" + "typediagram-core": "0.0.0-dev" }, "devDependencies": { "@11ty/eleventy": "^3.1.6", diff --git a/packages/cli/test/__snapshots__/alias-chain.svg b/packages/cli/test/__snapshots__/alias-chain.svg index 5cc1edf..c6ef753 100644 --- a/packages/cli/test/__snapshots__/alias-chain.svg +++ b/packages/cli/test/__snapshots__/alias-chain.svg @@ -1,45 +1,41 @@ - + + + + - - - - - - - alias Email + + + + + + + + + alias Email - -= String + = String - - - - - - - alias UserEmail + + + + + + alias UserEmail - -= Email + = Email - - - - - - - alias AdminEmail + + + + + + alias AdminEmail - -= UserEmail + = UserEmail - - - - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/all-external.svg b/packages/cli/test/__snapshots__/all-external.svg index 9d292cc..d2e759c 100644 --- a/packages/cli/test/__snapshots__/all-external.svg +++ b/packages/cli/test/__snapshots__/all-external.svg @@ -1,28 +1,26 @@ - + + + + - - - - - - - HttpRequest + + + + + + + + HttpRequest - -url: URL - -method: HttpMethod - -headers: Map<String, String> - -body: Option<Bytes> - -timeout: Duration + url: URL +method: HttpMethod +headers: Map<String, String> +body: Option<Bytes> +timeout: Duration - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/all-primitives.svg b/packages/cli/test/__snapshots__/all-primitives.svg index c2ea64e..a147a0d 100644 --- a/packages/cli/test/__snapshots__/all-primitives.svg +++ b/packages/cli/test/__snapshots__/all-primitives.svg @@ -1,30 +1,27 @@ - + + + + - - - - - - - AllPrimitives + + + + + + + + AllPrimitives - -b: Bool - -i: Int - -f: Float - -s: String - -by: Bytes - -u: Unit + b: Bool +i: Int +f: Float +s: String +by: Bytes +u: Unit - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/chat-model-render.test.svg b/packages/cli/test/__snapshots__/chat-model-render.test.svg index 8723f2d..a4b5238 100644 --- a/packages/cli/test/__snapshots__/chat-model-render.test.svg +++ b/packages/cli/test/__snapshots__/chat-model-render.test.svg @@ -1,173 +1,128 @@ - + + + + - - - - - - - ChatRequest + +tool_results +tool_results +tool_results +tool_results +content +List.items +Text.value +Uri.value +kind +media_type + + + + + + ChatRequest - -message: String - -session_id: String - -tool_results: Option<List<ToolResult>> + message: String +session_id: String +tool_results: Option<List<ToolResult>> - - - - - - - ChatTurnInput + + + + + + ChatTurnInput - -config: AgentConfig - -user_message: String - -tool_results: Option<List<ToolResult>> - -session_id: String + config: AgentConfig +user_message: String +tool_results: Option<List<ToolResult>> +session_id: String - - - - - - - ToolResult + + + + + + ToolResult - -tool_call_id: String - -name: String - -content: ToolResultContent - -ok: Bool + tool_call_id: String +name: String +content: ToolResultContent +ok: Bool - - - - - - - union ToolResultContent - ONE OF - - -◇ None - -◇ Scalar { value: String } - -◇ Dict { entries: Map<String, String> } - -◇ List { items: List<ContentItem> } + + + + + + union ToolResultContent + ONE OF + + ◇ None +◇ Scalar { value: String } +◇ Dict { entries: Map<String, String> } +◇ List { items: List<ContentItem> } - - - - - - - union ContentItem - ONE OF - - -◇ Text { value: TextPart } - -◇ Uri { value: UriPart } - -◇ Scalar { value: String } + + + + + + union ContentItem + ONE OF + + ◇ Text { value: TextPart } +◇ Uri { value: UriPart } +◇ Scalar { value: String } - - - - - - - TextPart + + + + + + TextPart - -text: String + text: String - - - - - - - UriPart + + + + + + UriPart - -url: String - -kind: UriKind - -media_type: Option<String> + url: String +kind: UriKind +media_type: Option<String> - - - - - - - union UriKind - ONE OF - - -◇ Image - -◇ Audio - -◇ Video - -◇ Document - -◇ Web - -◇ Api + + + + + + union UriKind + ONE OF + + ◇ Image +◇ Audio +◇ Video +◇ Document +◇ Web +◇ Api - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - -tool_results - -tool_results - -tool_results - -tool_results - -content - -List.items - -Text.value - -Uri.value - -kind - -media_type \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/deep-generics.svg b/packages/cli/test/__snapshots__/deep-generics.svg index b26488b..7ddecdd 100644 --- a/packages/cli/test/__snapshots__/deep-generics.svg +++ b/packages/cli/test/__snapshots__/deep-generics.svg @@ -1,50 +1,44 @@ - + + + + - - - - - - - Config + +rules +rules + + + + + + Config - -rules: Map<String, List<Option<Rule>>> + rules: Map<String, List<Option<Rule>>> - - - - - - - Rule + + + + + + Rule - -name: String - -priority: Int + name: String +priority: Int - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - -rules - -rules \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/empty-diagram.svg b/packages/cli/test/__snapshots__/empty-diagram.svg index 9dea812..02b5235 100644 --- a/packages/cli/test/__snapshots__/empty-diagram.svg +++ b/packages/cli/test/__snapshots__/empty-diagram.svg @@ -1,10 +1,14 @@ - + + + + + \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/long-names.svg b/packages/cli/test/__snapshots__/long-names.svg index 4bb1b05..e7e2bab 100644 --- a/packages/cli/test/__snapshots__/long-names.svg +++ b/packages/cli/test/__snapshots__/long-names.svg @@ -1,22 +1,23 @@ - + + + + - - - - - - - VeryLongTypeNameThatShouldNotBreakLayout + + + + + + + + VeryLongTypeNameThatShouldNotBreakLayout - -this_is_an_extremely_long_field_name_that_tests_rendering: String - -short: Int + this_is_an_extremely_long_field_name_that_tests_rendering: String +short: Int - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/many-nodes.svg b/packages/cli/test/__snapshots__/many-nodes.svg index 2307747..ff5b3f7 100644 --- a/packages/cli/test/__snapshots__/many-nodes.svg +++ b/packages/cli/test/__snapshots__/many-nodes.svg @@ -1,97 +1,81 @@ - + + + + - - - - - - - A + +x +x +x +x +x +x + + + + + + A - -x: B + x: B - - - - - - - B + + + + + + B - -x: C + x: C - - - - - - - C + + + + + + C - -x: D + x: D - - - - - - - D + + + + + + D - -x: E + x: E - - - - - - - E + + + + + + E - -x: F + x: F - - - - - - - F + + + + + + F - -x: G + x: G - - - - - - - G + + + + + + G - -x: String + x: String - -x - -x - -x - -x - -x - -x \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/mixed-union.svg b/packages/cli/test/__snapshots__/mixed-union.svg index 8627e7c..3d6c748 100644 --- a/packages/cli/test/__snapshots__/mixed-union.svg +++ b/packages/cli/test/__snapshots__/mixed-union.svg @@ -1,29 +1,27 @@ - + + + + - - - - - - - union Event - ONE OF - - -◇ Click { x: Int, y: Int } - -◇ KeyPress { key: String, modifiers: List<String> } - -◇ Scroll { deltaX: Float, deltaY: Float } - -◇ Focus - -◇ Blur - + + + + + + + union Event + ONE OF + + ◇ Click { x: Int, y: Int } +◇ KeyPress { key: String, modifiers: List<String> } +◇ Scroll { deltaX: Float, deltaY: Float } +◇ Focus +◇ Blur + \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/multi-generics.svg b/packages/cli/test/__snapshots__/multi-generics.svg index 2bf6b29..4236053 100644 --- a/packages/cli/test/__snapshots__/multi-generics.svg +++ b/packages/cli/test/__snapshots__/multi-generics.svg @@ -1,50 +1,45 @@ - + + + + - - - - - - - Pair<A, B> + + + + + + + + Pair<A, B> - -first: A - -second: B + first: A +second: B - - - - - - - union Either<L, R> - ONE OF - - -◇ Left { value: L } - -◇ Right { value: R } + + + + + + union Either<L, R> + ONE OF + + ◇ Left { value: L } +◇ Right { value: R } - - - - - - - union Result<T, E> - ONE OF - - -◇ Ok { value: T } - -◇ Err { error: E } + + + + + + union Result<T, E> + ONE OF + + ◇ Ok { value: T } +◇ Err { error: E } - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/self-ref.svg b/packages/cli/test/__snapshots__/self-ref.svg index 31f90f5..0220b28 100644 --- a/packages/cli/test/__snapshots__/self-ref.svg +++ b/packages/cli/test/__snapshots__/self-ref.svg @@ -1,23 +1,23 @@ - + + + + - - - - - - - TreeNode + +children + + + + + + TreeNode - -value: String - -children: List<TreeNode> + value: String +children: List<TreeNode> - -children \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/single-alias.svg b/packages/cli/test/__snapshots__/single-alias.svg index 3a4dd4b..b8404eb 100644 --- a/packages/cli/test/__snapshots__/single-alias.svg +++ b/packages/cli/test/__snapshots__/single-alias.svg @@ -1,20 +1,22 @@ - + + + + - - - - - - - alias UserId + + + + + + + + alias UserId - -= String + = String - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/single-record.svg b/packages/cli/test/__snapshots__/single-record.svg index 073141a..a77c701 100644 --- a/packages/cli/test/__snapshots__/single-record.svg +++ b/packages/cli/test/__snapshots__/single-record.svg @@ -1,22 +1,23 @@ - + + + + - - - - - - - Point + + + + + + + + Point - -x: Float - -y: Float + x: Float +y: Float - \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/single-union.svg b/packages/cli/test/__snapshots__/single-union.svg index 0f20372..125ce62 100644 --- a/packages/cli/test/__snapshots__/single-union.svg +++ b/packages/cli/test/__snapshots__/single-union.svg @@ -1,27 +1,26 @@ - + + + + - - - - - - - union Direction - ONE OF - - -◇ North - -◇ South - -◇ East - -◇ West - + + + + + + + union Direction + ONE OF + + ◇ North +◇ South +◇ East +◇ West + \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/small-example.svg b/packages/cli/test/__snapshots__/small-example.svg index c338eac..d496ea6 100644 --- a/packages/cli/test/__snapshots__/small-example.svg +++ b/packages/cli/test/__snapshots__/small-example.svg @@ -1,89 +1,71 @@ - + + + + - - - - - - - User + +email +email +address + + + + + + User - -id: UUID - -name: String - -email: Option<Email> - -roles: List<Role> - -address: Address + id: UUID +name: String +email: Option<Email> +roles: List<Role> +address: Address - - - - - - - Address + + + + + + Address - -line1: String - -city: String - -country: CountryCode + line1: String +city: String +country: CountryCode - - - - - - - union Shape - ONE OF - - -◇ Circle { radius: Float } - -◇ Square { side: Float } - -◇ Triangle { a: Float, b: Float, c: Float } + + + + + + union Shape + ONE OF + + ◇ Circle { radius: Float } +◇ Square { side: Float } +◇ Triangle { a: Float, b: Float, c: Float } - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - - - - - - - alias Email + + + + + + alias Email - -= String + = String - -email - -email - -address \ No newline at end of file diff --git a/packages/cli/test/__snapshots__/union-refs-union.svg b/packages/cli/test/__snapshots__/union-refs-union.svg index 6d4bf26..752d420 100644 --- a/packages/cli/test/__snapshots__/union-refs-union.svg +++ b/packages/cli/test/__snapshots__/union-refs-union.svg @@ -1,40 +1,36 @@ - + + + + - - - - - - - union Outer - ONE OF - - -◇ Leaf { value: String } - -◇ Nested { inner: Inner } + +Nested.inner + + + + + + union Outer + ONE OF + + ◇ Leaf { value: String } +◇ Nested { inner: Inner } - - - - - - - union Inner - ONE OF - - -◇ A - -◇ B { data: Int } - -◇ C { label: String, count: Int } + + + + + + union Inner + ONE OF + + ◇ A +◇ B { data: Int } +◇ C { label: String, count: Int } - -Nested.inner \ No newline at end of file diff --git a/packages/typediagram/package.json b/packages/typediagram/package.json index c99fc8a..d5dbd90 100644 --- a/packages/typediagram/package.json +++ b/packages/typediagram/package.json @@ -36,6 +36,10 @@ "types": "./dist/render-svg/index.d.ts", "import": "./dist/render-svg/index.js" }, + "./editor": { + "types": "./dist/editor/index.d.ts", + "import": "./dist/editor/index.js" + }, "./converters/rust-tdbin": { "types": "./dist/converters/rust-tdbin.d.ts", "import": "./dist/converters/rust-tdbin.js" @@ -67,10 +71,11 @@ "elkjs": "^0.11.1" }, "devDependencies": { + "@types/node": "^26.1.1", "esbuild": "^0.28.1", + "happy-dom": "^20.10.6", "typescript": "^7.0.2", - "vitest": "^4.1.10", - "@types/node": "^26.1.1", - "happy-dom": "^20.10.6" + "typescript-compiler": "npm:typescript@6.0.3", + "vitest": "^4.1.10" } } diff --git a/packages/typediagram/src/editor/controls.ts b/packages/typediagram/src/editor/controls.ts new file mode 100644 index 0000000..66b4abe --- /dev/null +++ b/packages/typediagram/src/editor/controls.ts @@ -0,0 +1,161 @@ +// [EDITOR-CONTROLS] Shared compact canvas controls, semantic legend, and inspector shell. +import type { DeclarationKind } from "./source-editor.js"; + +export type CanvasActions = { + addNode: (kind: DeclarationKind) => void; + zoomIn: () => void; + zoomOut: () => void; + reset: () => void; + fit: () => void; + clearLayout: () => void; + exportSvg: () => void; +}; + +export type CanvasChrome = { + inspector: HTMLElement; + inspectorBody: HTMLElement; + inspectorKind: HTMLElement; + toast: HTMLElement; + setZoom: (scale: number) => void; +}; + +const button = (label: string, title: string, action: () => void) => { + const element = document.createElement("button"); + element.type = "button"; + element.className = "td-canvas-button"; + element.textContent = label; + element.title = title; + element.setAttribute("aria-label", title); + element.setAttribute("data-td-interactive", "true"); + element.addEventListener("click", action); + return element; +}; + +const separator = () => { + const element = document.createElement("span"); + element.className = "td-canvas-separator"; + element.setAttribute("aria-hidden", "true"); + return element; +}; + +export const closeIcon = () => { + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + icon.setAttribute("viewBox", "0 0 16 16"); + icon.setAttribute("aria-hidden", "true"); + icon.setAttribute("focusable", "false"); + path.setAttribute("d", "M4 4l8 8M12 4l-8 8"); + icon.appendChild(path); + return icon; +}; + +const nodeKindButton = (kind: DeclarationKind, label: string, actions: CanvasActions, creator: HTMLElement) => { + const current = button(label, `Add ${kind} type`, () => { + actions.addNode(kind); + creator.hidden = true; + }); + current.classList.add("td-node-kind-button"); + return current; +}; + +const createNodeCreator = (container: HTMLElement, actions: CanvasActions) => { + const creator = document.createElement("div"); + creator.className = "td-node-creator"; + creator.hidden = true; + creator.setAttribute("data-td-interactive", "true"); + creator.setAttribute("aria-label", "Choose type kind"); + creator.append( + nodeKindButton("record", "Record", actions, creator), + nodeKindButton("union", "Union", actions, creator), + nodeKindButton("alias", "Alias", actions, creator) + ); + container.appendChild(creator); + return creator; +}; + +const createToolbar = (container: HTMLElement, actions: CanvasActions, creator: HTMLElement) => { + const toolbar = document.createElement("div"); + const zoom = button("100%", "Current zoom", () => undefined); + const addType = button("+ Type", "Add type", () => (creator.hidden = !creator.hidden)); + toolbar.className = "td-canvas-toolbar"; + toolbar.setAttribute("role", "toolbar"); + toolbar.setAttribute("aria-label", "Canvas controls"); + zoom.classList.add("td-zoom-value"); + addType.classList.add("td-add-node"); + toolbar.append( + addType, + separator(), + button("−", "Zoom out", actions.zoomOut), + zoom, + button("+", "Zoom in", actions.zoomIn), + separator(), + button("Fit", "Fit diagram to view", actions.fit), + button("1:1", "Reset canvas", actions.reset), + button("Auto", "Restore automatic layout", actions.clearLayout), + separator(), + button("SVG", "Export SVG", actions.exportSvg) + ); + container.appendChild(toolbar); + return zoom; +}; + +const legendItem = (label: string, modifier: string) => { + const item = document.createElement("span"); + const swatch = document.createElement("i"); + item.className = "td-legend-item"; + swatch.className = `td-legend-swatch ${modifier}`.trim(); + item.append(swatch, document.createTextNode(label)); + return item; +}; + +const createLegend = (container: HTMLElement) => { + const legend = document.createElement("div"); + legend.className = "td-canvas-legend"; + legend.setAttribute("aria-label", "Diagram legend"); + legend.append(legendItem("Type", ""), legendItem("Union", "td-legend-union"), legendItem("Alias", "td-legend-alias")); + container.appendChild(legend); +}; + +const createInspector = (container: HTMLElement) => { + const inspector = document.createElement("aside"); + const head = document.createElement("header"); + const kind = document.createElement("span"); + const close = button("×", "Close properties", () => (inspector.hidden = true)); + const body = document.createElement("div"); + inspector.className = "td-inspector"; + inspector.hidden = true; + inspector.setAttribute("data-td-interactive", "true"); + head.className = "td-inspector-head"; + kind.className = "td-inspector-kind"; + close.className = "td-icon-button td-inspector-close"; + close.replaceChildren(closeIcon()); + body.className = "td-inspector-body"; + head.append(kind, close); + inspector.append(head, body); + container.appendChild(inspector); + return { inspector, body, kind }; +}; + +const createToast = (container: HTMLElement) => { + const toast = document.createElement("output"); + toast.className = "td-editor-toast"; + toast.hidden = true; + toast.setAttribute("aria-live", "polite"); + container.appendChild(toast); + return toast; +}; + +export const createCanvasChrome = (container: HTMLElement, actions: CanvasActions): CanvasChrome => { + const creator = createNodeCreator(container, actions); + const zoom = createToolbar(container, actions, creator); + const { inspector, body, kind } = createInspector(container); + const toast = createToast(container); + createLegend(container); + return { + inspector, + inspectorBody: body, + inspectorKind: kind, + toast, + setZoom: (scale) => (zoom.textContent = `${String(Math.round(scale * 100))}%`), + }; +}; diff --git a/packages/typediagram/src/editor/effects.ts b/packages/typediagram/src/editor/effects.ts new file mode 100644 index 0000000..2729dac --- /dev/null +++ b/packages/typediagram/src/editor/effects.ts @@ -0,0 +1,19 @@ +// [EDITOR-EFFECTS] Explicit conditional effects without statement-level conditionals. +export const runWhen = (condition: boolean, effect: () => void) => { + switch (condition) { + case true: + effect(); + break; + default: + break; + } +}; + +export const runWhenDefined = (value: T | undefined, effect: (current: T) => void) => { + switch (value) { + case undefined: + break; + default: + effect(value); + } +}; diff --git a/packages/typediagram/src/editor/index.ts b/packages/typediagram/src/editor/index.ts new file mode 100644 index 0000000..0844ad8 --- /dev/null +++ b/packages/typediagram/src/editor/index.ts @@ -0,0 +1,15 @@ +export { + addDeclaration, + addRow, + connectDeclarations, + editRow, + removeDeclaration, + removeRow, + renameDeclaration, + type DeclarationKind, + type EditorFailure, + type RowPatch, +} from "./source-editor.js"; +export { createVisualEditor, type NodePosition, type VisualEditor, type VisualEditorOptions } from "./visual-editor.js"; +export { createViewport, setViewportContent, type ViewportControls, type ViewportState } from "./viewport.js"; +export { installVisualEditorStyles, VISUAL_EDITOR_CSS } from "./styles.js"; diff --git a/packages/typediagram/src/editor/inspector.ts b/packages/typediagram/src/editor/inspector.ts new file mode 100644 index 0000000..81b519b --- /dev/null +++ b/packages/typediagram/src/editor/inspector.ts @@ -0,0 +1,160 @@ +import type { Result } from "../result.js"; +import { + addRow, + editRow, + removeDeclaration, + removeRow, + renameDeclaration, + type EditorFailure, +} from "./source-editor.js"; +import { closeIcon, type CanvasChrome } from "./controls.js"; +import { runWhen } from "./effects.js"; +import type { VisualEditorOptions } from "./visual-editor.js"; + +export type EditorRow = { name: string; type: string }; +export type EditorNode = { name: string; kind: "record" | "union" | "alias"; rows: EditorRow[] }; +type RowContext = { + node: EditorNode; + getSource: () => string; + mutate: (result: Result) => void; +}; + +const showFailure = (chrome: CanvasChrome, failure: EditorFailure) => { + chrome.toast.textContent = failure.message; + chrome.toast.hidden = false; + globalThis.setTimeout(() => { + chrome.toast.hidden = true; + }, 2600); +}; + +export const applyMutation = ( + result: Result, + options: VisualEditorOptions, + chrome: CanvasChrome +) => { + switch (result.ok) { + case true: + options.onSourceChange(result.value); + break; + case false: + showFailure(chrome, result.error); + } +}; + +const field = (labelText: string, value: string, onChange: (value: string) => void) => { + const wrap = document.createElement("label"); + const label = document.createElement("span"); + const input = document.createElement("input"); + label.textContent = labelText; + input.value = value; + input.setAttribute("data-td-interactive", "true"); + input.addEventListener("change", () => { + onChange(input.value); + }); + wrap.append(label, input); + return wrap; +}; + +const removeButton = (action: () => void) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "td-icon-button td-inspector-remove"; + button.title = "Remove row"; + button.setAttribute("aria-label", "Remove row"); + button.setAttribute("data-td-interactive", "true"); + button.appendChild(closeIcon()); + button.addEventListener("click", action); + return button; +}; + +const removeControls = ( + node: EditorNode, + index: number, + getSource: () => string, + mutate: (result: Result) => void +) => + node.kind === "alias" + ? [] + : [ + removeButton(() => { + mutate(removeRow(getSource(), node.name, index)); + }), + ]; + +const inspectorRow = (row: EditorRow, index: number, context: RowContext) => { + const { node, getSource, mutate } = context; + const element = document.createElement("div"); + element.className = `td-inspector-row${node.kind === "union" ? " td-inspector-row--union" : ""}`; + element.append( + field(node.kind === "union" ? "Variant" : "Field", row.name, (name) => { + mutate(editRow(getSource(), node.name, index, { name })); + }), + field(node.kind === "union" ? "Payload" : "Type", row.type, (type) => { + mutate(editRow(getSource(), node.name, index, { type })); + }), + ...removeControls(node, index, getSource, mutate) + ); + return element; +}; + +const addButton = (action: () => void) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "td-inspector-add"; + button.textContent = "+ Add row"; + button.addEventListener("click", action); + return button; +}; + +const appendAddButton = ( + node: EditorNode, + chrome: CanvasChrome, + options: VisualEditorOptions, + mutate: (result: Result) => void +) => { + runWhen(node.kind !== "alias", () => { + chrome.inspectorBody.appendChild( + addButton(() => { + mutate(addRow(options.getSource(), node.name)); + }) + ); + }); +}; + +const appendDeleteButton = ( + node: EditorNode, + chrome: CanvasChrome, + options: VisualEditorOptions, + mutate: (result: Result) => void +) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "td-inspector-delete"; + button.textContent = "Delete type"; + button.setAttribute("aria-label", `Delete ${node.name}`); + button.addEventListener("click", () => { + const result = removeDeclaration(options.getSource(), node.name); + mutate(result); + runWhen(result.ok, () => { + chrome.inspector.hidden = true; + }); + }); + chrome.inspectorBody.appendChild(button); +}; + +export const renderInspector = (node: EditorNode, chrome: CanvasChrome, options: VisualEditorOptions) => { + const mutate = (result: Result) => { + applyMutation(result, options, chrome); + }; + const context = { node, getSource: options.getSource, mutate }; + chrome.inspectorKind.textContent = node.kind; + chrome.inspectorBody.replaceChildren( + field("Declaration", node.name, (name) => { + mutate(renameDeclaration(options.getSource(), node.name, name)); + }), + ...node.rows.map((row, index) => inspectorRow(row, index, context)) + ); + appendAddButton(node, chrome, options, mutate); + appendDeleteButton(node, chrome, options, mutate); + chrome.inspector.hidden = false; +}; diff --git a/packages/typediagram/src/editor/source-editor.ts b/packages/typediagram/src/editor/source-editor.ts new file mode 100644 index 0000000..99f561d --- /dev/null +++ b/packages/typediagram/src/editor/source-editor.ts @@ -0,0 +1,269 @@ +// [EDITOR-SOURCE] Model-backed source mutations used by every visual-editor host. +import { parse, formatDiagnostics } from "../parser/index.js"; +import type { TypeRef } from "../parser/ast.js"; +import { buildModel } from "../model/build.js"; +import { printSource } from "../model/print.js"; +import type { Model, ResolvedDecl, ResolvedTypeRef } from "../model/types.js"; +import { err, ok } from "../result.js"; +import { runWhen, runWhenDefined } from "./effects.js"; + +export type EditorFailure = { message: string }; +export type RowPatch = { name?: string; type?: string }; +export type DeclarationKind = ResolvedDecl["kind"]; + +const editorModel = (source: string) => { + const parsed = parse(source); + const built = parsed.ok ? buildModel(parsed.value) : undefined; + return !parsed.ok + ? err({ message: formatDiagnostics([...parsed.error]) }) + : built?.ok === true + ? ok(built.value) + : err({ message: formatDiagnostics([...(built?.error ?? [])]) }); +}; + +const modelOrFailure = (source: string) => { + const built = editorModel(source); + return built.ok ? ok(built.value) : err({ message: built.error.message }); +}; + +const findDecl = (model: Model, name: string) => model.decls.find((decl) => decl.name === name); + +const missingDecl = (name: string) => err({ message: `Unknown declaration '${name}'` }); + +const sourceResult = (model: Model, decl: ResolvedDecl | undefined, name: string) => + decl === undefined ? missingDecl(name) : ok(printSource(model)); + +const unresolvedRef = (ref: TypeRef): ResolvedTypeRef => ({ + name: ref.name, + args: ref.args.map(unresolvedRef), + resolution: { kind: "external" }, +}); + +const parseTypeRef = (source: string) => { + const parsed = parse(`typeDiagram\ntype __EditorProbe { value: ${source} }`); + const decl = parsed.ok ? parsed.value.decls[0] : undefined; + const field = decl?.kind === "record" ? decl.fields[0] : undefined; + return field === undefined + ? err({ + message: parsed.ok ? `Invalid type '${source}'` : formatDiagnostics([...parsed.error]), + }) + : ok(unresolvedRef(field.type)); +}; + +const renameRef = (ref: ResolvedTypeRef, before: string, after: string) => { + ref.name = ref.name === before ? after : ref.name; + ref.args.forEach((arg) => { + renameRef(arg, before, after); + }); + ref.resolution = + ref.resolution.kind === "declared" && ref.resolution.declName === before + ? { kind: "declared", declName: after } + : ref.resolution; +}; + +const visitRefs = (decl: ResolvedDecl, visit: (ref: ResolvedTypeRef) => void) => { + const refs = + decl.kind === "record" + ? decl.fields.map((field) => field.type) + : decl.kind === "union" + ? decl.variants.flatMap((variant) => variant.fields.map((field) => field.type)) + : [decl.target]; + refs.forEach(visit); +}; + +const uniqueName = (model: Model, base: string, suffix = 1): string => { + const name = suffix === 1 ? base : `${base}${String(suffix)}`; + return findDecl(model, name) === undefined ? name : uniqueName(model, base, suffix + 1); +}; + +const stringRef = (): ResolvedTypeRef => ({ + name: "String", + args: [], + resolution: { kind: "primitive" }, +}); + +const newDeclaration = (model: Model, kind: DeclarationKind): ResolvedDecl => { + switch (kind) { + case "record": + return { kind, name: uniqueName(model, "NewRecord"), generics: [], fields: [{ name: "field", type: stringRef() }] }; + case "union": + return { kind, name: uniqueName(model, "NewUnion"), generics: [], variants: [{ name: "Variant", fields: [] }] }; + case "alias": + return { kind, name: uniqueName(model, "NewAlias"), generics: [], target: stringRef() }; + } +}; + +export const addDeclaration = (source: string, kind: DeclarationKind) => { + const result = modelOrFailure(source); + const model = result.ok ? result.value : undefined; + runWhenDefined(model, (current) => { + current.decls.push(newDeclaration(current, kind)); + }); + return result.ok ? ok(printSource(result.value)) : result; +}; + +export const removeDeclaration = (source: string, name: string) => { + const result = modelOrFailure(source); + const model = result.ok ? result.value : undefined; + const index = model?.decls.findIndex((decl) => decl.name === name) ?? -1; + runWhenDefined(model !== undefined && index >= 0 ? model : undefined, (current) => { + current.decls.splice(index, 1); + }); + return !result.ok ? result : index < 0 ? missingDecl(name) : ok(printSource(result.value)); +}; + +export const renameDeclaration = (source: string, before: string, after: string) => { + const result = modelOrFailure(source); + const decl = result.ok ? findDecl(result.value, before) : undefined; + const valid = after.trim().length > 0; + return !result.ok + ? result + : decl === undefined || !valid + ? missingDecl(before) + : renameInModel(result.value, decl, before, after.trim()); +}; + +const renameInModel = (model: Model, decl: ResolvedDecl, before: string, after: string) => { + decl.name = after; + model.decls.forEach((candidate) => { + visitRefs(candidate, (ref) => { + renameRef(ref, before, after); + }); + }); + return ok(printSource(model)); +}; + +const replaceRowType = (decl: ResolvedDecl, rowIndex: number, type: ResolvedTypeRef) => { + const row = decl.kind === "record" ? decl.fields[rowIndex] : undefined; + const variant = decl.kind === "union" ? decl.variants[rowIndex] : undefined; + runWhenDefined(row, (current) => { + current.type = type; + }); + runWhen(decl.kind === "alias", () => { + switch (decl.kind) { + case "alias": + decl.target = type; + break; + } + }); + runWhenDefined(variant, (current) => { + replaceVariantType(current, type); + }); +}; + +const replaceVariantType = ( + variant: Extract["variants"][number], + type: ResolvedTypeRef +) => { + switch (variant.fields.length) { + case 0: + variant.fields.push({ name: "_0", type }); + break; + default: + variant.fields[0] = { name: variant.fields[0]?.name ?? "_0", type }; + } +}; + +const renameRow = (decl: ResolvedDecl, rowIndex: number, name: string) => { + const row = decl.kind === "record" ? decl.fields[rowIndex] : undefined; + const variant = decl.kind === "union" ? decl.variants[rowIndex] : undefined; + runWhenDefined(row, (current) => { + current.name = name; + }); + runWhenDefined(variant, (current) => { + current.name = name; + }); +}; + +const applyRowPatch = (decl: ResolvedDecl, rowIndex: number, patch: RowPatch) => { + runWhenDefined(patch.name, (name) => { + renameRow(decl, rowIndex, name.trim()); + }); + return patch.type === undefined ? ok(undefined) : applyTypePatch(decl, rowIndex, patch.type); +}; + +const applyTypePatch = (decl: ResolvedDecl, rowIndex: number, typeSource: string) => { + const parsed = parseTypeRef(typeSource.trim()); + switch (parsed.ok) { + case true: + replaceRowType(decl, rowIndex, parsed.value); + break; + } + return parsed; +}; + +export const editRow = (source: string, declName: string, rowIndex: number, patch: RowPatch) => { + const result = modelOrFailure(source); + const decl = result.ok ? findDecl(result.value, declName) : undefined; + const edited = decl === undefined ? missingDecl(declName) : applyRowPatch(decl, rowIndex, patch); + return !result.ok ? result : !edited.ok ? edited : sourceResult(result.value, decl, declName); +}; + +const anyRef = (): ResolvedTypeRef => ({ name: "Any", args: [], resolution: { kind: "external" } }); + +const targetRef = (name: string, genericCount = 0): ResolvedTypeRef => ({ + name, + args: Array.from({ length: genericCount }, anyRef), + resolution: { kind: "declared", declName: name }, +}); + +const fieldNameFor = (target: string) => `${target.slice(0, 1).toLowerCase()}${target.slice(1)}`; + +const appendConnection = (decl: ResolvedDecl, target: string, genericCount: number) => { + const type = targetRef(target, genericCount); + switch (decl.kind) { + case "record": + decl.fields.push({ name: fieldNameFor(target), type }); + break; + case "union": + decl.variants.push({ name: target, fields: [{ name: "_0", type }] }); + break; + case "alias": + decl.target = type; + } +}; + +export const connectDeclarations = (source: string, from: string, rowIndex: number, target: string) => { + const result = modelOrFailure(source); + const decl = result.ok ? findDecl(result.value, from) : undefined; + const targetDecl = result.ok ? findDecl(result.value, target) : undefined; + const exists = decl !== undefined && targetDecl !== undefined; + const genericCount = targetDecl?.generics.length ?? 0; + runWhenDefined(exists && rowIndex < 0 ? decl : undefined, (current) => { + appendConnection(current, target, genericCount); + }); + runWhenDefined(exists && rowIndex >= 0 ? decl : undefined, (current) => { + replaceRowType(current, rowIndex, targetRef(target, genericCount)); + }); + return !result.ok ? result : exists ? ok(printSource(result.value)) : missingDecl(decl === undefined ? from : target); +}; + +export const addRow = (source: string, declName: string) => { + const result = modelOrFailure(source); + const decl = result.ok ? findDecl(result.value, declName) : undefined; + runWhenDefined(decl, (current) => { + switch (current.kind) { + case "record": + current.fields.push({ name: "field", type: targetRef("String") }); + break; + case "union": + current.variants.push({ name: "Variant", fields: [] }); + } + }); + return !result.ok ? result : sourceResult(result.value, decl, declName); +}; + +export const removeRow = (source: string, declName: string, rowIndex: number) => { + const result = modelOrFailure(source); + const decl = result.ok ? findDecl(result.value, declName) : undefined; + runWhenDefined(decl, (current) => { + switch (current.kind) { + case "record": + current.fields.splice(rowIndex, 1); + break; + case "union": + current.variants.splice(rowIndex, 1); + } + }); + return !result.ok ? result : sourceResult(result.value, decl, declName); +}; diff --git a/packages/typediagram/src/editor/styles.ts b/packages/typediagram/src/editor/styles.ts new file mode 100644 index 0000000..72b5960 --- /dev/null +++ b/packages/typediagram/src/editor/styles.ts @@ -0,0 +1,41 @@ +// [EDITOR-DESIGN] Shared Architectural Blueprint canvas styles for web + VS Code. +import { runWhen } from "./effects.js"; +export const VISUAL_EDITOR_CSS = ` +.td-visual-editor{--td-bg:#0b1326;--td-low:#131b2e;--td-high:#222a3d;--td-highest:#2d3449;--td-bright:#31394d;--td-text:#dae2fd;--td-muted:#87929a;--td-primary:#8ed5ff;--td-secondary:#ddb7ff;--td-tertiary:#45e3ce;position:relative;overflow:hidden;background-color:var(--td-bg);background-image:linear-gradient(rgba(142,213,255,.032) 1px,transparent 1px),linear-gradient(90deg,rgba(142,213,255,.032) 1px,transparent 1px),radial-gradient(circle,rgba(142,213,255,.12) 1px,transparent 1.5px);background-size:40px 40px,40px 40px,8px 8px;color:var(--td-text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;touch-action:none;cursor:grab} +.td-visual-editor.td-is-panning{cursor:grabbing} +.td-visual-editor .viewport-wrapper{position:absolute;inset:0 auto auto 0;will-change:transform} +.td-visual-editor svg{display:block;max-width:none;overflow:visible;user-select:none} +.td-visual-editor [data-decl]{cursor:move;filter:url(#td-ambient-shadow);transition:filter 140ms ease} +.td-visual-editor [data-decl]:hover{filter:url(#td-hover-shadow)} +.td-visual-editor [data-decl].td-selected>rect:first-of-type{stroke:var(--td-primary);stroke-width:1.5;stroke-opacity:.6} +.td-visual-editor .td-port{fill:var(--td-highest);stroke:var(--td-primary);stroke-width:1.5;opacity:0;cursor:crosshair;transition:opacity 120ms ease,fill 120ms ease,r 120ms ease} +.td-visual-editor [data-decl]:hover>.td-port,.td-visual-editor [data-decl].td-selected>.td-port{opacity:1} +.td-visual-editor .td-port:hover{fill:var(--td-primary);r:6} +.td-visual-editor .td-target-port{stroke:var(--td-tertiary)} +.td-visual-editor .td-connection-preview{fill:none;stroke:var(--td-tertiary);stroke-width:2;stroke-dasharray:5 4;pointer-events:none} +.td-canvas-toolbar{position:absolute;z-index:8;left:18px;bottom:18px;display:flex;align-items:center;gap:3px;padding:5px;background:rgba(34,42,61,.78);backdrop-filter:blur(12px);box-shadow:0 20px 40px rgba(0,0,0,.4);border-radius:6px} +.td-canvas-button{height:34px;min-width:34px;padding:0 9px;border:0;border-radius:3px;background:transparent;color:var(--td-text);font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer} +.td-canvas-button:hover,.td-canvas-button[aria-pressed=true]{background:var(--td-bright);color:var(--td-primary)} +.td-canvas-button:focus-visible,.td-icon-button:focus-visible,.td-node-kind-button:focus-visible,.td-inspector-delete:focus-visible{outline:0;border-bottom:2px solid var(--td-primary)} +.td-zoom-value{min-width:50px;color:var(--td-muted);pointer-events:none} +.td-canvas-separator{width:1px;height:20px;margin:0 3px;background:rgba(135,146,154,.25)} +.td-node-creator{position:absolute;z-index:9;left:18px;bottom:64px;display:grid;grid-template-columns:repeat(3,1fr);gap:4px;padding:6px;background:rgba(34,42,61,.92);backdrop-filter:blur(12px);box-shadow:0 20px 40px rgba(0,0,0,.4);border-radius:4px}.td-node-creator[hidden]{display:none}.td-node-kind-button{height:36px;padding:0 13px;border:0;border-radius:3px;background:var(--td-highest);color:var(--td-text);font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}.td-node-kind-button:hover{background:var(--td-bright);color:var(--td-primary)} +.td-canvas-legend{position:absolute;z-index:7;right:18px;bottom:18px;display:flex;gap:14px;padding:9px 12px;background:rgba(34,42,61,.72);backdrop-filter:blur(12px);box-shadow:0 20px 40px rgba(0,0,0,.32);border-radius:3px;color:var(--td-muted);font-size:10px;letter-spacing:.06em;text-transform:uppercase} +.td-legend-item{display:flex;align-items:center;gap:6px}.td-legend-swatch{width:3px;height:13px;background:var(--td-primary)}.td-legend-union{background:var(--td-secondary)}.td-legend-alias{background:var(--td-tertiary)} +.td-inspector{position:absolute;z-index:10;top:18px;right:18px;width:min(320px,calc(100% - 36px));max-height:calc(100% - 92px);overflow:auto;padding:16px;background:rgba(34,42,61,.88);backdrop-filter:blur(12px);box-shadow:0 20px 40px rgba(0,0,0,.4);border-radius:3px;color:var(--td-text)} +.td-inspector[hidden]{display:none}.td-inspector-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.td-inspector-kind{color:var(--td-primary);font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase}.td-icon-button{display:inline-grid;place-items:center;width:32px;height:32px;padding:0;border:1px solid rgba(135,146,154,.22);border-radius:4px;background:rgba(19,27,46,.72);color:var(--td-muted);cursor:pointer;transition:background 120ms ease,color 120ms ease,border-color 120ms ease}.td-icon-button svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round}.td-icon-button:hover{border-color:rgba(142,213,255,.38);background:var(--td-bright);color:var(--td-text)}.td-inspector-close{flex:0 0 auto} +.td-inspector label{display:block;margin:10px 0 5px;color:var(--td-muted);font-size:10px;letter-spacing:.08em;text-transform:uppercase}.td-inspector input{width:100%;height:34px;padding:0 9px;border:0;border-bottom:2px solid transparent;border-radius:2px;background:#131b2e;color:var(--td-text);font:12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:0}.td-inspector input:focus{border-bottom-color:var(--td-primary)} +.td-inspector-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.2fr) 34px;gap:7px;align-items:end}.td-inspector-row--union{grid-template-columns:minmax(0,1fr) minmax(0,1.2fr) 34px}.td-inspector-remove{width:34px;height:34px}.td-inspector-remove:hover{border-color:rgba(255,180,171,.38);color:#ffb4ab}.td-inspector-add{width:100%;height:34px;margin-top:12px;border:0;border-radius:3px;background:#174966;color:var(--td-primary);font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}.td-inspector-add:hover{background:#205c7d}.td-inspector-delete{width:100%;height:34px;margin-top:8px;border:0;border-radius:3px;background:rgba(255,180,171,.08);color:#ffb4ab;font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}.td-inspector-delete:hover{background:rgba(255,180,171,.16)} +.td-editor-toast{position:absolute;z-index:12;top:18px;left:50%;transform:translateX(-50%);padding:9px 12px;border-radius:3px;background:rgba(49,57,77,.9);box-shadow:0 14px 32px rgba(0,0,0,.32);color:#ffb4ab;font-size:11px;pointer-events:none} +@media(max-width:700px){.td-canvas-legend{display:none}.td-inspector{top:10px;right:10px}.td-canvas-toolbar{left:10px;bottom:10px}.td-node-creator{left:10px;bottom:56px}.td-canvas-button{min-width:32px;padding:0 7px}} +`; + +export const installVisualEditorStyles = (doc: Document = document) => { + const current = doc.querySelector("style[data-td-visual-editor]"); + const style = current ?? doc.createElement("style"); + style.setAttribute("data-td-visual-editor", "true"); + style.textContent = VISUAL_EDITOR_CSS; + runWhen(current === null, () => { + doc.head.appendChild(style); + }); +}; diff --git a/packages/typediagram/src/editor/viewport.ts b/packages/typediagram/src/editor/viewport.ts new file mode 100644 index 0000000..b89e7c7 --- /dev/null +++ b/packages/typediagram/src/editor/viewport.ts @@ -0,0 +1,163 @@ +// [EDITOR-VIEWPORT] Shared infinite-canvas pan, zoom, fit, and content surface. +import { runWhen, runWhenDefined } from "./effects.js"; + +export type ViewportState = { scale: number; translateX: number; translateY: number }; +export type ViewportControls = ViewportState & { + wrapper: HTMLElement; + reset: () => void; + zoomIn: () => void; + zoomOut: () => void; + fit: () => void; +}; + +const ZOOM_MIN = 0.1; +const ZOOM_MAX = 5; +const ZOOM_FACTOR = 1.12; +const FIT_PADDING = 56; +const viewports = new WeakMap(); + +const clamp = (value: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value)); + +const renderTransform = (wrapper: HTMLElement, state: ViewportState) => { + wrapper.style.transform = `translate(${String(state.translateX)}px, ${String(state.translateY)}px) scale(${String(state.scale)})`; + wrapper.dispatchEvent(new CustomEvent("td:viewport", { detail: { ...state } })); +}; + +const zoomAt = (state: ViewportState, wrapper: HTMLElement, x: number, y: number, scale: number) => { + const ratio = scale / state.scale; + state.translateX = x - ratio * (x - state.translateX); + state.translateY = y - ratio * (y - state.translateY); + state.scale = scale; + renderTransform(wrapper, state); +}; + +const fitScale = (cw: number, ch: number, sw: number, sh: number) => { + const hasSize = cw > 0 && ch > 0 && sw > 0 && sh > 0; + return hasSize ? Math.min((cw - FIT_PADDING * 2) / sw, (ch - FIT_PADDING * 2) / sh, 2) : 1; +}; + +const fitSvg = (container: HTMLElement, wrapper: HTMLElement, state: ViewportState) => { + const svg = wrapper.querySelector("svg"); + const hasSvg = svg instanceof SVGSVGElement; + const sw = hasSvg ? svg.width.baseVal.value : 0; + const sh = hasSvg ? svg.height.baseVal.value : 0; + state.scale = hasSvg ? fitScale(container.clientWidth, container.clientHeight, sw, sh) : 1; + state.translateX = hasSvg ? (container.clientWidth - sw * state.scale) / 2 : 0; + state.translateY = hasSvg ? (container.clientHeight - sh * state.scale) / 2 : 0; + renderTransform(wrapper, state); +}; + +const interactiveTarget = (target: EventTarget | null) => + target instanceof Element && target.closest("[data-td-interactive], [data-decl], a, button, input") !== null; + +const wheelFactor = (deltaY: number) => { + const magnitude = Math.min(Math.abs(deltaY), 100) / 100; + return ZOOM_FACTOR ** (deltaY > 0 ? -magnitude : magnitude); +}; + +const installWheel = (container: HTMLElement, state: ViewportState, wrapper: HTMLElement) => { + container.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const rect = container.getBoundingClientRect(); + zoomAt( + state, + wrapper, + event.clientX - rect.left, + event.clientY - rect.top, + clamp(state.scale * wheelFactor(event.deltaY)) + ); + }, + { passive: false } + ); +}; + +const installPan = (container: HTMLElement, state: ViewportState, wrapper: HTMLElement) => { + let start: { x: number; y: number; tx: number; ty: number } | undefined; + container.addEventListener("pointerdown", (event) => { + start = interactiveTarget(event.target) + ? undefined + : { x: event.clientX, y: event.clientY, tx: state.translateX, ty: state.translateY }; + runWhenDefined(start, () => { + container.setPointerCapture(event.pointerId); + }); + container.classList.toggle("td-is-panning", start !== undefined); + }); + container.addEventListener("pointermove", (event) => { + runWhenDefined(start, (current) => { + state.translateX = current.tx + event.clientX - current.x; + state.translateY = current.ty + event.clientY - current.y; + renderTransform(wrapper, state); + }); + }); + const stop = () => { + start = undefined; + container.classList.remove("td-is-panning"); + }; + container.addEventListener("pointerup", stop); + container.addEventListener("pointercancel", stop); +}; + +const makeWrapper = (container: HTMLElement) => { + const existing = container.querySelector(":scope > .viewport-wrapper"); + const wrapper = existing ?? document.createElement("div"); + wrapper.className = "viewport-wrapper"; + wrapper.style.transformOrigin = "0 0"; + runWhen(existing === null, () => { + container.appendChild(wrapper); + }); + return wrapper; +}; + +export const createViewport = (container: HTMLElement): ViewportControls => { + const state: ViewportState = { scale: 1, translateX: 0, translateY: 0 }; + const wrapper = makeWrapper(container); + const center = () => ({ x: container.clientWidth / 2, y: container.clientHeight / 2 }); + const reset = () => { + Object.assign(state, { scale: 1, translateX: 0, translateY: 0 }); + renderTransform(wrapper, state); + }; + const zoom = (factor: number) => { + zoomAt(state, wrapper, center().x, center().y, clamp(state.scale * factor)); + }; + installWheel(container, state, wrapper); + installPan(container, state, wrapper); + container.style.cursor = "grab"; + const controls: ViewportControls = { + wrapper, + reset, + zoomIn: () => { + zoom(ZOOM_FACTOR); + }, + zoomOut: () => { + zoom(1 / ZOOM_FACTOR); + }, + fit: () => { + fitSvg(container, wrapper, state); + }, + get scale() { + return state.scale; + }, + get translateX() { + return state.translateX; + }, + get translateY() { + return state.translateY; + }, + }; + viewports.set(container, controls); + return controls; +}; + +export const setViewportContent = (container: HTMLElement, html: string) => { + const wrapper = container.querySelector(":scope > .viewport-wrapper"); + const target = wrapper ?? container; + const shouldFit = wrapper?.dataset.fitted !== "true"; + target.innerHTML = html; + runWhen(wrapper !== null && shouldFit, () => { + wrapper?.setAttribute("data-fitted", "true"); + viewports.get(container)?.fit(); + }); + return shouldFit; +}; diff --git a/packages/typediagram/src/editor/visual-editor.ts b/packages/typediagram/src/editor/visual-editor.ts new file mode 100644 index 0000000..6bcef0a --- /dev/null +++ b/packages/typediagram/src/editor/visual-editor.ts @@ -0,0 +1,464 @@ +// [EDITOR-CANVAS] Direct manipulation for rendered typeDiagram SVGs. +import { parse } from "../parser/index.js"; +import { buildModel } from "../model/build.js"; +import type { ResolvedDecl, ResolvedTypeRef } from "../model/types.js"; +import { addDeclaration, connectDeclarations } from "./source-editor.js"; +import { createCanvasChrome, type CanvasChrome } from "./controls.js"; +import { installVisualEditorStyles } from "./styles.js"; +import { createViewport, setViewportContent, type ViewportControls } from "./viewport.js"; +import { runWhen, runWhenDefined } from "./effects.js"; +import { applyMutation, renderInspector, type EditorNode, type EditorRow } from "./inspector.js"; + +export type NodePosition = { x: number; y: number }; +export type VisualEditorOptions = { + getSource: () => string; + onSourceChange: (source: string) => void; + initialPositions?: Readonly>; + onPositionsChange?: (positions: Readonly>) => void; +}; +export type VisualEditor = ViewportControls & { setContent: (html: string) => void; refresh: () => void }; + +type DragState = { name: string; startX: number; startY: number; origin: NodePosition; moved: boolean }; +type ConnectState = { name: string; rowIndex: number; path: SVGPathElement }; +type EditorState = { + selected: string | undefined; + drag: DragState | undefined; + connect: ConnectState | undefined; + suppressClick: boolean; + positions: Map; + polylines: WeakMap>; +}; +type EditorContext = { + state: EditorState; + options: VisualEditorOptions; + chrome: CanvasChrome; + viewport: ViewportControls; +}; +type NodeContext = EditorContext & { svg: SVGSVGElement }; +type TrackingContext = NodeContext; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const snap = (value: number) => Math.round(value / 8) * 8; +const refText = (ref: ResolvedTypeRef): string => + ref.args.length === 0 ? ref.name : `${ref.name}<${ref.args.map(refText).join(", ")}>`; + +const declRows = (decl: ResolvedDecl): EditorRow[] => { + switch (decl.kind) { + case "record": + return decl.fields.map((field) => ({ name: field.name, type: refText(field.type) })); + case "union": + return decl.variants.map((variant) => ({ + name: variant.name, + type: variant.fields[0]?.type ? refText(variant.fields[0].type) : "", + })); + case "alias": + return [{ name: "target", type: refText(decl.target) }]; + } +}; + +const inspectNode = (source: string, name: string): EditorNode | undefined => { + const parsed = parse(source); + const built = parsed.ok ? buildModel(parsed.value) : undefined; + const decl = built?.ok === true ? built.value.decls.find((candidate) => candidate.name === name) : undefined; + return decl === undefined ? undefined : { name: decl.name, kind: decl.kind, rows: declRows(decl) }; +}; + +const positionRecord = (positions: Map) => Object.fromEntries(positions.entries()); + +const applyNodePosition = (node: SVGGElement, position: NodePosition) => { + node.setAttribute("transform", `translate(${String(position.x)} ${String(position.y)})`); + node.dataset.editorX = String(position.x); + node.dataset.editorY = String(position.y); +}; + +const basePoints = (polyline: SVGPolylineElement) => + Array.from(polyline.points).map((point) => ({ x: point.x, y: point.y })); + +const shiftedPoints = (points: NodePosition[], source: NodePosition, target: NodePosition) => + points.map((point, index) => { + const offset = + index < Math.min(2, points.length / 2) ? source : index >= points.length - 2 ? target : { x: 0, y: 0 }; + return { x: point.x + offset.x, y: point.y + offset.y }; + }); + +const updateEdge = (edge: SVGGElement, state: EditorState) => { + const polyline = edge.querySelector("polyline"); + const line = polyline instanceof SVGPolylineElement ? polyline : undefined; + const source = state.positions.get(edge.dataset.source ?? "") ?? { x: 0, y: 0 }; + const target = state.positions.get(edge.dataset.target ?? "") ?? { x: 0, y: 0 }; + runWhenDefined(line, (current) => { + const original = state.polylines.get(current) ?? basePoints(current); + state.polylines.set(current, original); + current.setAttribute( + "points", + shiftedPoints(original, source, target) + .map((point) => `${String(point.x)},${String(point.y)}`) + .join(" ") + ); + }); +}; + +const updateEdges = (svg: SVGSVGElement, state: EditorState) => { + svg.querySelectorAll("g[data-edge]").forEach((edge) => { + updateEdge(edge, state); + }); +}; + +const circle = (x: number, y: number, className: string, rowIndex: number) => { + const port = document.createElementNS(SVG_NS, "circle"); + port.setAttribute("cx", String(x)); + port.setAttribute("cy", String(y)); + port.setAttribute("r", "4.5"); + port.setAttribute("class", `td-port ${className}`.trim()); + port.setAttribute("data-td-interactive", "true"); + port.dataset.rowIndex = String(rowIndex); + return port; +}; + +const numberData = (element: SVGGElement, key: string) => Number(element.dataset[key] ?? "0"); + +const decoratePorts = (node: SVGGElement) => { + const x = numberData(node, "x"); + const y = numberData(node, "y"); + const width = numberData(node, "width"); + const height = numberData(node, "height"); + node.querySelectorAll(":scope > .td-port").forEach((port) => { + port.remove(); + }); + node.append(circle(x, y + height / 2, "td-target-port", -2), circle(x + width, y + 16, "td-source-port", -1)); + node.querySelectorAll("g[data-row-index]").forEach((row) => { + const rowY = numberData(row, "rowY"); + const rowHeight = numberData(row, "rowHeight"); + node.append(circle(x + width, rowY + rowHeight / 2, "td-source-port", Number(row.dataset.rowIndex ?? "-1"))); + }); +}; + +const svgPoint = (svg: SVGSVGElement, clientX: number, clientY: number) => { + const point = svg.createSVGPoint(); + point.x = clientX; + point.y = clientY; + const matrix = svg.getScreenCTM()?.inverse(); + const local = matrix === undefined ? point : point.matrixTransform(matrix); + return { x: local.x, y: local.y }; +}; + +const previewPath = (svg: SVGSVGElement, start: NodePosition) => { + const path = document.createElementNS(SVG_NS, "path"); + path.classList.add("td-connection-preview"); + path.dataset.startX = String(start.x); + path.dataset.startY = String(start.y); + svg.appendChild(path); + return path; +}; + +const curve = (start: NodePosition, end: NodePosition) => { + const bend = Math.max(48, Math.abs(end.x - start.x) * 0.45); + return `M ${String(start.x)} ${String(start.y)} C ${String(start.x + bend)} ${String(start.y)}, ${String(end.x - bend)} ${String(end.y)}, ${String(end.x)} ${String(end.y)}`; +}; + +const startConnection = (event: PointerEvent, node: SVGGElement, svg: SVGSVGElement, state: EditorState) => { + const target = event.target instanceof SVGCircleElement ? event.target : undefined; + const sourcePort = target?.classList.contains("td-source-port") === true; + const start = sourcePort ? svgPoint(svg, event.clientX, event.clientY) : undefined; + state.connect = + start === undefined + ? undefined + : { + name: node.dataset.decl ?? "", + rowIndex: Number(target?.dataset.rowIndex ?? "-1"), + path: previewPath(svg, start), + }; + runWhen(sourcePort, () => { + event.preventDefault(); + event.stopPropagation(); + }); + return sourcePort; +}; + +const selectNode = (svg: SVGSVGElement, node: SVGGElement, state: EditorState) => { + state.selected = node.dataset.decl; + svg + .querySelectorAll("[data-decl]") + .forEach((candidate) => candidate.classList.toggle("td-selected", candidate === node)); +}; + +const startNodeDrag = (event: PointerEvent, node: SVGGElement, state: EditorState) => { + const name = node.dataset.decl ?? ""; + state.drag = { + name, + startX: event.clientX, + startY: event.clientY, + origin: state.positions.get(name) ?? { x: 0, y: 0 }, + moved: false, + }; + event.stopPropagation(); +}; + +const focusEditor = (svg: SVGSVGElement) => { + const editor = svg.closest(".td-visual-editor") ?? undefined; + runWhenDefined(editor, (current) => { + current.focus(); + }); +}; + +const handleNodePointerDown = (event: PointerEvent, node: SVGGElement, context: NodeContext) => { + const { svg, state, chrome } = context; + const connected = startConnection(event, node, svg, state); + selectNode(svg, node, state); + runWhen(!connected, () => { + startNodeDrag(event, node, state); + }); + chrome.inspector.hidden = true; +}; + +const handleNodeClick = (event: MouseEvent, node: SVGGElement, context: NodeContext) => { + const { state, options, chrome } = context; + const port = event.target instanceof Element && event.target.closest(".td-port") !== null; + const suppressed = state.suppressClick || state.drag?.moved === true; + const name = node.dataset.decl ?? ""; + const detail = port || suppressed ? undefined : inspectNode(options.getSource(), name); + runWhenDefined(detail, (current) => { + renderInspector(current, chrome, options); + }); + focusEditor(context.svg); + state.drag = undefined; + state.suppressClick = false; +}; + +const installNode = (node: SVGGElement, context: NodeContext) => { + const name = node.dataset.decl ?? ""; + applyNodePosition(node, context.state.positions.get(name) ?? { x: 0, y: 0 }); + decoratePorts(node); + node.addEventListener("pointerdown", (event) => { + handleNodePointerDown(event, node, context); + }); + node.addEventListener("pointerup", (event) => { + finishPointerGesture(event, context); + }); + node.addEventListener("click", (event) => { + handleNodeClick(event, node, context); + }); +}; + +const moveDrag = (event: PointerEvent, svg: SVGSVGElement, state: EditorState, viewport: ViewportControls) => { + const drag = state.drag; + runWhenDefined(drag, (current) => { + const node = svg.querySelector(`[data-decl="${CSS.escape(current.name)}"]`) ?? undefined; + const position = { + x: snap(current.origin.x + (event.clientX - current.startX) / viewport.scale), + y: snap(current.origin.y + (event.clientY - current.startY) / viewport.scale), + }; + current.moved = + current.moved || Math.abs(event.clientX - current.startX) > 2 || Math.abs(event.clientY - current.startY) > 2; + runWhenDefined(node, (element) => { + applyDragPosition(element, position, current.name, svg, state); + }); + }); +}; + +const applyDragPosition = ( + node: SVGGElement, + position: NodePosition, + name: string, + svg: SVGSVGElement, + state: EditorState +) => { + state.positions.set(name, position); + applyNodePosition(node, position); + updateEdges(svg, state); +}; + +const moveConnection = (event: PointerEvent, svg: SVGSVGElement, state: EditorState) => { + const connection = state.connect; + runWhenDefined(connection, (current) => { + const start = { x: Number(current.path.dataset.startX), y: Number(current.path.dataset.startY) }; + const end = svgPoint(svg, event.clientX, event.clientY); + current.path.setAttribute("d", curve(start, end)); + }); +}; + +const finishConnection = ( + event: PointerEvent, + state: EditorState, + options: VisualEditorOptions, + chrome: CanvasChrome +) => { + const connection = state.connect; + const eventTarget = event.target instanceof Element ? event.target.closest("[data-decl]") : null; + const target = eventTarget ?? document.elementFromPoint(event.clientX, event.clientY)?.closest("[data-decl]"); + const targetName = target?.dataset.decl; + runWhenDefined(connection, (current) => { + const destination = targetName === current.name ? undefined : targetName; + runWhenDefined(destination, (name) => { + applyMutation(connectDeclarations(options.getSource(), current.name, current.rowIndex, name), options, chrome); + }); + }); + connection?.path.remove(); + state.connect = undefined; +}; + +const finishPointerGesture = (event: PointerEvent, context: TrackingContext) => { + const { state, options, chrome } = context; + const drag = state.drag; + runWhen(drag?.moved === true, () => { + options.onPositionsChange?.(positionRecord(state.positions)); + focusEditor(context.svg); + }); + const detail = drag?.moved === false ? inspectNode(options.getSource(), drag.name) : undefined; + runWhenDefined(detail, (current) => { + renderInspector(current, chrome, options); + }); + runWhen(drag?.moved === true, () => { + state.suppressClick = true; + }); + runWhen(drag?.moved === false, () => { + state.suppressClick = false; + }); + state.drag = undefined; + finishConnection(event, state, options, chrome); +}; + +const cancelPointerGesture = (state: EditorState) => { + state.connect?.path.remove(); + state.connect = undefined; + state.drag = undefined; +}; + +const installPointerTracking = (container: HTMLElement, context: TrackingContext) => { + const { svg, state, viewport } = context; + svg.addEventListener("pointermove", (event) => { + moveDrag(event, svg, state, viewport); + moveConnection(event, svg, state); + }); + svg.addEventListener("pointerup", (event) => { + finishPointerGesture(event, context); + }); + container.addEventListener("pointercancel", () => { + cancelPointerGesture(state); + }); +}; + +const exportSvg = (wrapper: HTMLElement) => { + const svg = wrapper.querySelector("svg"); + const url = + svg instanceof SVGSVGElement + ? URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(svg)], { type: "image/svg+xml" })) + : undefined; + const anchor = url === undefined ? undefined : document.createElement("a"); + runWhenDefined(anchor, (current) => { + Object.assign(current, { href: url, download: "type-diagram.svg" }); + }); + anchor?.click(); + runWhenDefined(url, (current) => { + URL.revokeObjectURL(current); + }); +}; + +const installKeys = (container: HTMLElement, viewport: ViewportControls, chrome: CanvasChrome) => { + container.tabIndex = 0; + container.ownerDocument.addEventListener("keydown", (event) => { + const editing = event.target instanceof HTMLInputElement; + const inside = event.target instanceof Element && container.contains(event.target); + runWhen(inside && !editing && event.key === "+", viewport.zoomIn); + runWhen(inside && !editing && event.key === "-", viewport.zoomOut); + runWhen(inside && !editing && event.key === "0", viewport.reset); + runWhen(inside && !editing && event.key.toLowerCase() === "f", viewport.fit); + runWhen(event.key === "Escape", () => { + chrome.inspector.hidden = true; + }); + }); +}; + +const createEditorState = (options: VisualEditorOptions): EditorState => ({ + selected: undefined, + drag: undefined, + connect: undefined, + suppressClick: false, + positions: new Map(Object.entries(options.initialPositions ?? {})), + polylines: new WeakMap(), +}); + +const installSvg = (svg: SVGSVGElement, context: EditorContext, container: HTMLElement) => { + const tracking = { ...context, svg }; + svg.querySelectorAll("g[data-decl]").forEach((node) => { + installNode(node, tracking); + }); + updateEdges(svg, context.state); + installPointerTracking(container, tracking); +}; + +const refreshEditor = (container: HTMLElement, context: EditorContext) => { + const { viewport } = context; + const svg = viewport.wrapper.querySelector("svg") ?? undefined; + runWhenDefined(svg, (current) => { + installSvg(current, context, container); + }); + const { selected } = context.state; + const detail = selected === undefined ? undefined : inspectNode(context.options.getSource(), selected); + runWhenDefined(detail, (current) => { + renderInspector(current, context.chrome, context.options); + }); +}; + +const clearLayout = (state: EditorState, options: VisualEditorOptions, viewport: ViewportControls) => { + state.positions.clear(); + options.onPositionsChange?.({}); + const svg = viewport.wrapper.querySelector("svg"); + svg?.querySelectorAll("g[data-decl]").forEach((node) => { + applyNodePosition(node, { x: 0, y: 0 }); + }); + runWhenDefined(svg ?? undefined, (current) => { + updateEdges(current, state); + }); +}; + +const createEditorChrome = ( + container: HTMLElement, + viewport: ViewportControls, + state: EditorState, + options: VisualEditorOptions +) => { + let chrome: CanvasChrome | undefined; + chrome = createCanvasChrome(container, { + addNode: (kind) => { + runWhenDefined(chrome, (current) => { + applyMutation(addDeclaration(options.getSource(), kind), options, current); + }); + }, + ...viewport, + clearLayout: () => { + clearLayout(state, options, viewport); + }, + exportSvg: () => { + exportSvg(viewport.wrapper); + }, + }); + return chrome; +}; + +const installEditorEvents = (container: HTMLElement, viewport: ViewportControls, chrome: CanvasChrome) => { + viewport.wrapper.addEventListener("td:viewport", () => { + chrome.setZoom(viewport.scale); + }); + installKeys(container, viewport, chrome); +}; + +export const createVisualEditor = (container: HTMLElement, options: VisualEditorOptions) => { + installVisualEditorStyles(container.ownerDocument); + container.classList.add("td-visual-editor"); + const viewport = createViewport(container); + const state = createEditorState(options); + const chrome = createEditorChrome(container, viewport, state, options); + const context: EditorContext = { state, options, chrome, viewport }; + const refresh = () => { + refreshEditor(container, context); + }; + installEditorEvents(container, viewport, chrome); + return Object.assign(viewport, { + refresh, + setContent: (html: string) => { + setViewportContent(container, html); + refresh(); + }, + }); +}; diff --git a/packages/typediagram/src/render-svg/render.ts b/packages/typediagram/src/render-svg/render.ts index 40e4132..3817332 100644 --- a/packages/typediagram/src/render-svg/render.ts +++ b/packages/typediagram/src/render-svg/render.ts @@ -49,8 +49,8 @@ export function renderSvg(graph: LaidOutGraph, opts: SvgOpts = {}): string { const body = svg` ${defs} ${background} -${nodes} ${edges} +${nodes} `; return applyPostHook(body, ctx).value; } @@ -59,10 +59,8 @@ ${edges} function renderBackgroundLine(ctx: RenderCtx): SafeSvg { const arg: BackgroundCtx = { ...baseCtx(ctx), width: ctx.width, height: ctx.height }; const out = invokeSimpleHook(ctx.hooks?.background, arg, "background", ctx.hooks?.onError); - if (out === undefined) { - return raw(""); - } - return svg`\n${out}`; + const background = svg``; + return out === undefined ? svg`\n${background}` : svg`\n${background}\n${out}`; } function buildRenderCtx(graph: LaidOutGraph, opts: SvgOpts): RenderCtx { @@ -96,7 +94,10 @@ function renderDefs(ctx: RenderCtx): SafeSvg { function defaultDefs(theme: Theme): SafeSvg { return svg` - `; + + + + `; } function applyPostHook(body: SafeSvg, ctx: RenderCtx): SafeSvg { @@ -194,12 +195,11 @@ function renderDefaultNode( const badge = geo.isUnion ? renderUnionBadge(geo.x, geo.y + geo.nameHeaderH, UNION_BADGE_H, n.width, ctx.theme, ctx.fontSize) : raw(""); - return svg` - - - - - + return svg` + + + + ${raw(escapeText(n.header))} ${badge} ${rows} @@ -253,7 +253,7 @@ function renderDefaultRow(n: NodeBox, r: NodeRow, ctx: RenderCtx, geo: NodeGeome const prefix = isUnion ? "\u25c7 " : ""; const divider = svg``; const text = svg`${raw(escapeText(prefix + r.text))}`; - return raw(`${divider.value}\n${text.value}`); + return svg`${divider}${text}`; } function renderUnionBadge( @@ -308,12 +308,12 @@ function buildEdgeCtx(e: EdgeRoute, ctx: RenderCtx): EdgeCtx { function renderDefaultEdge(e: EdgeRoute, ctx: RenderCtx, edgeCtx: EdgeCtx): SafeSvg { const points = edgeCtx.points.map((p) => `${String(p.x)},${String(p.y)}`).join(" "); const dash = edgeCtx.dashArray !== undefined ? raw(`stroke-dasharray="${edgeCtx.dashArray}"`) : raw(""); - const polyline = svg``; + const polyline = svg``; const label = e.label === "" ? raw("") : svg`${raw(escapeText(e.label))}`; - return raw(`${polyline.value}\n${label.value}`); + return svg`${polyline}${label}`; } function midpoint(points: ReadonlyArray<{ x: number; y: number }>): { x: number; y: number } { diff --git a/packages/typediagram/src/render-svg/theme.ts b/packages/typediagram/src/render-svg/theme.ts index 7fe80b5..cb4f41f 100644 --- a/packages/typediagram/src/render-svg/theme.ts +++ b/packages/typediagram/src/render-svg/theme.ts @@ -21,38 +21,38 @@ export interface Theme { } export const LIGHT: Theme = { - bg: "#ffffff", + bg: "#f6f8fc", nodeFill: "#ffffff", - nodeStroke: "#3b3f46", - headerFill: "#eef1f5", - headerText: "#1a1d22", - unionHeaderFill: "#f3eefa", - unionBadgeText: "#7a4cc7", - rowText: "#1a1d22", - rowDivider: "#dadde2", - edgeStroke: "#5b6068", - edgeText: "#3b3f46", - unionAccent: "#7a4cc7", - aliasAccent: "#0a7a5e", - recordAccent: "#1f6feb", + nodeStroke: "#c5ced8", + headerFill: "#e6ecf4", + headerText: "#0b1326", + unionHeaderFill: "#eee5f8", + unionBadgeText: "#7a3fd1", + rowText: "#26384d", + rowDivider: "#d8e0eb", + edgeStroke: "#60758a", + edgeText: "#3a4a5c", + unionAccent: "#7a3fd1", + aliasAccent: "#0f8a7a", + recordAccent: "#0c7bb8", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", }; export const DARK: Theme = { - bg: "#1c1f24", - nodeFill: "#252931", - nodeStroke: "#9da3ac", - headerFill: "#2f343d", - headerText: "#f1f3f6", - unionHeaderFill: "#2d2640", - unionBadgeText: "#b48cf2", - rowText: "#e4e7ea", - rowDivider: "#3b3f46", - edgeStroke: "#a8aeb6", - edgeText: "#d4d7dc", - unionAccent: "#b48cf2", - aliasAccent: "#4cd1a6", - recordAccent: "#79b8ff", + bg: "#0b1326", + nodeFill: "#222a3d", + nodeStroke: "#3e484f", + headerFill: "#2d3449", + headerText: "#dae2fd", + unionHeaderFill: "#342a49", + unionBadgeText: "#ddb7ff", + rowText: "#bdc8d1", + rowDivider: "#31394d", + edgeStroke: "#87929a", + edgeText: "#bdc8d1", + unionAccent: "#ddb7ff", + aliasAccent: "#45e3ce", + recordAccent: "#8ed5ff", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", }; diff --git a/packages/typediagram/test/__snapshots__/render.test.ts.snap b/packages/typediagram/test/__snapshots__/render.test.ts.snap index e280bef..c7b5829 100644 --- a/packages/typediagram/test/__snapshots__/render.test.ts.snap +++ b/packages/typediagram/test/__snapshots__/render.test.ts.snap @@ -4,175 +4,130 @@ exports[`render — chat example > snapshot — chat example SVG (default theme) " - + + + + - - - - - - - ChatRequest + +tool_results +tool_results +tool_results +tool_results +content +List.items +Text.value +Uri.value +kind +media_type + + + + + + ChatRequest - -message: String - -session_id: String - -tool_results: Option<List<ToolResult>> + message: String +session_id: String +tool_results: Option<List<ToolResult>> - - - - - - - ChatTurnInput + + + + + + ChatTurnInput - -config: AgentConfig - -user_message: String - -tool_results: Option<List<ToolResult>> - -session_id: String + config: AgentConfig +user_message: String +tool_results: Option<List<ToolResult>> +session_id: String - - - - - - - ToolResult + + + + + + ToolResult - -tool_call_id: String - -name: String - -content: ToolResultContent - -ok: Bool + tool_call_id: String +name: String +content: ToolResultContent +ok: Bool - - - - - - - union ToolResultContent - ONE OF - - -◇ None - -◇ Scalar { value: String } - -◇ Dict { entries: Map<String, String> } - -◇ List { items: List<ContentItem> } + + + + + + union ToolResultContent + ONE OF + + ◇ None +◇ Scalar { value: String } +◇ Dict { entries: Map<String, String> } +◇ List { items: List<ContentItem> } - - - - - - - union ContentItem - ONE OF - - -◇ Text { value: TextPart } - -◇ Uri { value: UriPart } - -◇ Scalar { value: String } + + + + + + union ContentItem + ONE OF + + ◇ Text { value: TextPart } +◇ Uri { value: UriPart } +◇ Scalar { value: String } - - - - - - - TextPart + + + + + + TextPart - -text: String + text: String - - - - - - - UriPart + + + + + + UriPart - -url: String - -kind: UriKind - -media_type: Option<String> + url: String +kind: UriKind +media_type: Option<String> - - - - - - - union UriKind - ONE OF - - -◇ Image - -◇ Audio - -◇ Video - -◇ Document - -◇ Web - -◇ Api + + + + + + union UriKind + ONE OF + + ◇ Image +◇ Audio +◇ Video +◇ Document +◇ Web +◇ Api - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - -tool_results - -tool_results - -tool_results - -tool_results - -content - -List.items - -Text.value - -Uri.value - -kind - -media_type " `; @@ -180,90 +135,72 @@ exports[`render — chat example > snapshot — small example SVG (default theme " - + + + + - - - - - - - User + +email +email +address + + + + + + User - -id: UUID - -name: String - -email: Option<Email> - -roles: List<Role> - -address: Address + id: UUID +name: String +email: Option<Email> +roles: List<Role> +address: Address - - - - - - - Address + + + + + + Address - -line1: String - -city: String - -country: CountryCode + line1: String +city: String +country: CountryCode - - - - - - - union Shape - ONE OF - - -◇ Circle { radius: Float } - -◇ Square { side: Float } - -◇ Triangle { a: Float, b: Float, c: Float } + + + + + + union Shape + ONE OF + + ◇ Circle { radius: Float } +◇ Square { side: Float } +◇ Triangle { a: Float, b: Float, c: Float } - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - - - - - - - alias Email + + + + + + alias Email - -= String + = String - -email - -email - -address " `; diff --git a/packages/typediagram/test/converters/typescript-tdbin.test.ts b/packages/typediagram/test/converters/typescript-tdbin.test.ts index c1cf6dd..b60c206 100644 --- a/packages/typediagram/test/converters/typescript-tdbin.test.ts +++ b/packages/typediagram/test/converters/typescript-tdbin.test.ts @@ -2,7 +2,7 @@ // StructCodec objects over the runtime in `src/tdbin`, with layout baked into // the generated code ([TDBIN-FUTURE-TS], [TDBIN-REC-ALLOC]). import { runInNewContext } from "node:vm"; -import ts from "typescript"; +import * as ts from "typescript-compiler"; import { describe, expect, it } from "vitest"; import { emitRustCodec } from "../../src/converters/rust-tdbin.js"; import { emitTypeScriptCodec, generateTypeScriptModule } from "../../src/converters/typescript-tdbin.js"; diff --git a/packages/typediagram/test/editor.test.ts b/packages/typediagram/test/editor.test.ts new file mode 100644 index 0000000..342f46a --- /dev/null +++ b/packages/typediagram/test/editor.test.ts @@ -0,0 +1,146 @@ +// [EDITOR-SOURCE-TEST] Whole-model editing scenario matching visual-canvas actions. +import { describe, expect, it } from "vitest"; +import { + addDeclaration, + addRow, + connectDeclarations, + editRow, + removeDeclaration, + removeRow, + renameDeclaration, +} from "../src/editor/index.js"; +import { parse } from "../src/parser/index.js"; +import { buildModel } from "../src/model/index.js"; + +const SOURCE = `typeDiagram + +type User { + id: Int + email: String +} + +type Team { + members: List +} + +union Result { + Ok + Error { message: String } +} + +alias Owner = User +`; + +const value = (result: ReturnType) => { + expect(result.ok).toBe(true); + return result.ok ? result.value : ""; +}; + +describe("[EDITOR-SOURCE] visual operations preserve a valid, connected type model", () => { + it("renames declarations and edits/adds/removes/connects record, union, and alias rows", () => { + const renamed = value(renameDeclaration(SOURCE, "User", "Person")); + expect(renamed).toContain("type Person"); + expect(renamed).toContain("List"); + expect(renamed).toContain("alias Owner = Person"); + + const fieldEdited = value(editRow(renamed, "Person", 1, { name: "primary_email", type: "Option" })); + expect(fieldEdited).toContain("primary_email: Option"); + + const rowAdded = value(addRow(fieldEdited, "Person")); + expect(rowAdded).toContain("field: String"); + const rowRemoved = value(removeRow(rowAdded, "Person", 0)); + expect(rowRemoved).not.toContain("id: Int"); + + const recordConnected = value(connectDeclarations(rowRemoved, "Person", -1, "Team")); + expect(recordConnected).toContain("team: Team"); + const rowConnected = value(connectDeclarations(recordConnected, "Person", 0, "Team")); + expect(rowConnected).toContain("primary_email: Team"); + + const unionConnected = value(connectDeclarations(rowConnected, "Result", 0, "Person")); + expect(unionConnected).toContain("Ok(Person)"); + const aliasConnected = value(connectDeclarations(unionConnected, "Owner", -1, "Team")); + expect(aliasConnected).toContain("alias Owner = Team"); + + const parsed = parse(aliasConnected); + const model = parsed.ok ? buildModel(parsed.value) : parsed; + expect(parsed.ok).toBe(true); + expect(model.ok).toBe(true); + expect(model.ok ? model.value.edges.some((edge) => edge.targetDeclName === "Team") : false).toBe(true); + }); + + it("returns specific failures without corrupting source", () => { + const missing = renameDeclaration(SOURCE, "Missing", "Other"); + const invalidType = editRow(SOURCE, "User", 0, { type: "List<" }); + expect(missing).toEqual({ ok: false, error: { message: "Unknown declaration 'Missing'" } }); + expect(invalidType.ok).toBe(false); + expect(invalidType.ok ? "" : invalidType.error.message).toContain("expected"); + }); + + it("handles every declaration kind plus malformed and missing edit targets", () => { + const invalidSource = renameDeclaration("@@@", "User", "Person"); + const invalidModel = renameDeclaration("type A {}\ntype A {}", "A", "B"); + const emptyName = renameDeclaration(SOURCE, "User", " "); + expect(invalidSource.ok).toBe(false); + expect(invalidModel.ok).toBe(false); + expect(emptyName.ok).toBe(false); + + const unionNamed = value(editRow(SOURCE, "Result", 0, { name: "Success" })); + const unionTyped = value(editRow(unionNamed, "Result", 0, { type: "List" })); + expect(unionTyped).toContain("Success(List)"); + const aliasTyped = value(editRow(unionTyped, "Owner", 0, { type: "Map" })); + expect(aliasTyped).toContain("alias Owner = Map"); + + const unionAdded = value(addRow(aliasTyped, "Result")); + expect(unionAdded).toContain("Variant"); + const unionRemoved = value(removeRow(unionAdded, "Result", 2)); + expect(unionRemoved).not.toContain("Variant"); + expect(value(addRow(unionRemoved, "Owner"))).toBe(unionRemoved); + expect(value(removeRow(unionRemoved, "Owner", 0))).toBe(unionRemoved); + + const unionConnected = value(connectDeclarations(unionRemoved, "Result", -1, "Team")); + expect(unionConnected).toContain("Team(Team)"); + const unchangedRow = value(editRow(unionConnected, "User", 99, { name: "missing", type: "String" })); + expect(unchangedRow).not.toContain("missing:"); + + expect(editRow(SOURCE, "Missing", 0, { name: "x" }).ok).toBe(false); + expect(addRow(SOURCE, "Missing").ok).toBe(false); + expect(removeRow(SOURCE, "Missing", 0).ok).toBe(false); + expect(connectDeclarations(SOURCE, "Missing", -1, "User").ok).toBe(false); + expect(connectDeclarations(SOURCE, "User", -1, "Missing").ok).toBe(false); + }); + + it("adds uniquely named ADTs and removes only the selected declaration", () => { + const record = value(addDeclaration(SOURCE, "record")); + const secondRecord = value(addDeclaration(record, "record")); + const union = value(addDeclaration(secondRecord, "union")); + const alias = value(addDeclaration(union, "alias")); + expect(alias).toContain("type NewRecord"); + expect(alias).toContain("type NewRecord2"); + expect(alias).toContain("union NewUnion"); + expect(alias).toContain("alias NewAlias = String"); + + const removed = value(removeDeclaration(alias, "User")); + expect(removed).not.toContain("type User {"); + expect(removed).toContain("members: List"); + expect(removed).toContain("alias Owner = User"); + expect(removeDeclaration(SOURCE, "Missing")).toEqual({ + ok: false, + error: { message: "Unknown declaration 'Missing'" }, + }); + expect(addDeclaration("@@@", "record").ok).toBe(false); + expect(removeDeclaration("@@@", "User").ok).toBe(false); + }); + + it("connects to a generic node without committing an unrecoverable bare generic reference", () => { + const genericSource = `${SOURCE}\nunion Option {\n Some { value: T }\n None\n}\n`; + const connected = connectDeclarations(genericSource, "User", -1, "Option"); + expect(connected.ok).toBe(true); + const next = connected.ok ? connected.value : ""; + expect(next).toContain("option: Option"); + const parsed = parse(next); + expect(parsed.ok).toBe(true); + const model = parsed.ok ? buildModel(parsed.value) : parsed; + expect(model.ok).toBe(true); + expect(model.ok ? model.value.edges.some((edge) => edge.targetDeclName === "Option") : false).toBe(true); + }); +}); diff --git a/packages/typediagram/test/render.test.ts b/packages/typediagram/test/render.test.ts index a935489..0abd93d 100644 --- a/packages/typediagram/test/render.test.ts +++ b/packages/typediagram/test/render.test.ts @@ -31,7 +31,9 @@ describe("render — small example", () => { const light = unwrap(await renderToString(SMALL_EXAMPLE, { theme: "light" })); const dark = unwrap(await renderToString(SMALL_EXAMPLE, { theme: "dark" })); expect(light).not.toBe(dark); - expect(dark).toContain("#252931"); + expect(dark).toContain("#222a3d"); + expect(dark).toContain('id="td-grid"'); + expect(dark).toContain('id="td-ambient-shadow"'); }); }); diff --git a/packages/typediagram/test/visual-editor.test.ts b/packages/typediagram/test/visual-editor.test.ts new file mode 100644 index 0000000..d0cae69 --- /dev/null +++ b/packages/typediagram/test/visual-editor.test.ts @@ -0,0 +1,388 @@ +// @vitest-environment happy-dom +// [EDITOR-CANVAS-TEST] Whole visual editor in a browser DOM: render, edit, drag, connect, zoom, persist. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToString } from "../src/index.js"; +import { + createVisualEditor, + createViewport, + installVisualEditorStyles, + setViewportContent, + VISUAL_EDITOR_CSS, +} from "../src/editor/index.js"; + +const SOURCE = `typeDiagram + +type Account { + owner: Profile + active: Bool +} + +type Profile { + name: String +} + +union State { + Loading + Ready { account: Account } +} + +alias Owner = Profile +`; + +const size = (element: HTMLElement, width = 900, height = 640) => { + Object.defineProperty(element, "clientWidth", { configurable: true, value: width }); + Object.defineProperty(element, "clientHeight", { configurable: true, value: height }); +}; + +const pointer = (type: string, x: number, y: number) => + new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, clientX: x, clientY: y }); + +const clickNode = (node: SVGGElement | null | undefined, x: number, y: number) => { + node?.dispatchEvent(pointer("pointerdown", x, y)); + node?.ownerSVGElement?.dispatchEvent(pointer("pointerup", x, y)); +}; + +const render = async (source: string) => { + const result = await renderToString(source, { theme: "dark" }); + expect(result.ok).toBe(true); + return result.ok ? result.value : ""; +}; + +describe("[EDITOR-CANVAS] shared web and VS Code interaction runtime", () => { + beforeEach(() => { + document.head.replaceChildren(); + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + it("supports the full direct-manipulation workflow and all canvas controls", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + let source = SOURCE; + const changes: string[] = []; + const positions: Array>> = []; + const editor = createVisualEditor(container, { + getSource: () => source, + onSourceChange: (next) => { + source = next; + changes.push(next); + }, + initialPositions: { Profile: { x: 16, y: 8 } }, + onPositionsChange: (next) => positions.push(next), + }); + + editor.setContent(await render(source)); + expect(document.querySelector("style[data-td-visual-editor]")?.textContent).toBe(VISUAL_EDITOR_CSS); + expect(container.classList.contains("td-visual-editor")).toBe(true); + expect(container.querySelectorAll(".td-canvas-toolbar .td-canvas-button")).toHaveLength(8); + expect(container.querySelectorAll(".td-node-creator .td-node-kind-button")).toHaveLength(3); + expect(container.querySelectorAll(".td-legend-item")).toHaveLength(3); + expect(container.querySelector('[data-decl="Profile"]')?.getAttribute("transform")).toBe("translate(16 8)"); + expect(container.querySelectorAll(".td-port").length).toBeGreaterThan(8); + installVisualEditorStyles(document); + expect(document.querySelectorAll("style[data-td-visual-editor]")).toHaveLength(1); + + clickNode(container.querySelector('[data-decl="State"]'), 50, 50); + expect(container.querySelector(".td-inspector-kind")?.textContent).toBe("union"); + clickNode(container.querySelector('[data-decl="Owner"]'), 50, 50); + expect(container.querySelector(".td-inspector-kind")?.textContent).toBe("alias"); + + const account = container.querySelector('[data-decl="Account"]'); + const svg = container.querySelector("svg"); + expect(account).not.toBeNull(); + expect(svg).not.toBeNull(); + account?.dispatchEvent(pointer("pointerdown", 100, 100)); + svg?.dispatchEvent(pointer("pointermove", 164, 148)); + svg?.dispatchEvent(pointer("pointerup", 164, 148)); + expect(account?.getAttribute("transform")).toBe("translate(56 40)"); + expect(positions.at(-1)?.Account).toEqual({ x: 56, y: 40 }); + expect(container.querySelector(".td-inspector")?.hasAttribute("hidden")).toBe(true); + account?.dispatchEvent(pointer("pointerdown", 164, 148)); + svg?.dispatchEvent(pointer("pointerup", 164, 148)); + expect(container.querySelector(".td-inspector")?.hasAttribute("hidden")).toBe(false); + + const nameInput = container.querySelector(".td-inspector input"); + expect(nameInput?.value).toBe("Account"); + switch (nameInput) { + case null: + break; + default: + nameInput.value = "Workspace"; + nameInput.dispatchEvent(new Event("change")); + } + expect(source).toContain("type Workspace"); + expect(source).toContain("account: Workspace"); + editor.setContent(await render(source)); + + const workspace = container.querySelector('[data-decl="Workspace"]'); + clickNode(workspace, 80, 80); + const firstRowInputs = container.querySelectorAll(".td-inspector-row input"); + const firstName = firstRowInputs[0]; + const firstType = firstRowInputs[1]; + switch (firstName) { + case undefined: + break; + default: + firstName.value = "member"; + firstName.dispatchEvent(new Event("change")); + } + switch (firstType) { + case undefined: + break; + default: + firstType.value = "Option"; + firstType.dispatchEvent(new Event("change")); + } + expect(source).toContain("member: Option"); + container.querySelector(".td-inspector-add")?.click(); + expect(source).toContain("field: String"); + editor.setContent(await render(source)); + + clickNode(container.querySelector('[data-decl="Workspace"]'), 80, 80); + container.querySelectorAll(".td-inspector-remove").item(2).click(); + expect(source).not.toContain("field: String"); + editor.setContent(await render(source)); + + const port = container.querySelector( + '[data-decl="Workspace"] .td-source-port[data-row-index="0"]' + ); + const target = container.querySelector('[data-decl="Profile"]'); + const connectionSvg = container.querySelector("svg"); + switch (connectionSvg) { + case null: + break; + default: + vi.spyOn(connectionSvg, "getScreenCTM").mockReturnValue(null); + } + const elementFromPoint = vi.spyOn(document, "elementFromPoint").mockReturnValue(target); + port?.dispatchEvent(pointer("pointerdown", 200, 140)); + connectionSvg?.dispatchEvent(pointer("pointermove", 400, 200)); + expect(container.querySelector(".td-connection-preview")?.getAttribute("d")).toContain(" C "); + connectionSvg?.dispatchEvent(pointer("pointerup", 400, 200)); + expect(source).toContain("member: Profile"); + expect(elementFromPoint).toHaveBeenCalled(); + + const wrapper = container.querySelector(".viewport-wrapper"); + const beforeZoom = wrapper?.style.transform; + container.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 200, clientY: 200, deltaY: -1 }) + ); + expect(wrapper?.style.transform).not.toBe(beforeZoom); + container.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 200, clientY: 200, deltaY: 1 }) + ); + container.querySelector('[aria-label="Zoom in"]')?.click(); + container.querySelector('[aria-label="Zoom out"]')?.click(); + container.querySelector('[aria-label="Fit diagram to view"]')?.click(); + container.querySelector('[aria-label="Reset canvas"]')?.click(); + expect(wrapper?.style.transform).toBe("translate(0px, 0px) scale(1)"); + + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:test"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + container.querySelector('[aria-label="Export SVG"]')?.click(); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:test"); + expect(click).toHaveBeenCalledTimes(1); + + container.querySelector('[aria-label="Restore automatic layout"]')?.click(); + expect(positions.at(-1)).toEqual({}); + container.dispatchEvent(new KeyboardEvent("keydown", { key: "+", bubbles: true })); + container.dispatchEvent(new KeyboardEvent("keydown", { key: "-", bubbles: true })); + container.dispatchEvent(new KeyboardEvent("keydown", { key: "f", bubbles: true })); + container.dispatchEvent(new KeyboardEvent("keydown", { key: "0", bubbles: true })); + container.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(container.querySelector(".td-inspector")?.hidden).toBe(true); + expect(changes.length).toBeGreaterThanOrEqual(5); + }); + + it("handles bare viewports, invalid edits, cancelled gestures, and missing SVG exports", async () => { + const bare = document.createElement("div"); + size(bare, 0, 0); + document.body.appendChild(bare); + setViewportContent(bare, "

diagnostic

"); + expect(bare.innerHTML).toBe("

diagnostic

"); + + const viewport = createViewport(bare); + viewport.zoomIn(); + viewport.zoomOut(); + viewport.fit(); + viewport.reset(); + expect(viewport.scale).toBe(1); + bare.dispatchEvent(pointer("pointermove", 20, 20)); + const ignored = document.createElement("button"); + bare.appendChild(ignored); + ignored.dispatchEvent(pointer("pointerdown", 0, 0)); + bare.dispatchEvent(pointer("pointermove", 100, 100)); + bare.dispatchEvent(pointer("pointerdown", 0, 0)); + bare.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true })); + expect(bare.classList.contains("td-is-panning")).toBe(false); + + let source = SOURCE; + const editor = createVisualEditor(bare, { + getSource: () => source, + onSourceChange: (next) => (source = next), + }); + editor.setContent(await render(source)); + const account = bare.querySelector('[data-decl="Account"]'); + const svg = bare.querySelector("svg"); + account?.dispatchEvent(pointer("pointerdown", 20, 20)); + svg?.dispatchEvent(pointer("pointerup", 20, 20)); + const typeInput = bare.querySelectorAll(".td-inspector-row input").item(1); + typeInput.value = "List<"; + typeInput.dispatchEvent(new Event("change")); + expect(bare.querySelector(".td-editor-toast")?.hidden).toBe(false); + bare.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true })); + + editor.setContent("
bad source
"); + const createObjectURL = vi.spyOn(URL, "createObjectURL"); + bare.querySelector('[aria-label="Export SVG"]')?.click(); + expect(createObjectURL).not.toHaveBeenCalled(); + expect(bare.querySelector(".viewport-wrapper")?.textContent).toContain("bad source"); + }); + + it("keeps the inspector off the canvas while a node drag is in progress", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + const editor = createVisualEditor(container, { + getSource: () => SOURCE, + onSourceChange: vi.fn(), + }); + editor.setContent(await render(SOURCE)); + const account = container.querySelector('[data-decl="Account"]'); + const svg = container.querySelector("svg"); + const inspector = container.querySelector(".td-inspector"); + + expect(inspector?.hidden).toBe(true); + account?.dispatchEvent(pointer("pointerdown", 100, 100)); + expect(inspector?.hidden).toBe(true); + svg?.dispatchEvent(pointer("pointermove", 164, 148)); + expect(inspector?.hidden).toBe(true); + expect(account?.getAttribute("transform")).toBe("translate(56 40)"); + svg?.dispatchEvent(pointer("pointerup", 164, 148)); + expect(inspector?.hidden).toBe(true); + account?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(inspector?.hidden).toBe(true); + + account?.dispatchEvent(pointer("pointerdown", 164, 148)); + svg?.dispatchEvent(pointer("pointerup", 164, 148)); + account?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(inspector?.hidden).toBe(false); + expect(container.querySelector(".td-inspector input")?.value).toBe("Account"); + }); + + it("zooms proportionally for trackpad deltas without slowing toolbar controls", () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + const viewport = createViewport(container); + + container.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 200, clientY: 200, deltaY: -1 }) + ); + expect(viewport.scale).toBeGreaterThan(1); + expect(viewport.scale).toBeLessThan(1.01); + + viewport.reset(); + container.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 200, clientY: 200, deltaY: -100 }) + ); + expect(viewport.scale).toBeCloseTo(1.12, 5); + + viewport.reset(); + viewport.zoomIn(); + expect(viewport.scale).toBeCloseTo(1.12, 5); + viewport.zoomOut(); + expect(viewport.scale).toBeCloseTo(1, 5); + }); + + it("renders close and remove actions as accessible icon buttons", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + const editor = createVisualEditor(container, { + getSource: () => SOURCE, + onSourceChange: vi.fn(), + }); + editor.setContent(await render(SOURCE)); + const account = container.querySelector('[data-decl="Account"]'); + const svg = container.querySelector("svg"); + account?.dispatchEvent(pointer("pointerdown", 100, 100)); + svg?.dispatchEvent(pointer("pointerup", 100, 100)); + + const close = container.querySelector(".td-inspector-close"); + const removes = [...container.querySelectorAll(".td-inspector-remove")]; + expect(close?.type).toBe("button"); + expect(close?.classList.contains("td-icon-button")).toBe(true); + expect(close?.getAttribute("aria-label")).toBe("Close properties"); + expect(close?.querySelector('svg[aria-hidden="true"]')).not.toBeNull(); + expect(close?.textContent).toBe(""); + expect(removes).toHaveLength(2); + removes.forEach((button) => { + expect(button.type).toBe("button"); + expect(button.classList.contains("td-icon-button")).toBe(true); + expect(button.getAttribute("aria-label")).toBe("Remove row"); + expect(button.querySelector('svg[aria-hidden="true"]')).not.toBeNull(); + expect(button.textContent).toBe(""); + }); + expect(VISUAL_EDITOR_CSS).toContain(".td-icon-button{display:inline-grid"); + expect(VISUAL_EDITOR_CSS).toContain("border:1px solid"); + expect(VISUAL_EDITOR_CSS).toContain(".td-icon-button:focus-visible"); + }); + + it("adds every ADT node kind and deletes a selected node from the diagram source", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + let source = SOURCE; + const editor = createVisualEditor(container, { + getSource: () => source, + onSourceChange: (next) => { + source = next; + }, + }); + editor.setContent(await render(source)); + + const addType = container.querySelector('[aria-label="Add type"]'); + expect(addType).not.toBeNull(); + addType?.click(); + const creator = container.querySelector(".td-node-creator"); + expect(creator?.hidden).toBe(false); + expect(creator?.querySelectorAll("button")).toHaveLength(3); + expect(creator?.textContent).toContain("Record"); + expect(creator?.textContent).toContain("Union"); + expect(creator?.textContent).toContain("Alias"); + + container.querySelector('[aria-label="Add record type"]')?.click(); + expect(source).toContain("type NewRecord"); + expect(source).toContain("field: String"); + addType?.click(); + container.querySelector('[aria-label="Add union type"]')?.click(); + expect(source).toContain("union NewUnion"); + expect(source).toContain("Variant"); + addType?.click(); + container.querySelector('[aria-label="Add alias type"]')?.click(); + expect(source).toContain("alias NewAlias = String"); + + editor.setContent(await render(source)); + expect(container.querySelector('[data-decl="NewRecord"]')).not.toBeNull(); + expect(container.querySelector('[data-decl="NewUnion"]')).not.toBeNull(); + expect(container.querySelector('[data-decl="NewAlias"]')).not.toBeNull(); + const account = container.querySelector('[data-decl="Account"]'); + const svg = container.querySelector("svg"); + account?.dispatchEvent(pointer("pointerdown", 100, 100)); + svg?.dispatchEvent(pointer("pointerup", 100, 100)); + const deleteType = container.querySelector(".td-inspector-delete"); + expect(deleteType?.textContent).toContain("Delete type"); + expect(deleteType?.getAttribute("aria-label")).toBe("Delete Account"); + deleteType?.click(); + expect(source).not.toContain("type Account {"); + expect(source).toContain("account: Account"); + + editor.setContent(await render(source)); + expect(container.querySelector('[data-decl="Account"]')).toBeNull(); + expect(container.querySelectorAll('[data-edge][data-target="Account"]')).toHaveLength(0); + }); +}); diff --git a/packages/vscode/package.json b/packages/vscode/package.json index b8080e0..4bca33f 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -159,14 +159,14 @@ }, "dependencies": { "markdown-it": "^14.3.0", - "typediagram-core": "0.9.0" + "typediagram-core": "0.0.0-dev" }, "devDependencies": { "@types/markdown-it": "^14.1.2", "@types/mocha": "^10.0.10", "@types/node": "^26.1.1", "@types/pdfkit": "^0.17.6", - "@types/vscode": "^1.125.0", + "@types/vscode": "1.99.1", "@vscode/test-electron": "^3.0.0", "esbuild": "^0.28.1", "mocha": "^11.7.6", diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index b12395a..754d99c 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,10 +1,11 @@ // [VSCODE-EXT] Extension entry point — registers preview command, wires editor events. import * as vscode from "vscode"; import { warmupSyncRender, isSyncRenderReady } from "typediagram-core"; -import { openPreview } from "./preview-panel.js"; +import { openPreview, requestVisualEditorInteractions } from "./preview-panel.js"; import { typediagramMarkdownItPlugin, type MarkdownIt, setPluginLogger } from "./markdown-it-plugin.js"; import { getLogger, initLogger } from "./logger.js"; import { exportPdf, type ExportPdfDeps } from "./export-pdf.js"; +import { VISUAL_EDITOR_INTERACTION_COMMAND } from "./webview/interaction-protocol.js"; let extendCallCount = 0; @@ -63,7 +64,9 @@ export const activate = async (context: vscode.ExtensionContext) => { if (doc.languageId !== "typediagram") { return; } - openPreview(context, doc, panels); + openPreview(context, doc, panels, vscode.ViewColumn.Beside, () => { + diagramOnly.delete(doc.uri.toString()); + }); }; const cmd = vscode.commands.registerCommand("typediagram.preview", () => { @@ -73,6 +76,15 @@ export const activate = async (context: vscode.ExtensionContext) => { } }); + // [VSCODE-VISUAL-EDITOR-STATUS] Command-only black-box seam for extension-host E2E. + const editorStatus = vscode.commands.registerCommand("typediagram.editorStatus", () => ({ + visualEditor: true, + openPanels: panels.size, + })); + const editorInteractions = vscode.commands.registerCommand(VISUAL_EDITOR_INTERACTION_COMMAND, () => + requestVisualEditorInteractions(panels) + ); + // [VSCODE-OPEN-AS-DIAGRAM] Open a .td file directly as a diagram from the explorer context menu — no source editor. const openAsDiagram = vscode.commands.registerCommand("typediagram.openAsDiagram", async (uri?: vscode.Uri) => { const target = uri ?? vscode.window.activeTextEditor?.document.uri; @@ -88,7 +100,9 @@ export const activate = async (context: vscode.ExtensionContext) => { if (sourceTabs.length > 0) { await vscode.window.tabGroups.close(sourceTabs, false); } - openPreview(context, doc, panels, vscode.ViewColumn.Active); + openPreview(context, doc, panels, vscode.ViewColumn.Active, () => { + diagramOnly.delete(key); + }); }); // [VSCODE-AUTOPREVIEW] Auto-open preview beside the editor whenever a .td doc is shown without one. @@ -137,8 +151,9 @@ export const activate = async (context: vscode.ExtensionContext) => { const onClose = vscode.workspace.onDidCloseTextDocument((doc) => { const key = doc.uri.toString(); - panels.delete(key); - diagramOnly.delete(key); + if (!panels.has(key)) { + diagramOnly.delete(key); + } }); // [PDF] Export a markdown file to PDF next to the source. No prompts. @@ -172,7 +187,18 @@ export const activate = async (context: vscode.ExtensionContext) => { await exportPdf(target, { theme }, deps); }); - context.subscriptions.push(cmd, openAsDiagram, exportPdfCmd, onOpen, onActive, onVisible, onChange, onClose); + context.subscriptions.push( + cmd, + editorStatus, + editorInteractions, + openAsDiagram, + exportPdfCmd, + onOpen, + onActive, + onVisible, + onChange, + onClose + ); if (!isSyncRenderReady()) { const startedAt = Date.now(); diff --git a/packages/vscode/src/preview-panel.ts b/packages/vscode/src/preview-panel.ts index 18175c0..f4aca7b 100644 --- a/packages/vscode/src/preview-panel.ts +++ b/packages/vscode/src/preview-panel.ts @@ -1,31 +1,133 @@ // [VSCODE-PREVIEW-PANEL] Creates/reveals a webview panel for a .td document. import * as vscode from "vscode"; import { webviewHtml } from "./webview-html.js"; +import { + visualEditorReady, + visualInteractionResponse, + type VisualInteractionResult, +} from "./webview/interaction-protocol.js"; + +type EditorMessage = { kind: "edit"; source: string }; +type OpenSourceMessage = { kind: "open-source" }; +const pendingEdits = new WeakMap>(); +const panelReady = new WeakMap>(); +let interactionRequestId = 0; + +const readySignal = () => { + let resolve!: () => void; + const promise = new Promise((current) => { + resolve = current; + }); + return { promise, resolve }; +}; + +const webviewMessage = (message: unknown): EditorMessage | OpenSourceMessage | undefined => { + // Safe: the webview payload is narrowed before either property is consumed. + const value = typeof message === "object" && message !== null ? (message as Record) : {}; + switch (value.kind) { + case "edit": + return typeof value.source === "string" ? { kind: "edit", source: value.source } : undefined; + case "open-source": + return { kind: "open-source" }; + default: + return undefined; + } +}; + +const applyEditorSource = async (doc: vscode.TextDocument, source: string) => { + const edit = new vscode.WorkspaceEdit(); + const end = doc.positionAt(doc.getText().length); + edit.replace(doc.uri, new vscode.Range(new vscode.Position(0, 0), end), source); + await vscode.workspace.applyEdit(edit); +}; + +const queueEditorSource = (panel: vscode.WebviewPanel, doc: vscode.TextDocument, source: string) => { + const previous = pendingEdits.get(panel) ?? Promise.resolve(); + const next = previous.then(() => applyEditorSource(doc, source)); + pendingEdits.set(panel, next); +}; + +const requestPanelInteractions = async (panel: vscode.WebviewPanel, requestId: string) => { + await panelReady.get(panel); + return new Promise((resolve) => { + const subscription = panel.webview.onDidReceiveMessage((message: unknown) => { + const result = visualInteractionResponse(message, requestId); + switch (result) { + case undefined: + break; + default: + subscription.dispose(); + void (pendingEdits.get(panel) ?? Promise.resolve()).then(() => { + resolve(result); + }); + } + }); + void panel.webview.postMessage({ kind: "test-visual-interactions", requestId }); + }); +}; + +export const requestVisualEditorInteractions = (panels: Map) => { + const panel = [...panels.values()][0]; + interactionRequestId += 1; + return panel === undefined + ? Promise.resolve({ passed: [], sourceUpdated: false, evidence: {} }) + : requestPanelInteractions(panel, String(interactionRequestId)); +}; export const openPreview = ( context: vscode.ExtensionContext, doc: vscode.TextDocument, panels: Map, - column: vscode.ViewColumn = vscode.ViewColumn.Beside + column: vscode.ViewColumn, + onDispose: () => void ) => { const key = doc.uri.toString(); const existing = panels.get(key); - if (existing) { - existing.reveal(column); - return; + switch (existing) { + case undefined: + break; + default: + existing.reveal(column); + return; } const panel = vscode.window.createWebviewPanel("typediagram.preview", `Preview: ${fileName(doc)}`, column, { enableScripts: true, + retainContextWhenHidden: true, localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, "dist", "webview")], }); const scriptUri = panel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, "dist", "webview", "main.js")); + const ready = readySignal(); + panelReady.set(panel, ready.promise); + + panel.webview.onDidReceiveMessage((message: unknown) => { + switch (visualEditorReady(message)) { + case true: + ready.resolve(); + break; + } + const editor = webviewMessage(message); + switch (editor?.kind) { + case "edit": + queueEditorSource(panel, doc, editor.source); + break; + case "open-source": + void vscode.window.showTextDocument(doc, { preview: false }); + break; + } + }); panel.webview.html = webviewHtml(panel.webview.cspSource, scriptUri, doc.getText()); - panel.onDidDispose(() => panels.delete(key)); + panel.onDidDispose(() => { + panels.delete(key); + onDispose(); + }); panels.set(key, panel); }; -const fileName = (doc: vscode.TextDocument) => doc.uri.path.split("/").pop() ?? "untitled.td"; +const fileName = (doc: vscode.TextDocument) => { + const name = doc.uri.path.split("/").pop(); + return name === undefined || name === "" ? "untitled.td" : name; +}; diff --git a/packages/vscode/src/webview-html.ts b/packages/vscode/src/webview-html.ts index 8d4a8f4..fc31da3 100644 --- a/packages/vscode/src/webview-html.ts +++ b/packages/vscode/src/webview-html.ts @@ -16,14 +16,24 @@ export const webviewHtml = (cspSource: string, scriptUri: vscode.Uri, initialSou content="default-src 'none'; script-src ${cspSource}; style-src 'unsafe-inline';"> -
-

+  
+ `; diff --git a/packages/vscode/src/webview/interaction-protocol.ts b/packages/vscode/src/webview/interaction-protocol.ts new file mode 100644 index 0000000..77673d2 --- /dev/null +++ b/packages/vscode/src/webview/interaction-protocol.ts @@ -0,0 +1,80 @@ +// [VSCODE-VSIX-INTERACTION-PROTOCOL] Command-only black-box bridge used by the packaged VSIX E2E suite. +export const VISUAL_EDITOR_INTERACTION_COMMAND = "typediagram.testVisualEditorInteractions"; + +export const VISUAL_INTERACTION_PASSES = [ + "canvas-chrome", + "record-edit", + "invalid-edit", + "union-edit", + "alias-edit", + "add-remove", + "icon-buttons", + "node-add-delete", + "drag-snap-persist", + "zoom-in-out", + "trackpad-zoom", + "fit-reset-pan", + "draw-relationship", + "auto-layout", + "export-svg", + "close-and-escape", +] as const; + +export type VisualInteractionEvidence = Record; +export type VisualInteractionResult = { + passed: string[]; + sourceUpdated: boolean; + evidence: VisualInteractionEvidence; +}; +export type VisualInteractionRequest = { kind: "test-visual-interactions"; requestId: string }; +export type VisualEditorReady = { kind: "visual-editor-ready" }; +export type VisualInteractionResponse = { + kind: "visual-interactions-result"; + requestId: string; + result: VisualInteractionResult; +}; + +const messageRecord = (message: unknown) => { + // Safe: message properties are read only after the object/null check. + return typeof message === "object" && message !== null ? (message as Record) : {}; +}; + +const evidenceRecord = (message: unknown) => { + const raw = messageRecord(message); + const evidence: VisualInteractionEvidence = {}; + Object.entries(raw).forEach(([key, value]) => { + switch (typeof value) { + case "string": + case "number": + case "boolean": + evidence[key] = value; + break; + } + }); + return evidence; +}; + +export const visualInteractionRequest = (message: unknown): VisualInteractionRequest | undefined => { + const value = messageRecord(message); + return value.kind === "test-visual-interactions" && typeof value.requestId === "string" + ? { kind: "test-visual-interactions", requestId: value.requestId } + : undefined; +}; + +export const visualEditorReady = (message: unknown): message is VisualEditorReady => + messageRecord(message).kind === "visual-editor-ready"; + +export const visualInteractionResponse = (message: unknown, requestId: string): VisualInteractionResult | undefined => { + const value = messageRecord(message); + const raw = messageRecord(value.result); + const evidence = evidenceRecord(raw.evidence); + const passed = Array.isArray(raw.passed) + ? raw.passed.filter((item): item is string => typeof item === "string") + : undefined; + const valid = + value.kind === "visual-interactions-result" && + value.requestId === requestId && + passed !== undefined && + typeof raw.sourceUpdated === "boolean"; + return valid ? { passed, sourceUpdated: raw.sourceUpdated === true, evidence } : undefined; +}; diff --git a/packages/vscode/src/webview/interaction-test.ts b/packages/vscode/src/webview/interaction-test.ts new file mode 100644 index 0000000..ef10344 --- /dev/null +++ b/packages/vscode/src/webview/interaction-test.ts @@ -0,0 +1,341 @@ +// [VSCODE-VSIX-INTERACTIONS] Drives the real packaged webview through its user-facing DOM controls. +import type { VisualInteractionEvidence, VisualInteractionResult } from "./interaction-protocol.js"; + +export type VisualInteractionContext = { + preview: HTMLElement; + getSource: () => string; + settle: () => Promise; + getState: () => unknown; +}; + +type Audit = { context: VisualInteractionContext; passed: string[]; evidence: VisualInteractionEvidence }; +type Point = { x: number; y: number }; + +const mark = (audit: Audit, name: string, passed: boolean) => (passed ? audit.passed.push(name) : undefined); + +const pointer = (type: string, point: Point, buttons: number) => + new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 19, + pointerType: "mouse", + isPrimary: true, + buttons, + clientX: point.x, + clientY: point.y, + }); + +const button = (audit: Audit, label: string) => { + const value = audit.context.preview.querySelector(`[aria-label="${label}"]`); + return value instanceof HTMLButtonElement ? value : undefined; +}; + +const input = (audit: Audit, index: number) => { + const value = audit.context.preview.querySelectorAll(".td-inspector input").item(index); + return value instanceof HTMLInputElement ? value : undefined; +}; + +const declaration = (audit: Audit, name: string) => { + const value = audit.context.preview.querySelector(`[data-decl="${name}"]`); + return value instanceof SVGGElement ? value : undefined; +}; + +const selectDeclaration = (audit: Audit, name: string) => { + const node = declaration(audit, name); + const svg = node?.ownerSVGElement ?? undefined; + const point = { x: node?.getBoundingClientRect().x ?? 20, y: node?.getBoundingClientRect().y ?? 20 }; + node?.dispatchEvent(pointer("pointerdown", point, 1)); + svg?.dispatchEvent(pointer("pointerup", point, 0)); + return node; +}; + +const changeInput = async (audit: Audit, index: number, value: string) => { + const current = input(audit, index); + current?.focus(); + Object.assign(current ?? {}, { value }); + current?.dispatchEvent(new Event("change", { bubbles: true })); + await audit.context.settle(); + return current !== undefined; +}; + +const canvasChrome = (audit: Audit) => { + const { preview } = audit.context; + const toolbarButtons = preview.querySelectorAll(".td-canvas-toolbar .td-canvas-button").length; + const legendItems = preview.querySelectorAll(".td-canvas-legend .td-legend-item").length; + const grid = preview.querySelector("svg #td-grid") !== null; + const shadow = preview.querySelector("svg #td-ambient-shadow") !== null; + const nodeCount = preview.querySelectorAll("svg [data-decl]").length; + Object.assign(audit.evidence, { toolbarButtons, legendItems, grid, shadow, nodeCount }); + const toolbar = toolbarButtons === 8; + const legend = legendItems === 3; + const rendered = grid && shadow && nodeCount > 0; + mark(audit, "canvas-chrome", toolbar && legend && rendered); +}; + +const recordEdits = async (audit: Audit) => { + selectDeclaration(audit, "ChatRequest"); + await changeInput(audit, 0, "ConversationRequest"); + selectDeclaration(audit, "ConversationRequest"); + await changeInput(audit, 1, "prompt"); + selectDeclaration(audit, "ConversationRequest"); + await changeInput(audit, 2, "List<"); + const invalid = !audit.context.getSource().includes("prompt: List<"); + const invalidToast = audit.context.preview.querySelector(".td-editor-toast:not([hidden])") !== null; + Object.assign(audit.evidence, { invalidRejected: invalid, invalidToast }); + mark(audit, "invalid-edit", invalid && invalidToast); + selectDeclaration(audit, "ConversationRequest"); + await changeInput(audit, 2, "Option"); + const recordRenamed = audit.context.getSource().includes("type ConversationRequest"); + const recordFieldEdited = audit.context.getSource().includes("prompt: Option"); + Object.assign(audit.evidence, { recordRenamed, recordFieldEdited }); + mark(audit, "record-edit", recordRenamed && recordFieldEdited); +}; + +const unionEdit = async (audit: Audit) => { + selectDeclaration(audit, "ToolResultContent"); + await changeInput(audit, 3, "ScalarValue"); + selectDeclaration(audit, "ToolResultContent"); + await changeInput(audit, 4, "TextPart"); + const unionVariantRenamed = audit.context.getSource().includes("ScalarValue {"); + const unionPayloadEdited = audit.context.getSource().includes("ScalarValue { value: TextPart }"); + Object.assign(audit.evidence, { unionVariantRenamed, unionPayloadEdited }); + mark(audit, "union-edit", unionVariantRenamed && unionPayloadEdited); +}; + +const aliasEdit = async (audit: Audit) => { + selectDeclaration(audit, "Email"); + await changeInput(audit, 2, "Option"); + const aliasTargetEdited = audit.context.getSource().includes("alias Email = Option"); + audit.evidence.aliasTargetEdited = aliasTargetEdited; + mark(audit, "alias-edit", aliasTargetEdited); +}; + +const addRemove = async (audit: Audit) => { + selectDeclaration(audit, "ConversationRequest"); + const add = audit.context.preview.querySelector(".td-inspector-add"); + const beforeRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + (add instanceof HTMLButtonElement ? add : undefined)?.click(); + await audit.context.settle(); + const added = audit.context.getSource().includes("field: String"); + selectDeclaration(audit, "ConversationRequest"); + const afterAddRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const removes = audit.context.preview.querySelectorAll(".td-inspector-remove"); + const remove = removes.item(removes.length - 1); + (remove instanceof HTMLButtonElement ? remove : undefined)?.click(); + await audit.context.settle(); + selectDeclaration(audit, "ConversationRequest"); + const afterRemoveRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const removed = !audit.context.getSource().includes("field: String"); + Object.assign(audit.evidence, { beforeRows, afterAddRows, afterRemoveRows, rowAdded: added, rowRemoved: removed }); + mark(audit, "add-remove", added && removed && afterAddRows === beforeRows + 1 && afterRemoveRows === beforeRows); +}; + +const iconButtons = (audit: Audit) => { + selectDeclaration(audit, "ConversationRequest"); + const close = button(audit, "Close properties"); + const removes = [...audit.context.preview.querySelectorAll(".td-inspector-remove")]; + const closeIcon = close !== undefined && close.querySelector('svg[aria-hidden="true"]') !== null; + const removeIconCount = removes.filter((current) => current.querySelector('svg[aria-hidden="true"]') !== null).length; + const removeButtonsLabelled = removes.every((current) => current.getAttribute("aria-label") === "Remove row"); + Object.assign(audit.evidence, { closeIcon, removeIconCount, removeButtonsLabelled }); + mark(audit, "icon-buttons", closeIcon && removeIconCount === removes.length && removeButtonsLabelled); +}; + +const addNode = async (audit: Audit, label: string) => { + button(audit, "Add type")?.click(); + button(audit, label)?.click(); + await audit.context.settle(); +}; + +const addDeleteNodes = async (audit: Audit) => { + button(audit, "Add type")?.click(); + const creatorButtons = audit.context.preview.querySelectorAll(".td-node-creator button").length; + button(audit, "Add type")?.click(); + await addNode(audit, "Add record type"); + await addNode(audit, "Add union type"); + await addNode(audit, "Add alias type"); + const recordAdded = declaration(audit, "NewRecord") !== undefined; + const unionAdded = declaration(audit, "NewUnion") !== undefined; + const aliasAdded = declaration(audit, "NewAlias") !== undefined; + selectDeclaration(audit, "NewRecord"); + button(audit, "Delete NewRecord")?.click(); + await audit.context.settle(); + const recordDeleted = + declaration(audit, "NewRecord") === undefined && !audit.context.getSource().includes("type NewRecord"); + Object.assign(audit.evidence, { creatorButtons, recordAdded, unionAdded, aliasAdded, recordDeleted }); + mark(audit, "node-add-delete", creatorButtons === 3 && recordAdded && unionAdded && aliasAdded && recordDeleted); +}; + +const dragNode = (audit: Audit) => { + const node = declaration(audit, "ConversationRequest"); + const svg = node?.ownerSVGElement ?? undefined; + const box = node?.getBoundingClientRect(); + const start = { x: (box?.x ?? 20) + 8, y: (box?.y ?? 20) + 8 }; + const end = { x: start.x + 64, y: start.y + 40 }; + node?.dispatchEvent(pointer("pointerdown", start, 1)); + const inspector = audit.context.preview.querySelector(".td-inspector"); + const inspectorHiddenOnDragStart = inspector?.hidden === true; + svg?.dispatchEvent(pointer("pointermove", end, 1)); + const inspectorHiddenOnDragMove = inspector?.hidden === true; + svg?.dispatchEvent(pointer("pointerup", end, 0)); + const inspectorHiddenOnDragEnd = inspector?.hidden === true; + const x = Number(node?.dataset.editorX); + const y = Number(node?.dataset.editorY); + const persisted = JSON.stringify(audit.context.getState()).includes("ConversationRequest"); + const dragSnapped = x > 0 && y > 0 && x % 8 === 0 && y % 8 === 0; + Object.assign(audit.evidence, { + dragX: x, + dragY: y, + dragSnapped, + layoutPersisted: persisted, + inspectorHiddenOnDragStart, + inspectorHiddenOnDragMove, + inspectorHiddenOnDragEnd, + }); + mark( + audit, + "drag-snap-persist", + dragSnapped && persisted && inspectorHiddenOnDragStart && inspectorHiddenOnDragMove && inspectorHiddenOnDragEnd + ); +}; + +const zoom = (audit: Audit) => { + const wrapper = audit.context.preview.querySelector(".viewport-wrapper"); + const before = wrapper?.style.transform; + button(audit, "Zoom in")?.click(); + const afterIn = wrapper?.style.transform; + button(audit, "Zoom out")?.click(); + const afterOut = wrapper?.style.transform; + Object.assign(audit.evidence, { zoomBefore: before ?? "", zoomAfterIn: afterIn ?? "", zoomAfterOut: afterOut ?? "" }); + mark(audit, "zoom-in-out", before !== afterIn && afterIn !== afterOut); +}; + +const scaleFrom = (transform: string) => Number(transform.match(/scale\(([^)]+)\)/)?.[1] ?? "0"); + +const trackpadZoom = (audit: Audit) => { + button(audit, "Reset canvas")?.click(); + audit.context.preview.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 200, clientY: 200, deltaY: -1 }) + ); + const transform = audit.context.preview.querySelector(".viewport-wrapper")?.style.transform ?? ""; + const scale = scaleFrom(transform); + button(audit, "Reset canvas")?.click(); + audit.evidence.trackpadScale = scale; + mark(audit, "trackpad-zoom", scale > 1 && scale < 1.01); +}; + +const pan = (audit: Audit) => { + const start = { x: 12, y: 18 }; + const end = { x: 62, y: 53 }; + audit.context.preview.dispatchEvent(pointer("pointerdown", start, 1)); + audit.context.preview.dispatchEvent(pointer("pointermove", end, 1)); + audit.context.preview.dispatchEvent(pointer("pointerup", end, 0)); + return audit.context.preview.querySelector(".viewport-wrapper")?.style.transform ?? ""; +}; + +const fitResetPan = (audit: Audit) => { + button(audit, "Fit diagram to view")?.click(); + const fit = audit.context.preview.querySelector(".viewport-wrapper")?.style.transform ?? ""; + button(audit, "Reset canvas")?.click(); + const reset = audit.context.preview.querySelector(".viewport-wrapper")?.style.transform ?? ""; + const panned = pan(audit); + button(audit, "Reset canvas")?.click(); + Object.assign(audit.evidence, { fitTransform: fit, resetTransform: reset, panTransform: panned }); + mark( + audit, + "fit-reset-pan", + fit.includes("scale(") && reset.includes("translate(0px, 0px) scale(1)") && panned.includes("translate(50px, 35px)") + ); +}; + +const drawRelationship = async (audit: Audit) => { + selectDeclaration(audit, "ConversationRequest"); + const port = audit.context.preview.querySelector( + '[data-decl="ConversationRequest"] .td-source-port[data-row-index="0"]' + ); + const target = audit.context.preview.querySelector('[data-decl="AgentConfig"] .td-target-port'); + const svg = port instanceof SVGCircleElement ? port.ownerSVGElement : undefined; + const sourceBox = port?.getBoundingClientRect(); + const targetBox = target?.getBoundingClientRect(); + const start = { x: (sourceBox?.x ?? 0) + 2, y: (sourceBox?.y ?? 0) + 2 }; + const end = { x: (targetBox?.x ?? 0) + 2, y: (targetBox?.y ?? 0) + 2 }; + port?.dispatchEvent(pointer("pointerdown", start, 1)); + svg?.dispatchEvent(pointer("pointermove", end, 1)); + const connectionPreview = + audit.context.preview.querySelector(".td-connection-preview")?.getAttribute("d")?.includes(" C ") === true; + target?.dispatchEvent(pointer("pointerup", end, 0)); + await audit.context.settle(); + const relationshipSource = audit.context.getSource().includes("prompt: AgentConfig"); + Object.assign(audit.evidence, { connectionPreview, relationshipSource }); + mark(audit, "draw-relationship", connectionPreview && relationshipSource); +}; + +const autoLayout = (audit: Audit) => { + button(audit, "Restore automatic layout")?.click(); + const node = declaration(audit, "ConversationRequest"); + const emptyState = JSON.stringify(audit.context.getState()).includes('"positions":{}'); + const autoX = Number(node?.dataset.editorX); + const autoY = Number(node?.dataset.editorY); + Object.assign(audit.evidence, { autoX, autoY, autoStateEmpty: emptyState }); + mark(audit, "auto-layout", autoX === 0 && autoY === 0 && emptyState); +}; + +const exportSvg = (audit: Audit) => { + let download = ""; + const capture = (event: Event) => { + download = event.target instanceof HTMLAnchorElement ? event.target.download : download; + event.preventDefault(); + }; + document.addEventListener("click", capture, true); + button(audit, "Export SVG")?.click(); + document.removeEventListener("click", capture, true); + audit.evidence.exportFilename = download; + mark(audit, "export-svg", download === "type-diagram.svg"); +}; + +const closeAndEscape = (audit: Audit) => { + selectDeclaration(audit, "ConversationRequest"); + button(audit, "Close properties")?.click(); + const inspector = audit.context.preview.querySelector(".td-inspector"); + const closed = inspector?.hidden === true; + selectDeclaration(audit, "ConversationRequest"); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + const escaped = inspector?.hidden === true; + Object.assign(audit.evidence, { inspectorClosed: closed, inspectorEscaped: escaped }); + mark(audit, "close-and-escape", closed && escaped); +}; + +const editingInteractions = async (audit: Audit) => { + await recordEdits(audit); + await unionEdit(audit); + await aliasEdit(audit); + await addRemove(audit); + iconButtons(audit); + await addDeleteNodes(audit); +}; + +const canvasInteractions = async (audit: Audit) => { + dragNode(audit); + zoom(audit); + trackpadZoom(audit); + fitResetPan(audit); + await drawRelationship(audit); + autoLayout(audit); + exportSvg(audit); + closeAndEscape(audit); +}; + +export const runVisualEditorInteractions = async ( + context: VisualInteractionContext +): Promise => { + const audit: Audit = { context, passed: [], evidence: {} }; + canvasChrome(audit); + await editingInteractions(audit); + await canvasInteractions(audit); + const source = context.getSource(); + return { + passed: audit.passed, + sourceUpdated: source.includes("ConversationRequest") && source.includes("prompt: AgentConfig"), + evidence: audit.evidence, + }; +}; diff --git a/packages/vscode/src/webview/main.ts b/packages/vscode/src/webview/main.ts index 915a004..130f572 100644 --- a/packages/vscode/src/webview/main.ts +++ b/packages/vscode/src/webview/main.ts @@ -1,38 +1,146 @@ -// [VSCODE-WEBVIEW] Webview script — renders .td source to SVG using typediagram. -// Reuses the same renderToString API as the web playground and CLI. +// [VSCODE-WEBVIEW] Shared visual editor with a real workspace-edit bridge. import { parser, renderToString } from "typediagram-core"; +import { createVisualEditor, type NodePosition } from "typediagram-core/editor"; +import { runVisualEditorInteractions } from "./interaction-test.js"; +import { visualInteractionRequest } from "./interaction-protocol.js"; type UpdateMessage = { kind: "update"; source: string }; +type WebviewState = { positions?: Readonly> }; +type RecoveryElements = { + errorPanel: HTMLElement; + error: HTMLElement; + restore: HTMLButtonElement; + openSource: HTMLButtonElement; +}; +type RenderResult = Awaited>; +type VisualEditor = ReturnType; -acquireVsCodeApi(); -const previewEl = document.getElementById("preview"); -const errorEl = document.getElementById("error"); -if (previewEl === null || errorEl === null) { - throw new Error("[VSCODE-WEBVIEW] missing preview or error element"); -} +const vscode = acquireVsCodeApi(); + +const detectTheme = () => (document.body.classList.contains("vscode-light") ? ("light" as const) : ("dark" as const)); + +const initialSource = () => document.querySelector("script[data-source]")?.getAttribute("data-source") ?? ""; + +const persistedState = (): WebviewState => { + const state = vscode.getState(); + // Safe: VS Code owns this state object and positions are used only for numeric transforms. + return typeof state === "object" && state !== null ? state : {}; +}; -const detectTheme = (): "light" | "dark" => - document.body.getAttribute("data-vscode-theme-kind")?.includes("dark") === true ? "dark" : "light"; +const isUpdate = (message: unknown): message is UpdateMessage => { + // Safe: message properties are narrowed before use. + const value = typeof message === "object" && message !== null ? (message as Record) : {}; + return value.kind === "update" && typeof value.source === "string"; +}; + +const applyRenderResult = ( + result: RenderResult, + source: string, + visual: VisualEditor, + elements: RecoveryElements, + previous: string | undefined +) => { + elements.errorPanel.hidden = result.ok; + elements.error.textContent = result.ok ? "" : parser.formatDiagnostics([...result.error]); + elements.restore.disabled = !result.ok && previous === undefined; + switch (result.ok) { + case true: + visual.setContent(result.value); + break; + } + return result.ok ? source : previous; +}; -const renderSource = async (source: string) => { - const result = await renderToString(source, { theme: detectTheme() }); - previewEl.innerHTML = result.ok ? result.value : ""; - errorEl.textContent = result.ok ? "" : parser.formatDiagnostics([...result.error]); +const recoveryElements = () => { + const errorPanel = document.getElementById("error-panel"); + const error = document.getElementById("error"); + const restore = document.getElementById("restore-valid-source"); + const openSource = document.getElementById("open-source"); + const complete = + errorPanel !== null && error !== null && restore instanceof HTMLButtonElement && openSource instanceof HTMLButtonElement; + return complete ? { errorPanel, error, restore, openSource } : undefined; }; -// Initial render from data attribute -const scriptTag = document.querySelector("script[data-source]"); -const initial = - scriptTag - ?.getAttribute("data-source") - ?.replace(/"/g, '"') - .replace(/>/g, ">") - .replace(/</g, "<") - .replace(/&/g, "&") ?? ""; - -void renderSource(initial); - -// Live updates from extension host -window.addEventListener("message", (e: MessageEvent) => { - void renderSource(e.data.source); -}); +const boot = (preview: HTMLElement, elements: RecoveryElements) => { + let source = initialSource(); + let lastValidSource: string | undefined; + let version = 0; + let settled = Promise.resolve(); + const visual = createVisualEditor(preview, { + getSource: () => source, + onSourceChange: (next) => { + source = next; + vscode.postMessage({ kind: "edit", source: next }); + void render(); + }, + initialPositions: persistedState().positions ?? {}, + onPositionsChange: (positions) => { + vscode.setState({ positions }); + }, + }); + const render = () => { + const current = ++version; + settled = renderToString(source, { theme: detectTheme() }).then((result) => { + switch (current === version) { + case true: + lastValidSource = applyRenderResult(result, source, visual, elements, lastValidSource); + break; + } + }); + return settled; + }; + window.addEventListener("message", (event: MessageEvent) => { + const update = isUpdate(event.data) ? event.data : undefined; + switch (update) { + case undefined: + break; + default: + source = update.source; + void render(); + } + const request = visualInteractionRequest(event.data); + switch (request) { + case undefined: + break; + default: + void runVisualEditorInteractions({ + preview, + getSource: () => source, + settle: () => settled, + getState: persistedState, + }).then((result) => { + vscode.postMessage({ kind: "visual-interactions-result", requestId: request.requestId, result }); + }); + } + }); + elements.restore.addEventListener("click", () => { + switch (lastValidSource) { + case undefined: + break; + default: + source = lastValidSource; + vscode.postMessage({ kind: "edit", source }); + void render(); + } + }); + elements.openSource.addEventListener("click", () => { + vscode.postMessage({ kind: "open-source" }); + }); + void render().then(() => { + vscode.postMessage({ kind: "visual-editor-ready" }); + }); +}; + +const preview = document.getElementById("preview"); +const elements = recoveryElements(); +switch (preview) { + case null: + break; + default: + switch (elements) { + case undefined: + break; + default: + boot(preview, elements); + } +} diff --git a/packages/vscode/test/electron/harness/extension.cjs b/packages/vscode/test/electron/harness/extension.cjs new file mode 100644 index 0000000..153dea6 --- /dev/null +++ b/packages/vscode/test/electron/harness/extension.cjs @@ -0,0 +1,3 @@ +// [VSCODE-VSIX-HARNESS] Minimal development extension; the product extension is installed from the VSIX. +exports.activate = () => undefined; +exports.deactivate = () => undefined; diff --git a/packages/vscode/test/electron/harness/package.json b/packages/vscode/test/electron/harness/package.json new file mode 100644 index 0000000..77a3d7f --- /dev/null +++ b/packages/vscode/test/electron/harness/package.json @@ -0,0 +1,13 @@ +{ + "name": "typediagram-vsix-e2e-harness", + "displayName": "TypeDiagram VSIX E2E Harness", + "publisher": "typediagram-tests", + "version": "0.0.0", + "engines": { + "vscode": "^1.99.0" + }, + "main": "./extension.cjs", + "activationEvents": [ + "*" + ] +} diff --git a/packages/vscode/test/electron/runner.mjs b/packages/vscode/test/electron/runner.mjs index 60ffe65..25754ea 100644 --- a/packages/vscode/test/electron/runner.mjs +++ b/packages/vscode/test/electron/runner.mjs @@ -4,20 +4,27 @@ // Kept as plain ESM (.mjs) so we don't need a TS runtime step for the launcher. import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { readdirSync, existsSync } from "node:fs"; +import { readdirSync, existsSync, rmSync, mkdirSync, statSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import { runTests, resolveCliArgsFromVSCodeExecutablePath, downloadAndUnzipVSCode } from "@vscode/test-electron"; +import { runTests, downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from "@vscode/test-electron"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PKG_ROOT = resolve(__dirname, "../.."); const REPO_ROOT = resolve(PKG_ROOT, "../.."); +const RUN_ID = `${String(process.pid)}-${String(Date.now())}`; +const RESULT_PATH = resolve(PKG_ROOT, `.vscode-test/electron-result-${RUN_ID}.ok`); +const HARNESS_PATH = resolve(__dirname, "harness"); +const PROFILE_PATH = resolve(PKG_ROOT, `.vscode-test/vsix-profile-${RUN_ID}`); +const USER_DATA_PATH = resolve(PROFILE_PATH, "user-data"); +const EXTENSIONS_PATH = resolve(PROFILE_PATH, "extensions"); function findLatestVsix() { const files = readdirSync(REPO_ROOT).filter((f) => /^typediagram-.*\.vsix$/.test(f)); if (files.length === 0) { throw new Error("no .vsix found in repo root — run `npm run -w packages/vscode package` first"); } - return resolve(REPO_ROOT, files.sort().at(-1)); + const artifacts = files.map((file) => resolve(REPO_ROOT, file)); + return artifacts.sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs)[0]; } // [ELECTRON-DARWIN-ARM64-LIMITATION] On Apple Silicon, @vscode/test-electron's @@ -34,27 +41,42 @@ function checkPlatform() { } } -async function main() { - checkPlatform(); - if (!existsSync(resolve(REPO_ROOT, "packages/typediagram/dist/index.js"))) { - const r = spawnSync("npm", ["run", "-w", "typediagram-core", "build"], { - cwd: REPO_ROOT, - stdio: "inherit", - shell: process.platform === "win32", - }); - if (r.status !== 0) throw new Error("core build failed"); - } +function runNpm(args, failure) { + const result = spawnSync("npm", args, { + cwd: REPO_ROOT, + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.status !== 0) throw new Error(`${failure} (status ${result.status})`); +} - if (!readdirSync(REPO_ROOT).some((f) => /^typediagram-.*\.vsix$/.test(f))) { - const pkg = spawnSync("npm", ["run", "-w", "packages/vscode", "package"], { - cwd: REPO_ROOT, - stdio: "inherit", - shell: process.platform === "win32", - }); - if (pkg.status !== 0) throw new Error("vsix packaging failed"); - } +function installVsix(vscodeExecutablePath, configuredCli, appPath, vsixPath) { + const appCli = appPath === undefined ? undefined : resolve(appPath, "Contents/Resources/app/bin/code"); + const cli = configuredCli + ? [vscodeExecutablePath] + : appCli === undefined + ? resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath) + : [appCli]; + const result = spawnSync( + cli[0], + [ + ...cli.slice(1), + `--user-data-dir=${USER_DATA_PATH}`, + `--extensions-dir=${EXTENSIONS_PATH}`, + "--install-extension", + vsixPath, + "--force", + ], + { cwd: REPO_ROOT, stdio: "inherit", shell: process.platform === "win32" } + ); + if (result.status !== 0) throw new Error(`VSIX installation failed (status ${result.status})`); +} - void findLatestVsix; +async function main() { + checkPlatform(); + runNpm(["run", "-w", "typediagram-core", "build"], "core build failed"); + runNpm(["run", "-w", "packages/vscode", "package"], "VSIX packaging failed"); + const vsixPath = findLatestVsix(); const extensionTestsPath = resolve(__dirname, "suite/index.cjs"); // [ELECTRON-DARWIN-WORKAROUND] Pre-download VS Code so we get a concrete executable @@ -62,16 +84,36 @@ async function main() { // on Apple Silicon's "Electron" binary fails because we need the "code" entrypoint. // By calling downloadAndUnzipVSCode() + passing the executable path explicitly, // @vscode/test-electron routes arguments through the correct launcher. - const vscodeExecutablePath = await downloadAndUnzipVSCode(); - const [cli, ...args] = resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath); - void cli; - void args; + const appPath = process.env["TYPEDIAGRAM_VSCODE_APP_PATH"]; + const configuredExecutable = process.env["TYPEDIAGRAM_VSCODE_EXECUTABLE_PATH"]; + const configuredCli = configuredExecutable?.endsWith("/bin/code") === true; + const vscodeExecutablePath = + appPath === undefined ? (configuredExecutable ?? (await downloadAndUnzipVSCode())) : "/usr/bin/open"; + rmSync(PROFILE_PATH, { recursive: true, force: true }); + mkdirSync(USER_DATA_PATH, { recursive: true }); + mkdirSync(EXTENSIONS_PATH, { recursive: true }); + installVsix(configuredExecutable ?? vscodeExecutablePath, configuredCli, appPath, vsixPath); + const profileArgs = [`--user-data-dir=${USER_DATA_PATH}`, `--extensions-dir=${EXTENSIONS_PATH}`]; + const launchArgs = + appPath === undefined + ? configuredCli + ? ["--wait", "--new-window", ...profileArgs] + : profileArgs + : ["-W", "-n", "-a", appPath, "--args", ...profileArgs]; + rmSync(RESULT_PATH, { force: true }); const exitCode = await runTests({ vscodeExecutablePath, - extensionDevelopmentPath: PKG_ROOT, + extensionDevelopmentPath: HARNESS_PATH, extensionTestsPath, + extensionTestsEnv: { TYPEDIAGRAM_ELECTRON_RESULT_PATH: RESULT_PATH }, + ...(launchArgs === undefined ? {} : { launchArgs }), }); + existsSync(RESULT_PATH) + ? undefined + : (() => { + throw new Error("VS Code exited without completing the Electron suite"); + })(); process.exit(exitCode); } diff --git a/packages/vscode/test/electron/suite/extension.spec.cjs b/packages/vscode/test/electron/suite/extension.spec.cjs index 562a09b..a261bf4 100644 --- a/packages/vscode/test/electron/suite/extension.spec.cjs +++ b/packages/vscode/test/electron/suite/extension.spec.cjs @@ -6,19 +6,113 @@ const path = require("node:path"); const vscode = require("vscode"); suite("typediagram extension inside a real VS Code", () => { - // When loaded via extensionDevelopmentPath the id is publisher., - // which in the monorepo is "nimblesite.typediagram-vscode". A packaged VSIX - // rewrites it to "nimblesite.typediagram". We accept either. - const candidateIds = ["nimblesite.typediagram", "nimblesite.typediagram-vscode"]; - const findExt = () => candidateIds.map((id) => vscode.extensions.getExtension(id)).find(Boolean); + const extensionId = "nimblesite.typediagram"; + const findExt = () => vscode.extensions.getExtension(extensionId); test("extension is installed and activatable", async () => { const ext = findExt(); - assert.ok(ext, `none of ${candidateIds.join(", ")} found`); + assert.ok(ext, `${extensionId} was not installed from the freshly packaged VSIX`); + assert.strictEqual(ext.packageJSON.name, "typediagram"); + assert.ok(ext.extensionPath.includes("vsix-profile"), `unexpected extension path: ${ext.extensionPath}`); await ext.activate(); assert.strictEqual(ext.isActive, true); }); + test("opens the sample as a visual editor and reports the live panel through commands", async () => { + const docPath = path.resolve(__dirname, "../../../examples/sample.td"); + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(docPath)); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("typediagram.preview"); + const status = await vscode.commands.executeCommand("typediagram.editorStatus"); + assert.deepStrictEqual(status, { visualEditor: true, openPanels: 1 }); + }); + + test("runs every main canvas interaction inside the packaged VSIX webview", async () => { + const docPath = path.resolve(__dirname, "../../../examples/sample.td"); + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(docPath)); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("typediagram.preview"); + const result = await vscode.commands.executeCommand("typediagram.testVisualEditorInteractions"); + assert.deepStrictEqual(result.passed, [ + "canvas-chrome", + "record-edit", + "invalid-edit", + "union-edit", + "alias-edit", + "add-remove", + "icon-buttons", + "node-add-delete", + "drag-snap-persist", + "zoom-in-out", + "trackpad-zoom", + "fit-reset-pan", + "draw-relationship", + "auto-layout", + "export-svg", + "close-and-escape", + ]); + assert.strictEqual(result.sourceUpdated, true); + + const e = result.evidence; + assert.strictEqual(e.toolbarButtons, 8); + assert.strictEqual(e.legendItems, 3); + assert.strictEqual(e.grid, true); + assert.strictEqual(e.shadow, true); + assert.ok(e.nodeCount >= 10); + assert.strictEqual(e.recordRenamed, true); + assert.strictEqual(e.recordFieldEdited, true); + assert.strictEqual(e.invalidRejected, true); + assert.strictEqual(e.invalidToast, true); + assert.strictEqual(e.unionVariantRenamed, true); + assert.strictEqual(e.unionPayloadEdited, true); + assert.strictEqual(e.aliasTargetEdited, true); + assert.strictEqual(e.rowAdded, true); + assert.strictEqual(e.rowRemoved, true); + assert.strictEqual(e.afterAddRows, e.beforeRows + 1); + assert.strictEqual(e.afterRemoveRows, e.beforeRows); + assert.strictEqual(e.closeIcon, true); + assert.ok(e.removeIconCount >= 1); + assert.strictEqual(e.removeButtonsLabelled, true); + assert.strictEqual(e.creatorButtons, 3); + assert.strictEqual(e.recordAdded, true); + assert.strictEqual(e.unionAdded, true); + assert.strictEqual(e.aliasAdded, true); + assert.strictEqual(e.recordDeleted, true); + assert.ok(e.dragX > 0); + assert.ok(e.dragY > 0); + assert.strictEqual(e.dragX % 8, 0); + assert.strictEqual(e.dragY % 8, 0); + assert.strictEqual(e.dragSnapped, true); + assert.strictEqual(e.layoutPersisted, true); + assert.strictEqual(e.inspectorHiddenOnDragStart, true); + assert.strictEqual(e.inspectorHiddenOnDragMove, true); + assert.strictEqual(e.inspectorHiddenOnDragEnd, true); + assert.notStrictEqual(e.zoomBefore, e.zoomAfterIn); + assert.notStrictEqual(e.zoomAfterIn, e.zoomAfterOut); + assert.ok(e.trackpadScale > 1); + assert.ok(e.trackpadScale < 1.01); + assert.match(e.fitTransform, /scale\(/); + assert.match(e.resetTransform, /translate\(0px, 0px\) scale\(1\)/); + assert.match(e.panTransform, /translate\(50px, 35px\) scale\(1\)/); + assert.strictEqual(e.connectionPreview, true); + assert.strictEqual(e.relationshipSource, true); + assert.strictEqual(e.autoX, 0); + assert.strictEqual(e.autoY, 0); + assert.strictEqual(e.autoStateEmpty, true); + assert.strictEqual(e.exportFilename, "type-diagram.svg"); + assert.strictEqual(e.inspectorClosed, true); + assert.strictEqual(e.inspectorEscaped, true); + + assert.match(doc.getText(), /type ConversationRequest/); + assert.match(doc.getText(), /prompt: AgentConfig/); + assert.match(doc.getText(), /ScalarValue \{ value: TextPart \}/); + assert.match(doc.getText(), /alias Email = Option/); + assert.match(doc.getText(), /union NewUnion/); + assert.match(doc.getText(), /alias NewAlias = String/); + assert.doesNotMatch(doc.getText(), /type NewRecord/); + assert.doesNotMatch(doc.getText(), /field: String/); + }); + test("package.json declares markdown injection grammar and markdown-it plugin", () => { const ext = findExt(); assert.ok(ext); diff --git a/packages/vscode/test/electron/suite/index.cjs b/packages/vscode/test/electron/suite/index.cjs index add9f2a..7fd4322 100644 --- a/packages/vscode/test/electron/suite/index.cjs +++ b/packages/vscode/test/electron/suite/index.cjs @@ -1,11 +1,14 @@ // [VSCODE-E2E-SUITE] Mocha entry point executed inside the extension host by // @vscode/test-electron. Discovers and runs test files under this directory. const path = require("node:path"); +const { writeFileSync } = require("node:fs"); const Mocha = require("mocha"); const { glob } = require("glob"); +const resultPath = + process.env.TYPEDIAGRAM_ELECTRON_RESULT_PATH ?? path.resolve(__dirname, "../../../.vscode-test/electron-result.ok"); function run() { - const mocha = new Mocha({ ui: "bdd", color: true, timeout: 30_000 }); + const mocha = new Mocha({ ui: "tdd", color: true, timeout: 30_000 }); const testsRoot = path.resolve(__dirname); return new Promise((resolve, reject) => { @@ -16,7 +19,10 @@ function run() { } mocha.run((failures) => { if (failures > 0) reject(new Error(`${failures} tests failed.`)); - else resolve(); + else { + writeFileSync(resultPath, "passed\n"); + resolve(); + } }); }) .catch(reject); diff --git a/packages/vscode/test/extension.test.ts b/packages/vscode/test/extension.test.ts index fa5d3f8..f4a5275 100644 --- a/packages/vscode/test/extension.test.ts +++ b/packages/vscode/test/extension.test.ts @@ -21,6 +21,9 @@ describe("[VSCODE-EXT] activate", () => { mock.mockPanel.webview.html = ""; mock.mockPanel._disposeCb = undefined; mock.mockPanel._preserveFocus = false; + mock.mockPanel.webview._messageHandlers = []; + mock.workspace._lastEdit = undefined; + mock.workspace.applyEdit.mockClear(); mock.commands._handler = undefined; mock.workspace._changeCb = undefined; mock.workspace._closeCb = undefined; @@ -39,6 +42,11 @@ describe("[VSCODE-EXT] activate", () => { const { ctx } = await activateExtension(); expect(mock.commands.registerCommand).toHaveBeenCalledWith("typediagram.preview", expect.any(Function)); expect(mock.commands.registerCommand).toHaveBeenCalledWith("typediagram.openAsDiagram", expect.any(Function)); + expect(mock.commands.registerCommand).toHaveBeenCalledWith("typediagram.editorStatus", expect.any(Function)); + expect(mock.commands.registerCommand).toHaveBeenCalledWith( + "typediagram.testVisualEditorInteractions", + expect.any(Function) + ); // 7 original disposables + 1 Output Channel added by initLogger. // The Output Channel may already have been added in a prior test (lazy ensureChannel), // so we assert >= 7. Either way, both commands must be present. @@ -46,6 +54,47 @@ describe("[VSCODE-EXT] activate", () => { expect(mock.window.createOutputChannel).toHaveBeenCalledWith("TypeDiagram"); }); + it("reports visual-editor readiness and open panels through the command API", async () => { + await activateExtension(); + mock.window.activeTextEditor = { document: makeDoc("type Ready { x: Int }") }; + mock.commands._handler?.(); + const status = mock.commands._handlers.get("typediagram.editorStatus")?.(); + expect(status).toEqual({ visualEditor: true, openPanels: 1 }); + }); + + it("returns dense visual-interaction evidence from the live webview command bridge", async () => { + await activateExtension(); + const empty = await mock.commands._handlers.get("typediagram.testVisualEditorInteractions")?.(); + expect(empty).toEqual({ passed: [], sourceUpdated: false, evidence: {} }); + + mock.window.activeTextEditor = { document: makeDoc("type Ready { x: Int }") }; + mock.commands._handler?.(); + const expected = { + passed: ["canvas-chrome", "record-edit"], + sourceUpdated: true, + evidence: { toolbarButtons: 7, recordRenamed: true }, + }; + let postedRequestId = ""; + mock.mockPanel.webview.postMessage.mockImplementationOnce((message: unknown) => { + // Safe: the command bridge always posts an object containing a generated requestId. + const request = message as { requestId: string }; + postedRequestId = request.requestId; + queueMicrotask(() => { + mock.mockPanel.webview._messageHandlers.forEach((handler) => { + handler({ kind: "visual-interactions-result", requestId: request.requestId, result: expected }); + }); + }); + return Promise.resolve(true); + }); + const result = await mock.commands._handlers.get("typediagram.testVisualEditorInteractions")?.(); + expect(result).toEqual(expected); + expect(mock.mockPanel.webview.postMessage).toHaveBeenCalledWith({ + kind: "test-visual-interactions", + requestId: postedRequestId, + }); + expect(postedRequestId).toMatch(/^\d+$/); + }); + it("preview command does nothing without active typediagram editor", async () => { await activateExtension(); mock.window.activeTextEditor = undefined; @@ -90,22 +139,73 @@ describe("[VSCODE-EXT] activate", () => { }); }); + it("applies visual-editor source messages to the real text document", async () => { + await activateExtension(); + const doc = makeDoc("typeDiagram\ntype X { a: Int }"); + mock.window.activeTextEditor = { document: doc }; + mock.commands._handler?.(); + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.({ kind: "edit", source: "typeDiagram\ntype X { a: String }\n" }); + await vi.waitFor(() => { + expect(mock.workspace.applyEdit).toHaveBeenCalledTimes(1); + }); + expect(mock.workspace._lastEdit?.replace).toHaveBeenCalledWith( + doc.uri, + expect.any(mock.Range), + "typeDiagram\ntype X { a: String }\n" + ); + }); + + it("opens the source document as an escape hatch from an invalid visual edit", async () => { + await activateExtension(); + const doc = makeDoc("typeDiagram\ntype X { a: Int }"); + mock.window.activeTextEditor = { document: doc }; + mock.commands._handler?.(); + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.({ kind: "open-source" }); + await vi.waitFor(() => { + expect(mock.window.showTextDocument).toHaveBeenCalledWith(doc, { preview: false }); + }); + expect(mock.workspace.applyEdit).not.toHaveBeenCalled(); + expect(mock.mockPanel.dispose).not.toHaveBeenCalled(); + }); + + it("ignores malformed webview edits and labels pathless documents", async () => { + await activateExtension(); + const doc = makeDoc("typeDiagram\ntype X { a: Int }"); + doc.uri.path = ""; + mock.window.activeTextEditor = { document: doc }; + mock.commands._handler?.(); + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.(null); + handler?.("edit"); + handler?.({ kind: "edit", source: 42 }); + expect(mock.workspace.applyEdit).not.toHaveBeenCalled(); + expect(mock.window.createWebviewPanel).toHaveBeenCalledWith( + "typediagram.preview", + "Preview: untitled.td", + mock.ViewColumn.Beside, + expect.any(Object) + ); + }); + it("ignores changes to non-typediagram documents", async () => { await activateExtension(); mock.workspace._changeCb?.({ document: makeDoc("x", "json") }); expect(mock.mockPanel.webview.postMessage).not.toHaveBeenCalled(); }); - it("cleans up panel reference on document close", async () => { + it("keeps a live panel registered when only its hidden text document closes", async () => { await activateExtension(); const doc = makeDoc("type Y { b: String }"); mock.window.activeTextEditor = { document: doc }; mock.commands._handler?.(); mock.workspace._closeCb?.(doc); - // After close, next open should create fresh panel mock.window.createWebviewPanel.mockClear(); + mock.mockPanel.reveal.mockClear(); mock.commands._handler?.(); - expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1); + expect(mock.window.createWebviewPanel).not.toHaveBeenCalled(); + expect(mock.mockPanel.reveal).toHaveBeenCalledTimes(1); }); it("deactivate is a no-op", async () => { @@ -235,6 +335,29 @@ describe("[VSCODE-EXT] activate", () => { expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1); }); + it("[VSCODE-EDITOR-TAB] tab-committed edit keeps exactly one live diagram when its hidden document reopens", async () => { + await activateExtension(); + const doc = makeDoc("typeDiagram\nunion Content { Scalar(String) }"); + mock.workspace._openTextDocResult = doc; + const openAsDiagram = mock.commands._handlers.get("typediagram.openAsDiagram"); + + await openAsDiagram?.(doc.uri); + expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1); + expect(mock.mockPanel._disposeCb).toBeTypeOf("function"); + + mock.workspace._closeCb?.(doc); + const webviewHandler = mock.mockPanel.webview._messageHandlers.at(-1); + webviewHandler?.({ kind: "edit", source: "typeDiagram\nunion Content { Scalar(Int) }\n" }); + await vi.waitFor(() => { + expect(mock.workspace.applyEdit).toHaveBeenCalledTimes(1); + }); + mock.workspace._openCb?.(doc); + + expect(mock.window.createWebviewPanel).toHaveBeenCalledTimes(1); + expect(mock.mockPanel.dispose).not.toHaveBeenCalled(); + expect(mock.window.tabGroups.close).not.toHaveBeenCalled(); + }); + it("[VSCODE-OPEN-AS-DIAGRAM] openAsDiagram does nothing without URI or active editor", async () => { await activateExtension(); const handler = mock.commands._handlers.get("typediagram.openAsDiagram"); diff --git a/packages/vscode/test/helpers.ts b/packages/vscode/test/helpers.ts index 1b32f48..e8b6298 100644 --- a/packages/vscode/test/helpers.ts +++ b/packages/vscode/test/helpers.ts @@ -12,6 +12,7 @@ export const makeDoc = (text: string, langId = "typediagram", scheme = "file") = toString: () => `${scheme}:///test/${langId}.td`, }, getText: () => text, + positionAt: (offset: number) => ({ line: 0, character: offset }), languageId: langId, }); diff --git a/packages/vscode/test/vscode-mock.ts b/packages/vscode/test/vscode-mock.ts index 3bfee79..e7e48ea 100644 --- a/packages/vscode/test/vscode-mock.ts +++ b/packages/vscode/test/vscode-mock.ts @@ -18,12 +18,15 @@ export const mockPanel = { // renderHtmlToPdf succeeds in tests. printToPDF: vi.fn(() => Promise.resolve(FAKE_PDF)), onDidReceiveMessage: vi.fn((handler: (msg: unknown) => void) => { + mockPanel.webview._messageHandlers.push(handler); // Fire a synthetic load-ready message so the extension's awaited promise resolves. queueMicrotask(() => { handler({ kind: "td-print-ready" }); + handler({ kind: "visual-editor-ready" }); }); return { dispose: vi.fn() }; }), + _messageHandlers: [] as Array<(msg: unknown) => void>, }, reveal: vi.fn(), onDidDispose: vi.fn((cb: () => void) => { @@ -41,6 +44,27 @@ export class TabInputText { constructor(public uri: { toString: () => string }) {} } +export class Position { + constructor( + public line: number, + public character: number + ) {} +} + +export class Range { + constructor( + public start: Position, + public end: Position + ) {} +} + +export class WorkspaceEdit { + replace = vi.fn(); + constructor() { + workspace._lastEdit = this; + } +} + type Tab = { input: unknown }; type TabGroup = { tabs: Tab[] }; @@ -59,6 +83,7 @@ export const window = { visibleTextEditors: [] as { document: unknown }[], createWebviewPanel: vi.fn(() => mockPanel), createOutputChannel: vi.fn(() => mockOutputChannel), + showTextDocument: vi.fn((_doc: unknown, _options?: unknown) => Promise.resolve(undefined)), showInformationMessage: vi.fn((..._args: unknown[]) => Promise.resolve(undefined)), showErrorMessage: vi.fn((_msg: string) => Promise.resolve(undefined)), tabGroups: { @@ -100,6 +125,8 @@ export const workspace = { get: (_key: string, defaultValue?: T) => defaultValue, })), openTextDocument: vi.fn((_uri: unknown) => Promise.resolve(workspace._openTextDocResult)), + applyEdit: vi.fn((_edit: unknown) => Promise.resolve(true)), + _lastEdit: undefined as WorkspaceEdit | undefined, _openTextDocResult: undefined as unknown, onDidOpenTextDocument: vi.fn((cb: (doc: unknown) => void) => { workspace._openCb = cb; diff --git a/packages/vscode/test/webview-html.test.ts b/packages/vscode/test/webview-html.test.ts index b3443a2..f8e509c 100644 --- a/packages/vscode/test/webview-html.test.ts +++ b/packages/vscode/test/webview-html.test.ts @@ -1,5 +1,7 @@ // [VSCODE-WEBVIEW-HTML-TEST] Tests for webview HTML generation. +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { parser } from "typediagram-core"; import { webviewHtml } from "../src/webview-html.js"; describe("[VSCODE-WEBVIEW-HTML] webviewHtml", () => { @@ -38,5 +40,26 @@ describe("[VSCODE-WEBVIEW-HTML] webviewHtml", () => { const html = webviewHtml(csp, scriptUri, ""); expect(html).toContain('id="preview"'); expect(html).toContain('id="error"'); + expect(html).toContain('aria-label="Visual type editor"'); + expect(html).toContain("width: 100%; height: 100%"); + }); + + it("keeps an invalid visual edit recoverable from the error overlay", () => { + const html = webviewHtml(csp, scriptUri, "typeDiagram\ntype Safe { value: String }"); + expect(html).toContain('id="error-panel"'); + expect(html).toContain('id="restore-valid-source"'); + expect(html).toContain("Undo invalid edit"); + expect(html).toContain('aria-label="Undo invalid edit"'); + expect(html).toContain('id="open-source"'); + expect(html).toContain("Open source"); + }); + + it("ships a parseable sample for the visual editor recovery workflow", () => { + const source = readFileSync(new URL("../examples/sample.td", import.meta.url), "utf8"); + const result = parser.parse(source); + expect(result.ok).toBe(true); + expect(result.ok ? result.value.declarations.length : 0).toBeGreaterThan(8); + expect(source).toContain("union Option"); + expect(source).toContain("alias Email = String"); }); }); diff --git a/packages/web/e2e/visual-editor.spec.ts b/packages/web/e2e/visual-editor.spec.ts new file mode 100644 index 0000000..304526d --- /dev/null +++ b/packages/web/e2e/visual-editor.spec.ts @@ -0,0 +1,243 @@ +// [WEB-VISUAL-EDITOR-E2E] Whole-app canvas editing in a real browser. +import { expect, test } from "./support/coverage-fixture.js"; +import type { Locator, Page } from "@playwright/test"; + +type Point = { x: number; y: number }; + +const dispatchPointerDrag = async (source: Locator, from: Point, to: Point, releaseSelector?: string) => { + return source.evaluate( + (element, points) => { + const svg = (element as SVGElement).ownerSVGElement; + const inspector = element.ownerDocument.querySelector(".td-inspector"); + const pointer = (type: string, point: Point, buttons: number) => + new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 7, + pointerType: "mouse", + isPrimary: true, + buttons, + clientX: point.x, + clientY: point.y, + }); + element.dispatchEvent(pointer("pointerdown", points.from, 1)); + const hiddenOnDown = inspector?.hidden === true; + svg?.dispatchEvent(pointer("pointermove", points.to, 1)); + const hiddenOnMove = inspector?.hidden === true; + const release = points.releaseSelector === undefined ? svg : element.ownerDocument.querySelector(points.releaseSelector); + release?.dispatchEvent(pointer("pointerup", points.to, 0)); + return { hiddenOnDown, hiddenOnMove, hiddenOnUp: inspector?.hidden === true }; + }, + { from, to, releaseSelector } + ); +}; + +const openEditor = async (page: Page) => { + await page.goto("/"); + await page.evaluate(() => { + localStorage.clear(); + }); + await page.reload(); + await page.waitForSelector('#preview svg [data-decl="ChatRequest"]'); +}; + +test.describe("[WEB-VISUAL-EDITOR]", () => { + test("edits types directly while dragging nodes, drawing relations, zooming, and preserving layout", async ({ + page, + }) => { + await openEditor(page); + await expect(page.locator("#preview.td-visual-editor")).toHaveCount(1); + await expect(page.locator(".td-canvas-toolbar .td-canvas-button")).toHaveCount(8); + await expect(page.locator(".td-canvas-legend .td-legend-item")).toHaveCount(3); + await expect(page.locator("#preview svg #td-grid")).toHaveCount(1); + await expect(page.locator("#preview svg #td-ambient-shadow")).toHaveCount(1); + + const canvasBackground = await page + .locator("#preview") + .evaluate((element) => getComputedStyle(element).backgroundImage); + expect(canvasBackground).toContain("linear-gradient"); + expect(canvasBackground).toContain("radial-gradient"); + + const request = page.locator('[data-decl="ChatRequest"]'); + await request.click(); + await expect(request).toHaveClass(/td-selected/); + await expect(page.locator(".td-inspector")).toBeVisible(); + await expect(page.locator(".td-inspector-kind")).toHaveText("record"); + await expect(page.getByRole("button", { name: "Close properties" }).locator('svg[aria-hidden="true"]')).toHaveCount( + 1 + ); + + const declarationInput = page.locator(".td-inspector input").first(); + await declarationInput.fill("ConversationRequest"); + await declarationInput.blur(); + await expect(page.locator("#editor")).toHaveValue(/type ConversationRequest/); + await expect(page.locator('[data-decl="ConversationRequest"]')).toHaveCount(1); + + await page.locator('[data-decl="ConversationRequest"]').click(); + const firstFieldName = page.locator(".td-inspector-row").first().locator("input").first(); + await firstFieldName.fill("prompt"); + await firstFieldName.blur(); + const invalidType = page.locator(".td-inspector-row").first().locator("input").nth(1); + await invalidType.fill("List<"); + await invalidType.blur(); + await expect(page.locator(".td-editor-toast")).toBeVisible(); + await expect(page.locator("#editor")).not.toHaveValue(/prompt: List"); + await validType.blur(); + await expect(page.locator("#editor")).toHaveValue(/prompt: Option/); + + await page.getByRole("button", { name: "Close properties" }).click(); + await page.locator('[data-decl="ToolResultContent"]').click(); + await expect(page.locator(".td-inspector-kind")).toHaveText("union"); + const scalarRow = page.locator(".td-inspector-row").nth(1); + await scalarRow.locator("input").first().fill("ScalarValue"); + await scalarRow.locator("input").first().blur(); + await page.locator(".td-inspector-row").nth(1).locator("input").nth(1).fill("TextPart"); + await page.locator(".td-inspector-row").nth(1).locator("input").nth(1).blur(); + await expect(page.locator("#editor")).toHaveValue(/ScalarValue \{ value: TextPart \}/); + + await page.getByRole("button", { name: "Close properties" }).click(); + await page.locator('[data-decl="Email"]').click(); + await expect(page.locator(".td-inspector-kind")).toHaveText("alias"); + await page.locator(".td-inspector-row input").nth(1).fill("Option"); + await page.locator(".td-inspector-row input").nth(1).blur(); + await expect(page.locator("#editor")).toHaveValue(/alias Email = Option/); + + await page.getByRole("button", { name: "Close properties" }).click(); + await page.locator('[data-decl="ConversationRequest"]').click(); + const rows = page.locator(".td-inspector-row"); + const beforeAdd = await rows.count(); + await page.locator(".td-inspector-add").click(); + await expect(page.locator("#editor")).toHaveValue(/field: String/); + await expect(rows).toHaveCount(beforeAdd + 1); + const beforeRemove = await rows.count(); + await expect(rows.last().locator('.td-inspector-remove svg[aria-hidden="true"]')).toHaveCount(1); + await expect(rows.last().locator(".td-inspector-remove")).toHaveAttribute("aria-label", "Remove row"); + await rows.last().locator(".td-inspector-remove").click(); + await expect(page.locator(".td-inspector-row")).toHaveCount(beforeRemove - 1); + + const addType = page.getByRole("button", { name: "Add type" }); + await addType.click(); + await expect(page.locator(".td-node-creator")).toBeVisible(); + await expect(page.locator(".td-node-creator button")).toHaveCount(3); + await page.getByRole("button", { name: "Add record type" }).click(); + await expect(page.locator("#editor")).toHaveValue(/type NewRecord/); + await expect(page.locator('[data-decl="NewRecord"]')).toHaveCount(1); + await addType.click(); + await page.getByRole("button", { name: "Add union type" }).click(); + await expect(page.locator("#editor")).toHaveValue(/union NewUnion/); + await expect(page.locator('[data-decl="NewUnion"]')).toHaveCount(1); + await addType.click(); + await page.getByRole("button", { name: "Add alias type" }).click(); + await expect(page.locator("#editor")).toHaveValue(/alias NewAlias = String/); + await expect(page.locator('[data-decl="NewAlias"]')).toHaveCount(1); + await page.getByRole("button", { name: "Close properties" }).click(); + await page.locator('[data-decl="NewRecord"]').click(); + await expect(page.getByRole("button", { name: "Delete NewRecord" })).toHaveText("Delete type"); + await page.getByRole("button", { name: "Delete NewRecord" }).click(); + await expect(page.locator("#editor")).not.toHaveValue(/type NewRecord/); + await expect(page.locator('[data-decl="NewRecord"]')).toHaveCount(0); + await expect(page.locator(".td-inspector")).toBeHidden(); + + const movable = page.locator('[data-decl="ConversationRequest"]'); + const movableBox = await movable.boundingBox(); + expect(movableBox).not.toBeNull(); + const centerX = (movableBox?.x ?? 0) + (movableBox?.width ?? 0) / 2; + const centerY = (movableBox?.y ?? 0) + 12; + const dragInspector = await dispatchPointerDrag( + movable, + { x: centerX, y: centerY }, + { x: centerX + 64, y: centerY + 40 } + ); + expect(dragInspector).toEqual({ hiddenOnDown: true, hiddenOnMove: true, hiddenOnUp: true }); + const movedPosition = await movable.evaluate((element) => ({ + x: Number((element as SVGGElement).dataset.editorX), + y: Number((element as SVGGElement).dataset.editorY), + })); + expect(movedPosition.x).toBeGreaterThan(0); + expect(movedPosition.y).toBeGreaterThan(0); + expect(movedPosition.x % 8).toBe(0); + expect(movedPosition.y % 8).toBe(0); + await expect + .poll(() => page.evaluate(() => localStorage.getItem("td-playground-positions"))) + .toContain("ConversationRequest"); + + const transformBeforeZoom = await page.locator("#preview .viewport-wrapper").getAttribute("style"); + await page.getByRole("button", { name: "Zoom in" }).click(); + const transformAfterZoom = await page.locator("#preview .viewport-wrapper").getAttribute("style"); + expect(transformAfterZoom).not.toBe(transformBeforeZoom); + await expect(page.locator(".td-zoom-value")).not.toHaveText("100%"); + await page.getByRole("button", { name: "Zoom out" }).click(); + await page.getByRole("button", { name: "Fit diagram to view" }).click(); + + await page.getByRole("button", { name: "Reset canvas" }).click(); + await expect(page.locator(".td-zoom-value")).toHaveText("100%"); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute( + "style", + /translate\(0px, 0px\) scale\(1\)/ + ); + await page.locator("#preview").dispatchEvent("wheel", { + deltaY: -1, + clientX: 200, + clientY: 200, + }); + const trackpadScale = await page.locator("#preview .viewport-wrapper").evaluate((element) => { + const match = element.style.transform.match(/scale\(([^)]+)\)/); + return Number(match?.[1] ?? "0"); + }); + expect(trackpadScale).toBeGreaterThan(1); + expect(trackpadScale).toBeLessThan(1.01); + await page.getByRole("button", { name: "Reset canvas" }).click(); + await page.locator("#preview").evaluate((element) => { + const pointer = (type: string, x: number, y: number, buttons: number) => + new PointerEvent(type, { bubbles: true, pointerId: 11, clientX: x, clientY: y, buttons }); + element.dispatchEvent(pointer("pointerdown", 12, 18, 1)); + element.dispatchEvent(pointer("pointermove", 62, 53, 1)); + element.dispatchEvent(pointer("pointerup", 62, 53, 0)); + }); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute( + "style", + /translate\(50px, 35px\) scale\(1\)/ + ); + await page.getByRole("button", { name: "Reset canvas" }).click(); + + const sourcePort = page.locator('[data-decl="ConversationRequest"] .td-source-port[data-row-index="0"]'); + const target = page.locator('[data-decl="AgentConfig"] .td-target-port'); + const sourceBox = await sourcePort.boundingBox(); + const targetBox = await target.boundingBox(); + expect(sourceBox).not.toBeNull(); + expect(targetBox).not.toBeNull(); + await dispatchPointerDrag( + sourcePort, + { x: (sourceBox?.x ?? 0) + 2, y: (sourceBox?.y ?? 0) + 2 }, + { + x: (targetBox?.x ?? 0) + (targetBox?.width ?? 0) / 2, + y: (targetBox?.y ?? 0) + (targetBox?.height ?? 0) / 2, + }, + '[data-decl="AgentConfig"] .td-target-port' + ); + await expect(page.locator("#editor")).toHaveValue(/prompt: AgentConfig/); + + await page.getByRole("button", { name: "Restore automatic layout" }).click(); + await expect.poll(() => page.evaluate(() => localStorage.getItem("td-playground-positions"))).toBe("{}"); + expect( + await page.locator('[data-decl="ConversationRequest"]').evaluate((element) => ({ + x: Number((element as SVGGElement).dataset.editorX), + y: Number((element as SVGGElement).dataset.editorY), + })) + ).toEqual({ x: 0, y: 0 }); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export SVG" }).click(); + expect((await downloadPromise).suggestedFilename()).toBe("type-diagram.svg"); + + await page.locator('[data-decl="ConversationRequest"]').click(); + await page.getByRole("button", { name: "Close properties" }).click(); + await expect(page.locator(".td-inspector")).toBeHidden(); + await page.locator('[data-decl="ConversationRequest"]').click(); + await expect(page.locator("#preview")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(page.locator(".td-inspector")).toBeHidden(); + }); +}); diff --git a/packages/web/eleventy/_data/docs.ts b/packages/web/eleventy/_data/docs.ts index af7c6b3..81a87c9 100644 --- a/packages/web/eleventy/_data/docs.ts +++ b/packages/web/eleventy/_data/docs.ts @@ -28,7 +28,7 @@ const handwritten: ReadonlyArray<{ slug: string; label: string; group?: string } { slug: "multi-language-pipeline", label: "Multi-Language Pipeline" }, { slug: "converters", label: "Converters" }, { slug: "render-hooks", label: "Render Hooks" }, - { slug: "tdbin", label: "Binary Codec", group: "TDBIN" }, + { slug: "tdbin", label: "TDBIN Binary Codec", group: "TDBIN" }, { slug: "tdbin-benchmarks", label: "Benchmarks", group: "TDBIN" }, { slug: "tdbin-wire-format", label: "Wire Format", group: "TDBIN" }, { slug: "tdbin-rust-api", label: "Rust API", group: "TDBIN" }, @@ -48,12 +48,15 @@ const introEntry: DocEntry = { html: mdToHtml(`# Introduction\n\n${SHARED_INTRO_MD}`), }; +const docTitle = (label: string, group?: string) => + group === undefined || label.startsWith(group) ? label : `${group} ${label}`; + const loadHandwritten = (slug: string, label: string, group?: string): DocEntry => ({ slug, label, - title: group ? `${group} — ${label}` : label, + title: docTitle(label, group), isTopLevel: true, - ...(group ? { group } : {}), + ...(group === undefined ? {} : { group }), html: mdToHtml(readFileSync(resolve(DOCS_DIR, `${slug}.md`), "utf-8")), }); diff --git a/packages/web/eleventy/index.njk b/packages/web/eleventy/index.njk index 4b11be8..ba26ac4 100644 --- a/packages/web/eleventy/index.njk +++ b/packages/web/eleventy/index.njk @@ -45,7 +45,7 @@ structuredData:

Type-safe diagrams and source code from one schema.

Define your domain once. Generate types for TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, and Protobuf — - and an auto-laid-out SVG diagram — from the same source. + and a direct-manipulation visual type canvas — from the same source.

@@ -90,10 +90,10 @@ structuredData:
schema -

SVG diagrams

+

Visual type editor

- Automatic orthogonal layout — no dragging, no fiddling. Versionable in git and rendered the same on - every machine. + Drag types, edit fields in place, draw relationships, pan, zoom, and auto-layout. Every change remains + a clean, versionable .td schema.

@@ -115,10 +115,10 @@ structuredData:
hub -

A shared schema, not a diagramming tool

+

A canvas backed by a real schema

- Unlike Mermaid or PlantUML, the diagram is a side effect — not the goal. The primary output is code, in - as many languages as you need, kept strictly in sync by construction. + The beautiful canvas and the source model are one document. Visual edits produce typed source, ready + to generate code in every supported language.

@@ -140,10 +140,10 @@ structuredData:
VS Code Extension -

Live preview in your editor.

+

A visual type canvas in your editor.

- Syntax highlighting, hover docs, and a side-by-side SVG preview that updates as you type. Available free - on the Visual Studio Marketplace. + Drag nodes, edit fields, draw relationships, and navigate the infinite canvas beside syntax-highlighted + source. Every edit stays synchronized. Available free on the Visual Studio Marketplace.

Engineered for Scale -

Complex diagrams, simplified by code.

+

Complex type systems, shaped visually.

- Stop fighting with drag-and-drop tools. typeDiagram treats your architecture as a living document, - versionable and strictly typed. + Sketch relationships as freely as a whiteboard while typeDiagram keeps the architecture versionable, + generated, and strictly typed.

Get started diff --git a/packages/web/package.json b/packages/web/package.json index 00c44af..637e78d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "marked": "^18.0.6", - "typediagram-core": "0.9.0" + "typediagram-core": "0.0.0-dev" }, "devDependencies": { "@11ty/eleventy": "^3.1.6", diff --git a/packages/web/src/playground.ts b/packages/web/src/playground.ts index a247c8f..279f136 100644 --- a/packages/web/src/playground.ts +++ b/packages/web/src/playground.ts @@ -7,14 +7,13 @@ import { debounce } from "./debounce.js"; import { renderPane } from "./render-pane.js"; import { initSplitter } from "./splitter.js"; -import { createViewport, setViewportContent } from "./viewport.js"; import { initHighlight } from "./highlight.js"; import { initEditorZoom } from "./editor-zoom.js"; -import { createZoomControls } from "./zoom-controls.js"; import { evalHooks } from "./eval-hooks.js"; import { PRESETS, togglePresetInCode, presetsInCode, type PresetId } from "./hook-presets.js"; import { initJsHighlight } from "./highlight-js.js"; import { HOME_PAGE_SAMPLE } from "typediagram-core"; +import { createVisualEditor, type NodePosition } from "typediagram-core/editor"; const INITIAL_SOURCE = HOME_PAGE_SAMPLE; @@ -126,6 +125,18 @@ const activateTab = (tabId: "source" | "hooks", refs: ReturnType> => { + const stored = localStorage.getItem(STORAGE_POSITIONS_KEY); + try { + const parsed: unknown = JSON.parse(stored ?? "{}"); + // Safe: position values are consumed only as numeric SVG translations. + return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; + } catch { + return {}; + } +}; export const mountPlayground = (container: HTMLElement) => { const refs = buildDom(container); @@ -142,8 +153,6 @@ export const mountPlayground = (container: HTMLElement) => { editorWrap, } = refs; initSplitter(container, splitter); - const vp = createViewport(preview); - createZoomControls(preview, vp); const savedSource = localStorage.getItem(STORAGE_SOURCE_KEY); const savedHooks = localStorage.getItem(STORAGE_HOOKS_KEY); @@ -157,6 +166,19 @@ export const mountPlayground = (container: HTMLElement) => { console.error(`[playground] ${where} render failed`, err); }; + const visualEditor = createVisualEditor(preview, { + getSource: () => editor.value, + onSourceChange: (source) => { + editor.value = source; + localStorage.setItem(STORAGE_SOURCE_KEY, source); + editor.dispatchEvent(new Event("input", { bubbles: true })); + }, + initialPositions: savedPositions(), + onPositionsChange: (positions) => { + localStorage.setItem(STORAGE_POSITIONS_KEY, JSON.stringify(positions)); + }, + }); + buildPresetButtons( hooksToolbar, () => hooksEditor.value, @@ -187,7 +209,7 @@ export const mountPlayground = (container: HTMLElement) => { hooksBadge.textContent = ""; } const html = await renderPane(editor.value, evaluated.hooks); - setViewportContent(preview, html); + visualEditor.setContent(html); }; const debounced = debounce(() => { run().catch(logRenderFailure("debounced")); diff --git a/packages/web/src/render-pane.ts b/packages/web/src/render-pane.ts index d346f37..5e34f77 100644 --- a/packages/web/src/render-pane.ts +++ b/packages/web/src/render-pane.ts @@ -2,8 +2,14 @@ // Lazy-loads `typediagram` so the main chunk stays free of framework + ELK weight. import type { RenderHooks } from "typediagram-core"; -const getTheme = () => - window.matchMedia("(prefers-color-scheme: dark)").matches ? ("dark" as const) : ("light" as const); +const getTheme = () => { + const selected = document.documentElement.dataset.theme; + return selected === "dark" || selected === "light" + ? selected + : window.matchMedia("(prefers-color-scheme: dark)").matches + ? ("dark" as const) + : ("light" as const); +}; export const renderPane = async (source: string, hooks?: RenderHooks): Promise => { const { parser, renderToString } = await import("typediagram-core"); diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 6cd327b..cb06e31 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -752,7 +752,7 @@ pre[class*="language-"] { flex: 1; overflow: hidden; padding: 0; - background: var(--td-surface); + background-color: var(--td-surface); position: relative; } diff --git a/packages/web/src/viewport.ts b/packages/web/src/viewport.ts index 1e03d71..ed748a4 100644 --- a/packages/web/src/viewport.ts +++ b/packages/web/src/viewport.ts @@ -1,188 +1,2 @@ -// [WEB-VIEWPORT] Pan + zoom for the SVG preview container. -// Applies CSS transform to a wrapper div inside #preview. - -export type ViewportState = { - scale: number; - translateX: number; - translateY: number; -}; - -const ZOOM_MIN = 0.1; -const ZOOM_MAX = 5; -const ZOOM_STEP = 0.1; - -const clampScale = (s: number): number => (s < ZOOM_MIN ? ZOOM_MIN : s > ZOOM_MAX ? ZOOM_MAX : s); - -export type ViewportControls = ViewportState & { - reset: () => void; - zoomIn: () => void; - zoomOut: () => void; - fit: () => void; -}; - -export const createViewport = (container: HTMLElement): ViewportControls => { - const state: ViewportState = { scale: 1, translateX: 0, translateY: 0 }; - - const wrapper = document.createElement("div"); - wrapper.className = "viewport-wrapper"; - wrapper.style.transformOrigin = "0 0"; - container.appendChild(wrapper); - - const apply = () => { - wrapper.style.transform = `translate(${String(state.translateX)}px, ${String(state.translateY)}px) scale(${String(state.scale)})`; - }; - - // --- Zoom (wheel) --- - container.addEventListener( - "wheel", - (e) => { - e.preventDefault(); - const rect = container.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - - const prevScale = state.scale; - const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; - state.scale = clampScale(prevScale + delta * prevScale); - - const ratio = state.scale / prevScale; - state.translateX = mx - ratio * (mx - state.translateX); - state.translateY = my - ratio * (my - state.translateY); - apply(); - }, - { passive: false } - ); - - // --- Pan (pointer drag) --- - let dragging = false; - let startX = 0; - let startY = 0; - let startTx = 0; - let startTy = 0; - - container.addEventListener("pointerdown", (e) => { - const target = e.target as HTMLElement; - const isInteractive = target.tagName === "A" || target.tagName === "BUTTON"; - if (isInteractive) { - return; - } - dragging = true; - startX = e.clientX; - startY = e.clientY; - startTx = state.translateX; - startTy = state.translateY; - if (typeof container.setPointerCapture === "function") { - container.setPointerCapture(e.pointerId); - } - container.style.cursor = "grabbing"; - }); - - container.addEventListener("pointermove", (e) => { - if (!dragging) { - return; - } - state.translateX = startTx + (e.clientX - startX); - state.translateY = startTy + (e.clientY - startY); - apply(); - }); - - const stopDrag = () => { - dragging = false; - container.style.cursor = "grab"; - }; - container.addEventListener("pointerup", stopDrag); - container.addEventListener("pointercancel", stopDrag); - - container.style.cursor = "grab"; - - const reset = () => { - state.scale = 1; - state.translateX = 0; - state.translateY = 0; - apply(); - }; - - const zoomIn = () => { - const rect = container.getBoundingClientRect(); - const cx = rect.width / 2; - const cy = rect.height / 2; - const prevScale = state.scale; - state.scale = clampScale(prevScale + ZOOM_STEP * prevScale); - const ratio = state.scale / prevScale; - state.translateX = cx - ratio * (cx - state.translateX); - state.translateY = cy - ratio * (cy - state.translateY); - apply(); - }; - - const zoomOut = () => { - const rect = container.getBoundingClientRect(); - const cx = rect.width / 2; - const cy = rect.height / 2; - const prevScale = state.scale; - state.scale = clampScale(prevScale - ZOOM_STEP * prevScale); - const ratio = state.scale / prevScale; - state.translateX = cx - ratio * (cx - state.translateX); - state.translateY = cy - ratio * (cy - state.translateY); - apply(); - }; - - const fit = () => { - const svg = wrapper.querySelector("svg"); - if (svg instanceof SVGSVGElement) { - fitSvg(container, wrapper, svg); - } else { - reset(); - } - }; - - return { - ...state, - reset, - zoomIn, - zoomOut, - fit, - get scale() { - return state.scale; - }, - }; -}; - -/** - * [WEB-VIEWPORT-PRESERVE] Move rendered content into the viewport wrapper. - * Fit-to-container runs ONLY on the first ever render — once the user has - * interacted (or any non-identity transform is set) we leave it alone so - * their pan/zoom survives subsequent re-renders. - */ -export const setViewportContent = (container: HTMLElement, html: string) => { - const wrapper = container.querySelector(".viewport-wrapper"); - const target = wrapper ?? container; - const priorTransform = wrapper?.style.transform ?? ""; - target.innerHTML = html; - - const svg = target.querySelector("svg"); - const hasWrapper = wrapper instanceof HTMLElement; - if (svg === null || !hasWrapper) { - return; - } - if (priorTransform === "") { - fitSvg(container, wrapper, svg); - return; - } - wrapper.style.transform = priorTransform; -}; - -const FIT_PADDING = 16; - -const fitSvg = (container: HTMLElement, wrapper: HTMLElement, svg: SVGSVGElement) => { - const cw = container.clientWidth; - const ch = container.clientHeight; - const sw = svg.width.baseVal.value; - const sh = svg.height.baseVal.value; - - const noSize = sw === 0 || sh === 0 || cw === 0 || ch === 0; - const scale = noSize ? 1 : Math.min((cw - FIT_PADDING * 2) / sw, (ch - FIT_PADDING * 2) / sh, 2); - - const tx = (cw - sw * scale) / 2; - const ty = (ch - sh * scale) / 2; - wrapper.style.transform = `translate(${String(tx)}px, ${String(ty)}px) scale(${String(scale)})`; -}; +// [WEB-VIEWPORT] The web host consumes the framework's shared canvas behavior. +export { createViewport, setViewportContent, type ViewportControls, type ViewportState } from "typediagram-core/editor"; diff --git a/packages/web/test/__snapshots__/alias-chain.svg b/packages/web/test/__snapshots__/alias-chain.svg index 5cc1edf..c6ef753 100644 --- a/packages/web/test/__snapshots__/alias-chain.svg +++ b/packages/web/test/__snapshots__/alias-chain.svg @@ -1,45 +1,41 @@ - + + + + - - - - - - - alias Email + + + + + + + + + alias Email - -= String + = String - - - - - - - alias UserEmail + + + + + + alias UserEmail - -= Email + = Email - - - - - - - alias AdminEmail + + + + + + alias AdminEmail - -= UserEmail + = UserEmail - - - - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/all-external.svg b/packages/web/test/__snapshots__/all-external.svg index 9d292cc..d2e759c 100644 --- a/packages/web/test/__snapshots__/all-external.svg +++ b/packages/web/test/__snapshots__/all-external.svg @@ -1,28 +1,26 @@ - + + + + - - - - - - - HttpRequest + + + + + + + + HttpRequest - -url: URL - -method: HttpMethod - -headers: Map<String, String> - -body: Option<Bytes> - -timeout: Duration + url: URL +method: HttpMethod +headers: Map<String, String> +body: Option<Bytes> +timeout: Duration - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/all-primitives.svg b/packages/web/test/__snapshots__/all-primitives.svg index c2ea64e..a147a0d 100644 --- a/packages/web/test/__snapshots__/all-primitives.svg +++ b/packages/web/test/__snapshots__/all-primitives.svg @@ -1,30 +1,27 @@ - + + + + - - - - - - - AllPrimitives + + + + + + + + AllPrimitives - -b: Bool - -i: Int - -f: Float - -s: String - -by: Bytes - -u: Unit + b: Bool +i: Int +f: Float +s: String +by: Bytes +u: Unit - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/chat-model-render.test.svg b/packages/web/test/__snapshots__/chat-model-render.test.svg index 8723f2d..a4b5238 100644 --- a/packages/web/test/__snapshots__/chat-model-render.test.svg +++ b/packages/web/test/__snapshots__/chat-model-render.test.svg @@ -1,173 +1,128 @@ - + + + + - - - - - - - ChatRequest + +tool_results +tool_results +tool_results +tool_results +content +List.items +Text.value +Uri.value +kind +media_type + + + + + + ChatRequest - -message: String - -session_id: String - -tool_results: Option<List<ToolResult>> + message: String +session_id: String +tool_results: Option<List<ToolResult>> - - - - - - - ChatTurnInput + + + + + + ChatTurnInput - -config: AgentConfig - -user_message: String - -tool_results: Option<List<ToolResult>> - -session_id: String + config: AgentConfig +user_message: String +tool_results: Option<List<ToolResult>> +session_id: String - - - - - - - ToolResult + + + + + + ToolResult - -tool_call_id: String - -name: String - -content: ToolResultContent - -ok: Bool + tool_call_id: String +name: String +content: ToolResultContent +ok: Bool - - - - - - - union ToolResultContent - ONE OF - - -◇ None - -◇ Scalar { value: String } - -◇ Dict { entries: Map<String, String> } - -◇ List { items: List<ContentItem> } + + + + + + union ToolResultContent + ONE OF + + ◇ None +◇ Scalar { value: String } +◇ Dict { entries: Map<String, String> } +◇ List { items: List<ContentItem> } - - - - - - - union ContentItem - ONE OF - - -◇ Text { value: TextPart } - -◇ Uri { value: UriPart } - -◇ Scalar { value: String } + + + + + + union ContentItem + ONE OF + + ◇ Text { value: TextPart } +◇ Uri { value: UriPart } +◇ Scalar { value: String } - - - - - - - TextPart + + + + + + TextPart - -text: String + text: String - - - - - - - UriPart + + + + + + UriPart - -url: String - -kind: UriKind - -media_type: Option<String> + url: String +kind: UriKind +media_type: Option<String> - - - - - - - union UriKind - ONE OF - - -◇ Image - -◇ Audio - -◇ Video - -◇ Document - -◇ Web - -◇ Api + + + + + + union UriKind + ONE OF + + ◇ Image +◇ Audio +◇ Video +◇ Document +◇ Web +◇ Api - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - -tool_results - -tool_results - -tool_results - -tool_results - -content - -List.items - -Text.value - -Uri.value - -kind - -media_type \ No newline at end of file diff --git a/packages/web/test/__snapshots__/deep-generics.svg b/packages/web/test/__snapshots__/deep-generics.svg index b26488b..7ddecdd 100644 --- a/packages/web/test/__snapshots__/deep-generics.svg +++ b/packages/web/test/__snapshots__/deep-generics.svg @@ -1,50 +1,44 @@ - + + + + - - - - - - - Config + +rules +rules + + + + + + Config - -rules: Map<String, List<Option<Rule>>> + rules: Map<String, List<Option<Rule>>> - - - - - - - Rule + + + + + + Rule - -name: String - -priority: Int + name: String +priority: Int - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - -rules - -rules \ No newline at end of file diff --git a/packages/web/test/__snapshots__/empty-diagram.svg b/packages/web/test/__snapshots__/empty-diagram.svg index 9dea812..02b5235 100644 --- a/packages/web/test/__snapshots__/empty-diagram.svg +++ b/packages/web/test/__snapshots__/empty-diagram.svg @@ -1,10 +1,14 @@ - + + + + + \ No newline at end of file diff --git a/packages/web/test/__snapshots__/long-names.svg b/packages/web/test/__snapshots__/long-names.svg index 4bb1b05..e7e2bab 100644 --- a/packages/web/test/__snapshots__/long-names.svg +++ b/packages/web/test/__snapshots__/long-names.svg @@ -1,22 +1,23 @@ - + + + + - - - - - - - VeryLongTypeNameThatShouldNotBreakLayout + + + + + + + + VeryLongTypeNameThatShouldNotBreakLayout - -this_is_an_extremely_long_field_name_that_tests_rendering: String - -short: Int + this_is_an_extremely_long_field_name_that_tests_rendering: String +short: Int - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/many-nodes.svg b/packages/web/test/__snapshots__/many-nodes.svg index 2307747..ff5b3f7 100644 --- a/packages/web/test/__snapshots__/many-nodes.svg +++ b/packages/web/test/__snapshots__/many-nodes.svg @@ -1,97 +1,81 @@ - + + + + - - - - - - - A + +x +x +x +x +x +x + + + + + + A - -x: B + x: B - - - - - - - B + + + + + + B - -x: C + x: C - - - - - - - C + + + + + + C - -x: D + x: D - - - - - - - D + + + + + + D - -x: E + x: E - - - - - - - E + + + + + + E - -x: F + x: F - - - - - - - F + + + + + + F - -x: G + x: G - - - - - - - G + + + + + + G - -x: String + x: String - -x - -x - -x - -x - -x - -x \ No newline at end of file diff --git a/packages/web/test/__snapshots__/mixed-union.svg b/packages/web/test/__snapshots__/mixed-union.svg index 8627e7c..3d6c748 100644 --- a/packages/web/test/__snapshots__/mixed-union.svg +++ b/packages/web/test/__snapshots__/mixed-union.svg @@ -1,29 +1,27 @@ - + + + + - - - - - - - union Event - ONE OF - - -◇ Click { x: Int, y: Int } - -◇ KeyPress { key: String, modifiers: List<String> } - -◇ Scroll { deltaX: Float, deltaY: Float } - -◇ Focus - -◇ Blur - + + + + + + + union Event + ONE OF + + ◇ Click { x: Int, y: Int } +◇ KeyPress { key: String, modifiers: List<String> } +◇ Scroll { deltaX: Float, deltaY: Float } +◇ Focus +◇ Blur + \ No newline at end of file diff --git a/packages/web/test/__snapshots__/multi-generics.svg b/packages/web/test/__snapshots__/multi-generics.svg index 2bf6b29..4236053 100644 --- a/packages/web/test/__snapshots__/multi-generics.svg +++ b/packages/web/test/__snapshots__/multi-generics.svg @@ -1,50 +1,45 @@ - + + + + - - - - - - - Pair<A, B> + + + + + + + + Pair<A, B> - -first: A - -second: B + first: A +second: B - - - - - - - union Either<L, R> - ONE OF - - -◇ Left { value: L } - -◇ Right { value: R } + + + + + + union Either<L, R> + ONE OF + + ◇ Left { value: L } +◇ Right { value: R } - - - - - - - union Result<T, E> - ONE OF - - -◇ Ok { value: T } - -◇ Err { error: E } + + + + + + union Result<T, E> + ONE OF + + ◇ Ok { value: T } +◇ Err { error: E } - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/self-ref.svg b/packages/web/test/__snapshots__/self-ref.svg index 31f90f5..0220b28 100644 --- a/packages/web/test/__snapshots__/self-ref.svg +++ b/packages/web/test/__snapshots__/self-ref.svg @@ -1,23 +1,23 @@ - + + + + - - - - - - - TreeNode + +children + + + + + + TreeNode - -value: String - -children: List<TreeNode> + value: String +children: List<TreeNode> - -children \ No newline at end of file diff --git a/packages/web/test/__snapshots__/single-alias.svg b/packages/web/test/__snapshots__/single-alias.svg index 3a4dd4b..b8404eb 100644 --- a/packages/web/test/__snapshots__/single-alias.svg +++ b/packages/web/test/__snapshots__/single-alias.svg @@ -1,20 +1,22 @@ - + + + + - - - - - - - alias UserId + + + + + + + + alias UserId - -= String + = String - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/single-record.svg b/packages/web/test/__snapshots__/single-record.svg index 073141a..a77c701 100644 --- a/packages/web/test/__snapshots__/single-record.svg +++ b/packages/web/test/__snapshots__/single-record.svg @@ -1,22 +1,23 @@ - + + + + - - - - - - - Point + + + + + + + + Point - -x: Float - -y: Float + x: Float +y: Float - \ No newline at end of file diff --git a/packages/web/test/__snapshots__/single-union.svg b/packages/web/test/__snapshots__/single-union.svg index 0f20372..125ce62 100644 --- a/packages/web/test/__snapshots__/single-union.svg +++ b/packages/web/test/__snapshots__/single-union.svg @@ -1,27 +1,26 @@ - + + + + - - - - - - - union Direction - ONE OF - - -◇ North - -◇ South - -◇ East - -◇ West - + + + + + + + union Direction + ONE OF + + ◇ North +◇ South +◇ East +◇ West + \ No newline at end of file diff --git a/packages/web/test/__snapshots__/small-example.svg b/packages/web/test/__snapshots__/small-example.svg index c338eac..d496ea6 100644 --- a/packages/web/test/__snapshots__/small-example.svg +++ b/packages/web/test/__snapshots__/small-example.svg @@ -1,89 +1,71 @@ - + + + + - - - - - - - User + +email +email +address + + + + + + User - -id: UUID - -name: String - -email: Option<Email> - -roles: List<Role> - -address: Address + id: UUID +name: String +email: Option<Email> +roles: List<Role> +address: Address - - - - - - - Address + + + + + + Address - -line1: String - -city: String - -country: CountryCode + line1: String +city: String +country: CountryCode - - - - - - - union Shape - ONE OF - - -◇ Circle { radius: Float } - -◇ Square { side: Float } - -◇ Triangle { a: Float, b: Float, c: Float } + + + + + + union Shape + ONE OF + + ◇ Circle { radius: Float } +◇ Square { side: Float } +◇ Triangle { a: Float, b: Float, c: Float } - - - - - - - union Option<T> - ONE OF - - -◇ Some { value: T } - -◇ None + + + + + + union Option<T> + ONE OF + + ◇ Some { value: T } +◇ None - - - - - - - alias Email + + + + + + alias Email - -= String + = String - -email - -email - -address \ No newline at end of file diff --git a/packages/web/test/__snapshots__/union-refs-union.svg b/packages/web/test/__snapshots__/union-refs-union.svg index 6d4bf26..752d420 100644 --- a/packages/web/test/__snapshots__/union-refs-union.svg +++ b/packages/web/test/__snapshots__/union-refs-union.svg @@ -1,40 +1,36 @@ - + + + + - - - - - - - union Outer - ONE OF - - -◇ Leaf { value: String } - -◇ Nested { inner: Inner } + +Nested.inner + + + + + + union Outer + ONE OF + + ◇ Leaf { value: String } +◇ Nested { inner: Inner } - - - - - - - union Inner - ONE OF - - -◇ A - -◇ B { data: Int } - -◇ C { label: String, count: Int } + + + + + + union Inner + ONE OF + + ◇ A +◇ B { data: Int } +◇ C { label: String, count: Int } - -Nested.inner \ No newline at end of file diff --git a/packages/web/test/render-pane.test.ts b/packages/web/test/render-pane.test.ts index 2fb164d..f95357d 100644 --- a/packages/web/test/render-pane.test.ts +++ b/packages/web/test/render-pane.test.ts @@ -33,7 +33,7 @@ describe("[WEB-RENDER-PANE] renderPane", () => { const html = await renderPane(SMALL); expect(html).toMatch(/^]/); // dark theme uses dark node fill color - expect(html).toContain("#252931"); + expect(html).toContain("#222a3d"); spy.mockRestore(); }); }); From 3450d35b2ed276c359e37a42c849d65958c516ca Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:13:17 +1000 Subject: [PATCH 12/14] Stuff --- .mcp.json | 7 + coverage-thresholds.json | 4 +- docs/specs/cli.md | 39 ++- docs/specs/multi-language-pipeline.md | 19 ++ packages/cli/README.md | 20 ++ packages/cli/src/args.ts | 71 +++-- packages/cli/src/cli.ts | 20 +- packages/cli/src/generation-config.ts | 273 ++++++++++++++++++ packages/cli/test/args.test.ts | 15 + .../cli/test/config-generation.e2e.test.ts | 222 ++++++++++++++ packages/typediagram/src/editor/controls.ts | 4 +- .../typediagram/src/editor/source-editor.ts | 14 +- .../typediagram/src/editor/visual-editor.ts | 12 +- packages/typediagram/test/editor.test.ts | 11 + .../typediagram/test/visual-editor.test.ts | 100 ++++++- packages/vscode/src/extension.ts | 15 +- packages/vscode/src/preview-panel.ts | 18 +- .../src/webview/interaction-protocol.ts | 3 +- .../vscode/src/webview/interaction-test.ts | 229 ++++++++++++--- packages/vscode/src/webview/main.ts | 5 +- .../vscode/test/electron/macos-launcher.sh | 6 + packages/vscode/test/electron/runner.mjs | 14 +- .../test/electron/suite/extension.spec.cjs | 108 +++++-- packages/vscode/test/extension.test.ts | 106 +++++++ packages/vscode/test/webview-html.test.ts | 13 +- packages/web/e2e/visual-editor.spec.ts | 205 ++++++++++++- 26 files changed, 1420 insertions(+), 133 deletions(-) create mode 100644 .mcp.json create mode 100644 packages/cli/src/generation-config.ts create mode 100644 packages/cli/test/config-generation.e2e.test.ts create mode 100755 packages/vscode/test/electron/macos-launcher.sh diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..c0e2ef4 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "too-many-cooks": { + "url": "http://localhost:4040/mcp" + } + } +} \ No newline at end of file diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 606466e..664b0a4 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -4,10 +4,10 @@ "default_threshold": 90, "projects": { "packages/typediagram": { - "statements": 97.12, + "statements": 97.14, "branches": 91.05, "functions": 98.8, - "lines": 97.09 + "lines": 97.11 }, "packages/cli": { "statements": 99, diff --git a/docs/specs/cli.md b/docs/specs/cli.md index d7f3db1..559e969 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -14,22 +14,47 @@ npx typediagram schema.td > diagram.svg ``` typediagram [options] [file] +typediagram --config FILE [--watch] ``` If `file` is omitted, reads from stdin. Output goes to stdout. Errors go to stderr with exit code 1. ## Options -| Flag | Value | Description | -| -------------- | ------------------------------------------------------------------- | ------------------------------------------- | -| `--from` | `typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from language source to SVG | -| `--to` | `typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from typeDiagram to language source | -| `--theme` | `light\|dark` | Color theme (default: `light`) | -| `--font-size` | number | Font size in pixels | -| `-h`, `--help` | | Show help | +| Flag | Value | Description | +| -------------- | ------------------------------------------------------------------- | ------------------------------------------------- | +| `--config` | JSON file | Generate every configured language output | +| `--watch` | | Regenerate configured outputs after `.td` changes | +| `--from` | `typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from language source to SVG | +| `--to` | `typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from typeDiagram to language source | +| `--theme` | `light\|dark` | Color theme (default: `light`) | +| `--font-size` | number | Font size in pixels | +| `-h`, `--help` | | Show help | `--from` and `--to` are mutually exclusive. +## Configured generation and watch mode + +Paths are relative to the config file. Each `outputs` key selects one converter from the supported language registry: + +```json +{ + "source": "schemas/person.td", + "watch": true, + "outputs": { + "typescript": "frontend/src/generated/person.ts", + "rust": "backend/src/generated/person.rs", + "python": "services/generated/person.py" + } +} +``` + +```sh +typediagram --config typediagram.json +``` + +`"watch": true` performs initial generation and then watches the source. `--watch` enables the same behavior from the command line when the config omits it. Each valid save regenerates every selected language. Invalid source reports diagnostics without overwriting the last good generated files; the watcher recovers automatically after the next valid save. + ## Examples ### Render typeDiagram to SVG diff --git a/docs/specs/multi-language-pipeline.md b/docs/specs/multi-language-pipeline.md index 4cd70b2..fd4dcac 100644 --- a/docs/specs/multi-language-pipeline.md +++ b/docs/specs/multi-language-pipeline.md @@ -2,6 +2,25 @@ Configure typeDiagram as the single source of truth for DTOs in a polyglot project. This guide uses a TypeScript frontend + Rust backend, but the pattern works for any combination of languages. +## Configure generation once + +Create `typediagram.json` at the repository root. Paths are resolved relative to this file: + +```json +{ + "source": "schemas/user.td", + "watch": true, + "outputs": { + "typescript": "frontend/src/generated/user.ts", + "rust": "backend/src/generated/user.rs" + } +} +``` + +Run `typediagram --config typediagram.json`. It generates both outputs immediately and then watches `schemas/user.td`. Every valid edit regenerates both files. Invalid edits report diagnostics while preserving the last good generated code, and the next valid edit recovers without restarting the watcher. + +For one-shot CI generation, omit `"watch": true`. For an ad hoc development watcher, use `typediagram --config typediagram.json --watch`. + ## Project layout ``` diff --git a/packages/cli/README.md b/packages/cli/README.md index 513e2e2..731e652 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -22,6 +22,9 @@ typediagram --from typescript types.ts > diagram.svg # DSL → source language typediagram --to rust schema.td > types.rs +# One schema → every configured language, regenerated on save +typediagram --config typediagram.json --watch + # Read from stdin cat schema.td | typediagram > diagram.svg ``` @@ -30,6 +33,8 @@ cat schema.td | typediagram > diagram.svg | Flag | Values | Default | | --------------- | ----------------------------------------------------------------------------------- | ------- | +| `--config FILE` | JSON source/output manifest | — | +| `--watch` | watch the configured `.td` source | config | | `--from ` | `typescript`, `python`, `rust`, `go`, `csharp`, `fsharp`, `dart`, `protobuf`, `php` | — | | `--to ` | `typescript`, `python`, `rust`, `go`, `csharp`, `fsharp`, `dart`, `protobuf`, `php` | — | | `--emit ` | `svg`, `td`, `td+svg` (for `--from`) | `svg` | @@ -39,6 +44,21 @@ cat schema.td | typediagram > diagram.svg If no file is given, stdin is read. Output goes to stdout; errors go to stderr. +Config paths are relative to the config file: + +```json +{ + "source": "schemas/user.td", + "watch": true, + "outputs": { + "typescript": "frontend/src/generated/user.ts", + "rust": "backend/src/generated/user.rs" + } +} +``` + +Watch mode retains generated files when an edit is invalid and regenerates all selected languages after the source becomes valid again. + ## Example ```sh diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 335756e..cc6e3a9 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -15,6 +15,8 @@ const TDBIN_COMMANDS: ReadonlySet = new Set(["encode export interface CliArgs { readonly file: string | null; + readonly config: string | null; + readonly watch: boolean; readonly tdbinCommand: TdbinCommand | null; readonly theme: Theme; readonly fontSize: number | null; @@ -47,6 +49,8 @@ const parseFontSize = (v: string): Result => { export const parseArgs = (argv: readonly string[]): Result => { const state = { file: null as string | null, + config: null as string | null, + watch: false, tdbinCommand: null as TdbinCommand | null, theme: "light" as Theme, fontSize: null as number | null, @@ -71,13 +75,19 @@ export const parseArgs = (argv: readonly string[]): Result => } cur = it.next(); } - return state.from !== null && state.to !== null - ? err({ message: "--from and --to are mutually exclusive" }) - : state.tdbinCommand !== null && (state.from !== null || state.to !== null) - ? err({ message: "tdbin commands cannot be combined with --from or --to" }) - : state.json && !state.version - ? err({ message: "--json requires --version" }) - : ok(state); + return state.watch && state.config === null + ? err({ message: "--watch requires --config" }) + : state.config !== null && state.file !== null + ? err({ message: "--config cannot be combined with a positional file" }) + : state.config !== null && (state.from !== null || state.to !== null || state.tdbinCommand !== null) + ? err({ message: "--config cannot be combined with --from, --to, or a tdbin command" }) + : state.from !== null && state.to !== null + ? err({ message: "--from and --to are mutually exclusive" }) + : state.tdbinCommand !== null && (state.from !== null || state.to !== null) + ? err({ message: "tdbin commands cannot be combined with --from or --to" }) + : state.json && !state.version + ? err({ message: "--json requires --version" }) + : ok(state); }; const applyArg = ( @@ -85,6 +95,8 @@ const applyArg = ( next: () => string | null, s: { file: string | null; + config: string | null; + watch: boolean; tdbinCommand: TdbinCommand | null; theme: Theme; fontSize: number | null; @@ -102,23 +114,30 @@ const applyArg = ( ? ((s.version = true), ok(true as const)) : a === "--json" ? ((s.json = true), ok(true as const)) - : a === "--theme" - ? applyTheme(next(), s) - : a === "--font-size" - ? applyFontSize(next(), s) - : a === "--from" - ? applyLang(next(), s, "from") - : a === "--to" - ? applyLang(next(), s, "to") - : a === "--emit" - ? applyEmit(next(), s) - : a.startsWith("-") - ? err({ message: `unknown flag: ${a}` }) - : s.tdbinCommand === null && s.file === null && isTdbinCommand(a) - ? ((s.tdbinCommand = a), ok(true as const)) - : s.file !== null - ? err({ message: `unexpected positional arg: ${a}` }) - : ((s.file = a), ok(true as const)); + : a === "--config" + ? applyConfig(next(), s) + : a === "--watch" + ? ((s.watch = true), ok(true as const)) + : a === "--theme" + ? applyTheme(next(), s) + : a === "--font-size" + ? applyFontSize(next(), s) + : a === "--from" + ? applyLang(next(), s, "from") + : a === "--to" + ? applyLang(next(), s, "to") + : a === "--emit" + ? applyEmit(next(), s) + : a.startsWith("-") + ? err({ message: `unknown flag: ${a}` }) + : s.tdbinCommand === null && s.file === null && isTdbinCommand(a) + ? ((s.tdbinCommand = a), ok(true as const)) + : s.file !== null + ? err({ message: `unexpected positional arg: ${a}` }) + : ((s.file = a), ok(true as const)); + +const applyConfig = (v: string | null, s: { config: string | null }): Result => + v === null ? err({ message: "--config expects a file path" }) : ((s.config = v), ok(true as const)); const applyTheme = (v: string | null, s: { theme: Theme }): Result => v === null @@ -159,11 +178,14 @@ export const HELP_TEXT = `typediagram — render typeDiagram DSL to SVG, or conv Usage: typediagram [options] [file] + typediagram --config FILE [--watch] typediagram encode [file] typediagram decode [file] typediagram verify [file] Options: + --config FILE Generate configured language outputs from one .td source + --watch Regenerate configured outputs whenever the .td source changes --from LANG Convert from language source to SVG --to LANG Convert from typeDiagram to language source --emit svg|td|td+svg Output format for --from (default: svg) @@ -179,5 +201,6 @@ SVG (or language source with --to) is written to stdout. With --emit td, outputs the intermediate typeDiagram source. With --emit td+svg, outputs typeDiagram source then a --- separator then SVG. TDBIN encode emits a generated Rust ADT+codec module; decode emits codec impls for generated Rust ADTs; verify validates schema support. +Config paths are resolved relative to FILE. Invalid watched edits retain the last generated outputs and recover on the next valid edit. Errors go to stderr; exit code 1 on failure. `; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 77d8a39..9ebaa73 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -10,6 +10,7 @@ import { } from "typediagram-core"; import { emitRustCodec, generateRustModule } from "typediagram-core/converters/rust-tdbin"; import { HELP_TEXT, parseArgs, type CliArgs } from "./args.js"; +import { runGenerationConfig } from "./generation-config.js"; import { readSource } from "./io.js"; import { versionJson, versionText } from "./version.js"; @@ -18,7 +19,8 @@ type TdModelResult = { readonly ok: true; readonly value: modelLayer.Model } | { export const main = async ( argv: readonly string[], stdout: NodeJS.WritableStream, - stderr: NodeJS.WritableStream + stderr: NodeJS.WritableStream, + signal?: AbortSignal ): Promise => { const argsResult = parseArgs(argv); return !argsResult.ok @@ -27,13 +29,15 @@ export const main = async ( ? (stdout.write(HELP_TEXT), 0) : argsResult.value.version ? versionFlow(argsResult.value, stdout, stderr) - : argsResult.value.tdbinCommand !== null - ? tdbinFlow(argsResult.value, stdout, stderr) - : argsResult.value.from !== null - ? fromLangFlow(argsResult.value, stdout, stderr) - : argsResult.value.to !== null - ? toLangFlow(argsResult.value, stdout, stderr) - : renderFlow(argsResult.value, stdout, stderr); + : argsResult.value.config !== null + ? runGenerationConfig(argsResult.value.config, argsResult.value.watch, stdout, stderr, signal) + : argsResult.value.tdbinCommand !== null + ? tdbinFlow(argsResult.value, stdout, stderr) + : argsResult.value.from !== null + ? fromLangFlow(argsResult.value, stdout, stderr) + : argsResult.value.to !== null + ? toLangFlow(argsResult.value, stdout, stderr) + : renderFlow(argsResult.value, stdout, stderr); }; /** [SWR-VERSION-CLI-OUTPUT] --version: print from package metadata and exit. No runtime, no network. */ diff --git a/packages/cli/src/generation-config.ts b/packages/cli/src/generation-config.ts new file mode 100644 index 0000000..a3e5aae --- /dev/null +++ b/packages/cli/src/generation-config.ts @@ -0,0 +1,273 @@ +// [CLI-CONFIG-GENERATE] Configured one-schema-to-many-language generation and watching. +import { watch, type FSWatcher } from "node:fs"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; +import { converters, model as modelLayer, parser } from "typediagram-core"; +import type { Result } from "./result.js"; +import { err, ok } from "./result.js"; + +type GenerationError = { readonly message: string }; +type OutputTarget = { readonly language: converters.Language; readonly path: string }; +type GeneratedOutput = OutputTarget & { readonly content: string }; +type GenerationConfig = { + readonly source: string; + readonly outputs: readonly OutputTarget[]; + readonly watch: boolean; +}; + +const recordValue = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const languageValue = (value: string): value is converters.Language => + converters.LANGUAGES.some((language) => language === value); + +const errorMessage = (error: unknown) => String(error); + +const readText = async (path: string): Promise> => { + try { + return ok(await readFile(path, "utf8")); + } catch (error) { + return err({ message: `cannot read ${path}: ${errorMessage(error)}` }); + } +}; + +const parseJson = (text: string, path: string): Result => { + try { + const value: unknown = JSON.parse(text); + return ok(value); + } catch (error) { + return err({ message: `cannot parse ${path}: ${errorMessage(error)}` }); + } +}; + +const parseOutput = ([language, path]: [string, unknown]): Result => + !languageValue(language) + ? err({ message: `unsupported output language '${language}'` }) + : typeof path !== "string" || path.trim().length === 0 + ? err({ message: `output '${language}' expects a non-empty file path` }) + : ok({ language, path }); + +const collectResults = (results: readonly Result[]) => { + const values: T[] = []; + for (const result of results) { + switch (result.ok) { + case false: + return result; + case true: + values.push(result.value); + break; + } + } + return ok(values); +}; + +const parseOutputs = (value: unknown) => { + const results = recordValue(value) ? Object.entries(value).map(parseOutput) : []; + return !recordValue(value) + ? err({ message: "config.outputs must be an object keyed by language" }) + : results.length === 0 + ? err({ message: "config.outputs must select at least one language" }) + : collectResults(results); +}; + +const parseWatch = (value: unknown) => + value === undefined || typeof value === "boolean" + ? ok(value === true) + : err({ message: "config.watch must be a boolean" }); + +const resolveConfig = (path: string, source: string, outputs: readonly OutputTarget[], watchEnabled: boolean) => { + const root = dirname(resolve(path)); + const resolvedSource = resolve(root, source); + const resolvedOutputs = outputs.map((output) => ({ ...output, path: resolve(root, output.path) })); + const collision = resolvedOutputs.find((output) => output.path === resolvedSource); + return collision === undefined + ? ok({ source: resolvedSource, outputs: resolvedOutputs, watch: watchEnabled }) + : err({ message: `output '${collision.language}' cannot overwrite the .td source` }); +}; + +const parseConfig = (value: unknown, path: string): Result => { + const config = recordValue(value) ? value : undefined; + const source = config?.source; + const outputs = parseOutputs(config?.outputs); + const watchEnabled = parseWatch(config?.watch); + return config === undefined + ? err({ message: "generation config must be a JSON object" }) + : typeof source !== "string" || source.trim().length === 0 + ? err({ message: "config.source must be a non-empty .td file path" }) + : !source.endsWith(".td") + ? err({ message: "config.source must point to a .td file" }) + : !outputs.ok + ? outputs + : !watchEnabled.ok + ? watchEnabled + : resolveConfig(path, source, outputs.value, watchEnabled.value); +}; + +const loadConfig = async (path: string) => { + const text = await readText(path); + const json = text.ok ? parseJson(text.value, path) : text; + return json.ok ? parseConfig(json.value, path) : json; +}; + +const modelFromSource = (source: string) => { + const parsed = parser.parse(source); + const built = parsed.ok ? modelLayer.buildModel(parsed.value) : parsed; + return built.ok ? built : err({ message: parser.formatDiagnostics([...built.error]) }); +}; + +const generateOutput = (model: modelLayer.Model, output: OutputTarget) => { + const diagnostics = modelLayer.validateForCodegen(model, output.language); + return diagnostics.length === 0 + ? ok({ ...output, content: converters.byLanguage[output.language].toSource(model) }) + : err({ message: parser.formatDiagnostics(diagnostics) }); +}; + +const generateAll = async (config: GenerationConfig) => { + const source = await readText(config.source); + const model = source.ok ? modelFromSource(source.value) : source; + return model.ok ? collectResults(config.outputs.map((output) => generateOutput(model.value, output))) : model; +}; + +const temporaryOutputPath = (path: string) => + resolve(dirname(path), `.${basename(path)}.${String(process.pid)}.typediagram.tmp`); + +const writeOutput = async (output: GeneratedOutput): Promise> => { + try { + await mkdir(dirname(output.path), { recursive: true }); + const temporary = temporaryOutputPath(output.path); + await writeFile(temporary, output.content, "utf8"); + await rename(temporary, output.path); + return ok(output); + } catch (error) { + return err({ message: `cannot write ${output.path}: ${errorMessage(error)}` }); + } +}; + +const writeAll = async (outputs: readonly GeneratedOutput[]) => { + const results = await Promise.all(outputs.map(writeOutput)); + return collectResults(results); +}; + +const reportError = (error: GenerationError, stderr: NodeJS.WritableStream) => { + stderr.write(`${error.message.trimEnd()}\n`); + return false; +}; + +const reportGenerated = (outputs: readonly GeneratedOutput[], stdout: NodeJS.WritableStream) => { + outputs.forEach((output) => { + stdout.write(`generated ${output.language} -> ${output.path}\n`); + }); + return true; +}; + +const generateOnce = async (config: GenerationConfig, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream) => { + const generated = await generateAll(config); + const written = generated.ok ? await writeAll(generated.value) : generated; + return written.ok ? reportGenerated(written.value, stdout) : reportError(written.error, stderr); +}; + +const processAbortSignal = () => { + const controller = new AbortController(); + const abort = () => { + controller.abort(); + }; + process.once("SIGINT", abort); + process.once("SIGTERM", abort); + controller.signal.addEventListener("abort", () => { + process.removeListener("SIGINT", abort); + process.removeListener("SIGTERM", abort); + }); + return controller.signal; +}; + +const openWatcher = (source: string, regenerate: () => void): Result => { + try { + const name = basename(source); + return ok( + watch(dirname(source), (_event, changed) => { + const relevant = changed === null || changed === name; + switch (relevant) { + case true: + regenerate(); + break; + } + }) + ); + } catch (error) { + return err({ message: `cannot watch ${source}: ${errorMessage(error)}` }); + } +}; + +const watchOutcome = (reason: unknown) => + reason instanceof Error + ? { code: 1, stdout: "", stderr: `watch failed: ${errorMessage(reason)}\n` } + : { code: 0, stdout: "watch stopped\n", stderr: "" }; + +const finishWatcher = async ( + watcher: FSWatcher, + pending: () => Promise, + reason: unknown, + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream +) => { + const outcome = watchOutcome(reason); + watcher.close(); + await pending(); + stderr.write(outcome.stderr); + stdout.write(outcome.stdout); + return outcome.code; +}; + +const waitForWatcher = ( + watcher: FSWatcher, + pending: () => Promise, + signal: AbortSignal, + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream +) => + new Promise((resolveStopped) => { + const finish = (reason: unknown) => { + void finishWatcher(watcher, pending, reason, stdout, stderr).then(resolveStopped); + }; + watcher.once("error", finish); + switch (signal.aborted) { + case true: + finish(undefined); + break; + case false: + signal.addEventListener("abort", finish, { once: true }); + break; + } + }); + +const watchConfig = async ( + config: GenerationConfig, + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream, + signal?: AbortSignal +) => { + let pending = Promise.resolve(); + const regenerate = () => { + pending = pending.then(async () => { + await generateOnce(config, stdout, stderr); + }); + }; + const watcher = openWatcher(config.source, regenerate); + stdout.write(watcher.ok ? `watching ${config.source}\n` : ""); + return watcher.ok + ? waitForWatcher(watcher.value, () => pending, signal ?? processAbortSignal(), stdout, stderr) + : (reportError(watcher.error, stderr), 1); +}; + +export const runGenerationConfig = async ( + path: string, + forceWatch: boolean, + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream, + signal?: AbortSignal +) => { + const loaded = await loadConfig(path); + const initial = loaded.ok ? await generateOnce(loaded.value, stdout, stderr) : reportError(loaded.error, stderr); + const watching = loaded.ok && (forceWatch || loaded.value.watch); + return !loaded.ok || !watching ? (initial ? 0 : 1) : watchConfig(loaded.value, stdout, stderr, signal); +}; diff --git a/packages/cli/test/args.test.ts b/packages/cli/test/args.test.ts index d272a4c..6eaced8 100644 --- a/packages/cli/test/args.test.ts +++ b/packages/cli/test/args.test.ts @@ -11,6 +11,8 @@ describe("[CLI-ARGS] parseArgs", () => { } expect(r.value).toEqual({ file: null, + config: null, + watch: false, tdbinCommand: null, theme: "light", fontSize: null, @@ -153,6 +155,19 @@ describe("[CLI-ARGS] parseArgs", () => { expect(r.ok && r.value.emit).toBe("td+svg"); }); + it("parses config watch mode and rejects every conflicting invocation", () => { + const configured = parseArgs(["--config", "typediagram.json", "--watch"]); + expect(configured.ok).toBe(true); + expect(configured.ok && configured.value.config).toBe("typediagram.json"); + expect(configured.ok && configured.value.watch).toBe(true); + expect(parseArgs(["--watch"]).ok).toBe(false); + expect(parseArgs(["--config"]).ok).toBe(false); + expect(parseArgs(["--config", "td.json", "schema.td"]).ok).toBe(false); + expect(parseArgs(["--config", "td.json", "--to", "rust"]).ok).toBe(false); + expect(parseArgs(["--config", "td.json", "--from", "rust"]).ok).toBe(false); + expect(parseArgs(["--config", "td.json", "encode"]).ok).toBe(false); + }); + it("defaults --emit to svg", () => { const r = parseArgs([]); expect(r.ok && r.value.emit).toBe("svg"); diff --git a/packages/cli/test/config-generation.e2e.test.ts b/packages/cli/test/config-generation.e2e.test.ts new file mode 100644 index 0000000..551776c --- /dev/null +++ b/packages/cli/test/config-generation.e2e.test.ts @@ -0,0 +1,222 @@ +// [CLI-CONFIG-GENERATE-TEST] One config drives atomic multi-language generation +// and repeated source-file regeneration through the real CLI entry point. +import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { main } from "../src/cli.js"; +import { makeStream, run } from "./helpers.js"; + +const source = (fields: string) => `typeDiagram + +type Person { +${fields} +} +`; + +const runConfig = async (root: string, name: string, value: unknown) => { + const path = join(root, name); + await writeFile(path, typeof value === "string" ? value : JSON.stringify(value), "utf8"); + return run(["--config", path]); +}; + +const expectFailure = (result: Awaited>, message: string) => { + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(message); +}; + +const expectGeneratedFields = async (typeScriptPath: string, rustPath: string, tsField: string, rustField: string) => { + expect(await readFile(typeScriptPath, "utf8")).toContain(tsField); + expect(await readFile(rustPath, "utf8")).toContain(rustField); +}; + +describe("[CLI-CONFIG-GENERATE] configured ADT generation", () => { + it("generates every selected language and regenerates after valid, invalid, and recovered edits", async () => { + const root = await mkdtemp(join(tmpdir(), "td-config-generate-")); + const configPath = join(root, "typediagram.json"); + const sourcePath = join(root, "schemas", "person.td"); + const typeScriptPath = join(root, "generated", "web", "person.ts"); + const rustPath = join(root, "generated", "server", "person.rs"); + await mkdir(join(root, "schemas"), { recursive: true }); + await writeFile(sourcePath, source(" id: Int"), "utf8"); + await writeFile( + configPath, + JSON.stringify({ + source: "schemas/person.td", + outputs: { + typescript: "generated/web/person.ts", + rust: "generated/server/person.rs", + }, + }), + "utf8" + ); + + const initial = await run(["--config", configPath]); + expect(initial.code).toBe(0); + expect(initial.stderr).toBe(""); + expect(initial.stdout).toContain(`generated typescript -> ${typeScriptPath}`); + expect(initial.stdout).toContain(`generated rust -> ${rustPath}`); + expect(await readFile(typeScriptPath, "utf8")).toContain("export interface Person"); + expect(await readFile(typeScriptPath, "utf8")).toContain("id: number"); + expect(await readFile(rustPath, "utf8")).toContain("pub struct Person"); + expect(await readFile(rustPath, "utf8")).toContain("pub id: i64"); + + await writeFile( + configPath, + JSON.stringify({ + source: "schemas/person.td", + watch: true, + outputs: { + typescript: "generated/web/person.ts", + rust: "generated/server/person.rs", + }, + }), + "utf8" + ); + + const out = makeStream(); + const err = makeStream(); + const controller = new AbortController(); + const watched = main(["--config", configPath], out.stream, err.stream, controller.signal); + await vi.waitFor(() => { + expect(out.text()).toContain(`watching ${sourcePath}`); + }); + await writeFile(sourcePath, source(" id: Int\n name: String"), "utf8"); + await vi.waitFor(() => expectGeneratedFields(typeScriptPath, rustPath, "name: string", "pub name: String")); + const lastGoodTypeScript = await readFile(typeScriptPath, "utf8"); + const lastGoodRust = await readFile(rustPath, "utf8"); + + await writeFile(sourcePath, `${source(" id: Int\n name: String")}active`, "utf8"); + await vi.waitFor(() => { + expect(err.text()).toContain("expected 'type', 'union', 'untagged union', or 'alias'"); + }); + expect(await readFile(typeScriptPath, "utf8")).toBe(lastGoodTypeScript); + expect(await readFile(rustPath, "utf8")).toBe(lastGoodRust); + expect(lastGoodTypeScript).not.toContain("active: boolean"); + expect(lastGoodRust).not.toContain("pub active: bool"); + + await writeFile(sourcePath, source(" id: Int\n name: String\n active: Bool"), "utf8"); + await vi.waitFor(() => expectGeneratedFields(typeScriptPath, rustPath, "active: boolean", "pub active: bool")); + controller.abort(); + expect(await watched).toBe(0); + expect(out.text().split("generated typescript ->").length - 1).toBeGreaterThanOrEqual(3); + expect(out.text().split("generated rust ->").length - 1).toBeGreaterThanOrEqual(3); + expect(out.text()).toContain("watch stopped"); + expect(await readFile(typeScriptPath, "utf8")).not.toContain("active\n"); + expect(await readFile(rustPath, "utf8")).not.toContain("active\n"); + expect((await readdir(join(root, "generated", "web"))).filter((name) => name.endsWith(".tmp"))).toEqual([]); + expect((await readdir(join(root, "generated", "server"))).filter((name) => name.endsWith(".tmp"))).toEqual([]); + + const invalidConfigPath = join(root, "invalid.json"); + await writeFile( + invalidConfigPath, + JSON.stringify({ source: "schemas/person.td", outputs: { swift: "generated/person.swift" } }), + "utf8" + ); + const invalidConfig = await run(["--config", invalidConfigPath]); + expectFailure(invalidConfig, "unsupported output language 'swift'"); + + const invalidCases: ReadonlyArray = [ + ["array.json", [], "generation config must be a JSON object"], + ["missing-source.json", { outputs: { typescript: "generated/person.ts" } }, "config.source"], + [ + "wrong-extension.json", + { source: "schemas/person.txt", outputs: { typescript: "generated/person.ts" } }, + "must point to a .td file", + ], + ["outputs-array.json", { source: "schemas/person.td", outputs: [] }, "config.outputs must be an object"], + ["outputs-empty.json", { source: "schemas/person.td", outputs: {} }, "select at least one language"], + [ + "output-path-empty.json", + { source: "schemas/person.td", outputs: { typescript: "", rust: "generated/person.rs" } }, + "expects a non-empty file path", + ], + [ + "watch-invalid.json", + { source: "schemas/person.td", watch: "yes", outputs: { typescript: "generated/person.ts" } }, + "config.watch must be a boolean", + ], + [ + "source-collision.json", + { source: "schemas/person.td", outputs: { typescript: "schemas/person.td" } }, + "cannot overwrite the .td source", + ], + ["malformed.json", "{", "cannot parse"], + ]; + for (const [name, value, message] of invalidCases) { + expectFailure(await runConfig(root, name, value), message); + } + + const absentConfig = await run(["--config", join(root, "absent.json")]); + expectFailure(absentConfig, "cannot read"); + const absentSource = await runConfig(root, "absent-source.json", { + source: "schemas/absent.td", + outputs: { typescript: "generated/absent.ts" }, + }); + expectFailure(absentSource, "cannot read"); + + await writeFile(sourcePath, source(" mystery: Mystery"), "utf8"); + const codegenFailure = await runConfig(root, "codegen-failure.json", { + source: "schemas/person.td", + outputs: { typescript: "generated/person.ts", rust: "generated/person.rs" }, + }); + expectFailure(codegenFailure, "unknown type 'Mystery'"); + + await writeFile(sourcePath, "type Person { id: Int }\ntype Person { name: String }\n", "utf8"); + const modelFailure = await runConfig(root, "model-failure.json", { + source: "schemas/person.td", + outputs: { typescript: "generated/person.ts" }, + }); + expectFailure(modelFailure, "duplicate declaration 'Person'"); + + await writeFile(sourcePath, source(" id: Int"), "utf8"); + const outputDirectory = join(root, "generated", "directory-output"); + await mkdir(outputDirectory, { recursive: true }); + const writeFailure = await runConfig(root, "write-failure.json", { + source: "schemas/person.td", + outputs: { typescript: "generated/directory-output" }, + }); + expectFailure(writeFailure, "cannot write"); + + const watchFailure = await runConfig(root, "watch-failure.json", { + source: "missing/person.td", + watch: true, + outputs: { typescript: "generated/missing.ts" }, + }); + expectFailure(watchFailure, "cannot watch"); + + await writeFile( + configPath, + JSON.stringify({ + source: "schemas/person.td", + watch: true, + outputs: { typescript: "generated/pre-aborted.ts" }, + }), + "utf8" + ); + const preAborted = new AbortController(); + preAborted.abort(); + const preAbortedOut = makeStream(); + const preAbortedErr = makeStream(); + expect(await main(["--config", configPath], preAbortedOut.stream, preAbortedErr.stream, preAborted.signal)).toBe(0); + expect(preAbortedOut.text()).toContain("watching"); + expect(preAbortedOut.text()).toContain("watch stopped"); + expect(preAbortedErr.text()).toBe(""); + + const signalOut = makeStream(); + const signalErr = makeStream(); + const originalSignals = new Set(process.listeners("SIGINT")); + const processWatched = main(["--config", configPath], signalOut.stream, signalErr.stream); + await vi.waitFor(() => { + expect(signalOut.text()).toContain("watching"); + }); + const stop = process.listeners("SIGINT").find((listener) => !originalSignals.has(listener)); + expect(stop).toBeDefined(); + stop?.(); + expect(await processWatched).toBe(0); + expect(signalOut.text()).toContain("watch stopped"); + expect(signalErr.text()).toBe(""); + expect(process.listeners("SIGINT")).toEqual([...originalSignals]); + }); +}); diff --git a/packages/typediagram/src/editor/controls.ts b/packages/typediagram/src/editor/controls.ts index 66b4abe..cc9f8cf 100644 --- a/packages/typediagram/src/editor/controls.ts +++ b/packages/typediagram/src/editor/controls.ts @@ -76,7 +76,9 @@ const createNodeCreator = (container: HTMLElement, actions: CanvasActions) => { const createToolbar = (container: HTMLElement, actions: CanvasActions, creator: HTMLElement) => { const toolbar = document.createElement("div"); const zoom = button("100%", "Current zoom", () => undefined); - const addType = button("+ Type", "Add type", () => (creator.hidden = !creator.hidden)); + const addType = button("+ Type", "Add type", () => { + creator.hidden = creator.hidden === false; + }); toolbar.className = "td-canvas-toolbar"; toolbar.setAttribute("role", "toolbar"); toolbar.setAttribute("aria-label", "Canvas controls"); diff --git a/packages/typediagram/src/editor/source-editor.ts b/packages/typediagram/src/editor/source-editor.ts index 99f561d..525ce7a 100644 --- a/packages/typediagram/src/editor/source-editor.ts +++ b/packages/typediagram/src/editor/source-editor.ts @@ -30,8 +30,11 @@ const findDecl = (model: Model, name: string) => model.decls.find((decl) => decl const missingDecl = (name: string) => err({ message: `Unknown declaration '${name}'` }); -const sourceResult = (model: Model, decl: ResolvedDecl | undefined, name: string) => - decl === undefined ? missingDecl(name) : ok(printSource(model)); +const sourceResult = (model: Model, decl: ResolvedDecl | undefined, name: string) => { + const source = printSource(model); + const validated = modelOrFailure(source); + return decl === undefined ? missingDecl(name) : validated.ok ? ok(source) : validated; +}; const unresolvedRef = (ref: TypeRef): ResolvedTypeRef => ({ name: ref.name, @@ -85,7 +88,12 @@ const stringRef = (): ResolvedTypeRef => ({ const newDeclaration = (model: Model, kind: DeclarationKind): ResolvedDecl => { switch (kind) { case "record": - return { kind, name: uniqueName(model, "NewRecord"), generics: [], fields: [{ name: "field", type: stringRef() }] }; + return { + kind, + name: uniqueName(model, "NewRecord"), + generics: [], + fields: [{ name: "field", type: stringRef() }], + }; case "union": return { kind, name: uniqueName(model, "NewUnion"), generics: [], variants: [{ name: "Variant", fields: [] }] }; case "alias": diff --git a/packages/typediagram/src/editor/visual-editor.ts b/packages/typediagram/src/editor/visual-editor.ts index 6bcef0a..8dfca15 100644 --- a/packages/typediagram/src/editor/visual-editor.ts +++ b/packages/typediagram/src/editor/visual-editor.ts @@ -285,9 +285,12 @@ const finishConnection = ( ) => { const connection = state.connect; const eventTarget = event.target instanceof Element ? event.target.closest("[data-decl]") : null; - const target = eventTarget ?? document.elementFromPoint(event.clientX, event.clientY)?.closest("[data-decl]"); + const target = + eventTarget ?? document.elementFromPoint(event.clientX, event.clientY)?.closest("[data-decl]"); const targetName = target?.dataset.decl; runWhenDefined(connection, (current) => { + state.selected = undefined; + chrome.inspector.hidden = true; const destination = targetName === current.name ? undefined : targetName; runWhenDefined(destination, (name) => { applyMutation(connectDeclarations(options.getSource(), current.name, current.rowIndex, name), options, chrome); @@ -346,9 +349,11 @@ const exportSvg = (wrapper: HTMLElement) => { : undefined; const anchor = url === undefined ? undefined : document.createElement("a"); runWhenDefined(anchor, (current) => { - Object.assign(current, { href: url, download: "type-diagram.svg" }); + Object.assign(current, { href: url, download: "type-diagram.svg", hidden: true }); + document.body.append(current); }); anchor?.click(); + anchor?.remove(); runWhenDefined(url, (current) => { URL.revokeObjectURL(current); }); @@ -418,8 +423,7 @@ const createEditorChrome = ( state: EditorState, options: VisualEditorOptions ) => { - let chrome: CanvasChrome | undefined; - chrome = createCanvasChrome(container, { + const chrome = createCanvasChrome(container, { addNode: (kind) => { runWhenDefined(chrome, (current) => { applyMutation(addDeclaration(options.getSource(), kind), options, current); diff --git a/packages/typediagram/test/editor.test.ts b/packages/typediagram/test/editor.test.ts index 342f46a..7f4efc1 100644 --- a/packages/typediagram/test/editor.test.ts +++ b/packages/typediagram/test/editor.test.ts @@ -143,4 +143,15 @@ describe("[EDITOR-SOURCE] visual operations preserve a valid, connected type mod expect(model.ok).toBe(true); expect(model.ok ? model.value.edges.some((edge) => edge.targetDeclName === "Option") : false).toBe(true); }); + + it("rejects a bare generic field edit before it can replace the live diagram", () => { + const genericSource = `${SOURCE}\nunion Option {\n Some { value: T }\n None\n}\n`; + const edited = editRow(genericSource, "User", 0, { type: "Option" }); + expect(edited.ok).toBe(false); + expect(edited.ok ? edited.value : edited.error.message).toContain("takes 1 type argument(s), got 0"); + expect(genericSource).toContain("id: Int"); + expect(genericSource).not.toContain("id: Option"); + const originalModel = parse(genericSource); + expect(originalModel.ok).toBe(true); + }); }); diff --git a/packages/typediagram/test/visual-editor.test.ts b/packages/typediagram/test/visual-editor.test.ts index d0cae69..9e3b18f 100644 --- a/packages/typediagram/test/visual-editor.test.ts +++ b/packages/typediagram/test/visual-editor.test.ts @@ -55,7 +55,7 @@ describe("[EDITOR-CANVAS] shared web and VS Code interaction runtime", () => { vi.restoreAllMocks(); }); - it("supports the full direct-manipulation workflow and all canvas controls", async () => { + it("supports the full workflow without unsafe declaration-level connection handles", async () => { const container = document.createElement("section"); size(container); document.body.appendChild(container); @@ -80,6 +80,10 @@ describe("[EDITOR-CANVAS] shared web and VS Code interaction runtime", () => { expect(container.querySelectorAll(".td-legend-item")).toHaveLength(3); expect(container.querySelector('[data-decl="Profile"]')?.getAttribute("transform")).toBe("translate(16 8)"); expect(container.querySelectorAll(".td-port").length).toBeGreaterThan(8); + expect(container.querySelectorAll('.td-source-port[data-row-index="-1"]')).toHaveLength(0); + expect(container.querySelector('[data-decl="Account"] .td-source-port[data-row-index="-1"]')).toBeNull(); + expect(container.querySelector('[data-decl="State"] .td-source-port[data-row-index="-1"]')).toBeNull(); + expect(container.querySelector('[data-decl="Owner"] .td-source-port[data-row-index="-1"]')).toBeNull(); installVisualEditorStyles(document); expect(document.querySelectorAll("style[data-td-visual-editor]")).toHaveLength(1); @@ -162,6 +166,9 @@ describe("[EDITOR-CANVAS] shared web and VS Code interaction runtime", () => { connectionSvg?.dispatchEvent(pointer("pointerup", 400, 200)); expect(source).toContain("member: Profile"); expect(elementFromPoint).toHaveBeenCalled(); + editor.setContent(await render(source)); + expect(container.querySelector(".td-inspector")?.hidden).toBe(true); + expect(container.querySelectorAll(".td-selected")).toHaveLength(0); const wrapper = container.querySelector(".viewport-wrapper"); const beforeZoom = wrapper?.style.transform; @@ -180,11 +187,20 @@ describe("[EDITOR-CANVAS] shared web and VS Code interaction runtime", () => { const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:test"); const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); - const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { + this.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + let exportFilename = ""; + const captureExport = (event: Event) => { + exportFilename = event.target instanceof HTMLAnchorElement ? event.target.download : exportFilename; + }; + document.addEventListener("click", captureExport, true); container.querySelector('[aria-label="Export SVG"]')?.click(); + document.removeEventListener("click", captureExport, true); expect(createObjectURL).toHaveBeenCalledTimes(1); expect(revokeObjectURL).toHaveBeenCalledWith("blob:test"); expect(click).toHaveBeenCalledTimes(1); + expect(exportFilename).toBe("type-diagram.svg"); container.querySelector('[aria-label="Restore automatic layout"]')?.click(); expect(positions.at(-1)).toEqual({}); @@ -332,6 +348,28 @@ describe("[EDITOR-CANVAS] shared web and VS Code interaction runtime", () => { expect(VISUAL_EDITOR_CSS).toContain(".td-icon-button:focus-visible"); }); + it("aligns every record row close button with its input edge", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + const editor = createVisualEditor(container, { + getSource: () => SOURCE, + onSourceChange: vi.fn(), + }); + editor.setContent(await render(SOURCE)); + clickNode(container.querySelector('[data-decl="Account"]'), 100, 100); + + const row = container.querySelector(".td-inspector-row"); + const label = row?.querySelector("label"); + const input = label?.querySelector("input"); + const close = row?.querySelector(".td-inspector-remove"); + expect(getComputedStyle(row as HTMLElement).alignItems).toBe("end"); + expect(getComputedStyle(input as HTMLInputElement).boxSizing).toBe("border-box"); + expect(getComputedStyle(label as HTMLLabelElement).marginBottom).toBe("0px"); + expect(getComputedStyle(close as HTMLButtonElement).height).toBe(getComputedStyle(input as HTMLInputElement).height); + expect(getComputedStyle(close as HTMLButtonElement).width).toBe(getComputedStyle(input as HTMLInputElement).height); + }); + it("adds every ADT node kind and deletes a selected node from the diagram source", async () => { const container = document.createElement("section"); size(container); @@ -385,4 +423,62 @@ describe("[EDITOR-CANVAS] shared web and VS Code interaction runtime", () => { expect(container.querySelector('[data-decl="Account"]')).toBeNull(); expect(container.querySelectorAll('[data-edge][data-target="Account"]')).toHaveLength(0); }); + + it("assigns distinct node identities when creation outruns the host source round-trip", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + const changes: string[] = []; + const editor = createVisualEditor(container, { + getSource: () => SOURCE, + onSourceChange: (next) => changes.push(next), + }); + editor.setContent(await render(SOURCE)); + + const addType = container.querySelector('[aria-label="Add type"]'); + const addRecord = () => { + addType?.click(); + expect(container.querySelector(".td-node-creator")?.hidden).toBe(false); + container.querySelector('[aria-label="Add record type"]')?.click(); + }; + + addRecord(); + addRecord(); + + expect(changes).toHaveLength(2); + expect(changes[0]).toContain("type NewRecord {"); + expect(changes[0]).toContain("field: String"); + expect(changes[1]).toContain("type NewRecord2 {"); + expect(changes[1]).not.toBe(changes[0]); + expect(await render(changes[0] ?? "")).toContain('data-decl="NewRecord"'); + expect(await render(changes[1] ?? "")).toContain('data-decl="NewRecord2"'); + }); + + it("assigns a unique field name whenever Add row is clicked on a record", async () => { + const container = document.createElement("section"); + size(container); + document.body.appendChild(container); + let source = SOURCE; + const changes: string[] = []; + const editor = createVisualEditor(container, { + getSource: () => source, + onSourceChange: (next) => { + source = next; + changes.push(next); + }, + }); + editor.setContent(await render(source)); + + clickNode(container.querySelector('[data-decl="Account"]'), 80, 80); + const addRow = container.querySelector(".td-inspector-add"); + expect(addRow?.textContent).toContain("Add row"); + addRow?.click(); + addRow?.click(); + + expect(changes).toHaveLength(2); + expect(source.match(/^ field: String$/gm)).toHaveLength(1); + expect(source.match(/^ field2: String$/gm)).toHaveLength(1); + expect(source).toContain("active: Bool\n field: String\n field2: String"); + expect(await render(source)).toContain('data-decl="Account"'); + }); }); diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 754d99c..e09a708 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,7 +1,7 @@ // [VSCODE-EXT] Extension entry point — registers preview command, wires editor events. import * as vscode from "vscode"; import { warmupSyncRender, isSyncRenderReady } from "typediagram-core"; -import { openPreview, requestVisualEditorInteractions } from "./preview-panel.js"; +import { consumeEditorSource, openPreview, requestVisualEditorInteractions } from "./preview-panel.js"; import { typediagramMarkdownItPlugin, type MarkdownIt, setPluginLogger } from "./markdown-it-plugin.js"; import { getLogger, initLogger } from "./logger.js"; import { exportPdf, type ExportPdfDeps } from "./export-pdf.js"; @@ -146,7 +146,18 @@ export const activate = async (context: vscode.ExtensionContext) => { return; } const panel = panels.get(doc.uri.toString()); - panel?.webview.postMessage({ kind: "update", source: doc.getText() }); + const source = doc.getText(); + switch (panel) { + case undefined: + break; + default: + switch (consumeEditorSource(panel, source)) { + case true: + break; + case false: + void panel.webview.postMessage({ kind: "update", source }); + } + } }); const onClose = vscode.workspace.onDidCloseTextDocument((doc) => { diff --git a/packages/vscode/src/preview-panel.ts b/packages/vscode/src/preview-panel.ts index f4aca7b..6bdf872 100644 --- a/packages/vscode/src/preview-panel.ts +++ b/packages/vscode/src/preview-panel.ts @@ -10,6 +10,7 @@ import { type EditorMessage = { kind: "edit"; source: string }; type OpenSourceMessage = { kind: "open-source" }; const pendingEdits = new WeakMap>(); +const pendingSources = new WeakMap(); const panelReady = new WeakMap>(); let interactionRequestId = 0; @@ -41,9 +42,24 @@ const applyEditorSource = async (doc: vscode.TextDocument, source: string) => { await vscode.workspace.applyEdit(edit); }; +export const consumeEditorSource = (panel: vscode.WebviewPanel, source: string) => { + const sources = pendingSources.get(panel) ?? []; + const index = sources.indexOf(source); + switch (index >= 0) { + case true: + sources.splice(index, 1); + break; + } + return index >= 0; +}; + const queueEditorSource = (panel: vscode.WebviewPanel, doc: vscode.TextDocument, source: string) => { const previous = pendingEdits.get(panel) ?? Promise.resolve(); - const next = previous.then(() => applyEditorSource(doc, source)); + const next = previous.then(async () => { + pendingSources.set(panel, [...(pendingSources.get(panel) ?? []), source]); + await applyEditorSource(doc, source); + consumeEditorSource(panel, source); + }); pendingEdits.set(panel, next); }; diff --git a/packages/vscode/src/webview/interaction-protocol.ts b/packages/vscode/src/webview/interaction-protocol.ts index 77673d2..f0986d2 100644 --- a/packages/vscode/src/webview/interaction-protocol.ts +++ b/packages/vscode/src/webview/interaction-protocol.ts @@ -3,8 +3,8 @@ export const VISUAL_EDITOR_INTERACTION_COMMAND = "typediagram.testVisualEditorIn export const VISUAL_INTERACTION_PASSES = [ "canvas-chrome", - "record-edit", "invalid-edit", + "record-edit", "union-edit", "alias-edit", "add-remove", @@ -15,6 +15,7 @@ export const VISUAL_INTERACTION_PASSES = [ "trackpad-zoom", "fit-reset-pan", "draw-relationship", + "generic-relationship-recovery", "auto-layout", "export-svg", "close-and-escape", diff --git a/packages/vscode/src/webview/interaction-test.ts b/packages/vscode/src/webview/interaction-test.ts index ef10344..31684ba 100644 --- a/packages/vscode/src/webview/interaction-test.ts +++ b/packages/vscode/src/webview/interaction-test.ts @@ -10,9 +10,7 @@ export type VisualInteractionContext = { type Audit = { context: VisualInteractionContext; passed: string[]; evidence: VisualInteractionEvidence }; type Point = { x: number; y: number }; - const mark = (audit: Audit, name: string, passed: boolean) => (passed ? audit.passed.push(name) : undefined); - const pointer = (type: string, point: Point, buttons: number) => new PointerEvent(type, { bubbles: true, @@ -24,22 +22,18 @@ const pointer = (type: string, point: Point, buttons: number) => clientX: point.x, clientY: point.y, }); - const button = (audit: Audit, label: string) => { const value = audit.context.preview.querySelector(`[aria-label="${label}"]`); return value instanceof HTMLButtonElement ? value : undefined; }; - const input = (audit: Audit, index: number) => { const value = audit.context.preview.querySelectorAll(".td-inspector input").item(index); return value instanceof HTMLInputElement ? value : undefined; }; - const declaration = (audit: Audit, name: string) => { const value = audit.context.preview.querySelector(`[data-decl="${name}"]`); return value instanceof SVGGElement ? value : undefined; }; - const selectDeclaration = (audit: Audit, name: string) => { const node = declaration(audit, name); const svg = node?.ownerSVGElement ?? undefined; @@ -48,7 +42,6 @@ const selectDeclaration = (audit: Audit, name: string) => { svg?.dispatchEvent(pointer("pointerup", point, 0)); return node; }; - const changeInput = async (audit: Audit, index: number, value: string) => { const current = input(audit, index); current?.focus(); @@ -57,7 +50,6 @@ const changeInput = async (audit: Audit, index: number, value: string) => { await audit.context.settle(); return current !== undefined; }; - const canvasChrome = (audit: Audit) => { const { preview } = audit.context; const toolbarButtons = preview.querySelectorAll(".td-canvas-toolbar .td-canvas-button").length; @@ -65,60 +57,151 @@ const canvasChrome = (audit: Audit) => { const grid = preview.querySelector("svg #td-grid") !== null; const shadow = preview.querySelector("svg #td-ambient-shadow") !== null; const nodeCount = preview.querySelectorAll("svg [data-decl]").length; - Object.assign(audit.evidence, { toolbarButtons, legendItems, grid, shadow, nodeCount }); + const ports = preview.querySelectorAll("svg .td-port").length; + const nodeKinds = ["record", "union", "alias"].every( + (kind) => preview.querySelectorAll(`svg [data-kind="${kind}"]`).length > 0 + ); + const toolbarLabel = preview.querySelector(".td-canvas-toolbar")?.getAttribute("aria-label") === "Canvas controls"; + const legendText = preview.querySelector(".td-canvas-legend")?.textContent?.replace(/\s/g, "") === "TypeUnionAlias"; + const chromeInitiallyClosed = + preview.querySelector(".td-node-creator")?.hidden === true && + preview.querySelector(".td-inspector")?.hidden === true; + Object.assign(audit.evidence, { + toolbarButtons, + legendItems, + grid, + shadow, + nodeCount, + ports, + nodeKinds, + toolbarLabel, + legendText, + chromeInitiallyClosed, + }); const toolbar = toolbarButtons === 8; const legend = legendItems === 3; - const rendered = grid && shadow && nodeCount > 0; - mark(audit, "canvas-chrome", toolbar && legend && rendered); + const rendered = grid && shadow && nodeCount > 10 && ports > 30; + mark( + audit, + "canvas-chrome", + toolbar && legend && rendered && nodeKinds && toolbarLabel && legendText && chromeInitiallyClosed + ); }; - const recordEdits = async (audit: Audit) => { selectDeclaration(audit, "ChatRequest"); + const recordKind = audit.context.preview.querySelector(".td-inspector-kind")?.textContent === "record"; + const recordRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const recordSelected = audit.context.preview.querySelectorAll(".td-selected").length === 1; await changeInput(audit, 0, "ConversationRequest"); + const renamedNode = declaration(audit, "ConversationRequest") !== undefined; + const oldNodeGone = declaration(audit, "ChatRequest") === undefined; selectDeclaration(audit, "ConversationRequest"); await changeInput(audit, 1, "prompt"); selectDeclaration(audit, "ConversationRequest"); + const beforeInvalid = audit.context.getSource(); await changeInput(audit, 2, "List<"); - const invalid = !audit.context.getSource().includes("prompt: List<"); + const invalid = audit.context.getSource() === beforeInvalid && !audit.context.getSource().includes("prompt: List<"); const invalidToast = audit.context.preview.querySelector(".td-editor-toast:not([hidden])") !== null; - Object.assign(audit.evidence, { invalidRejected: invalid, invalidToast }); + Object.assign(audit.evidence, { invalidRejected: invalid, invalidToast, recordKind, recordRows, recordSelected }); mark(audit, "invalid-edit", invalid && invalidToast); selectDeclaration(audit, "ConversationRequest"); await changeInput(audit, 2, "Option"); const recordRenamed = audit.context.getSource().includes("type ConversationRequest"); const recordFieldEdited = audit.context.getSource().includes("prompt: Option"); - Object.assign(audit.evidence, { recordRenamed, recordFieldEdited }); - mark(audit, "record-edit", recordRenamed && recordFieldEdited); + const recordRendered = + declaration(audit, "ConversationRequest")?.textContent?.includes("prompt: Option") === true; + const recordEdge = + audit.context.preview.querySelector('[data-edge][data-source="ConversationRequest"][data-target="Option"]') !== + null; + Object.assign(audit.evidence, { + recordRenamed, + recordFieldEdited, + renamedNode, + oldNodeGone, + recordRendered, + recordEdge, + }); + mark( + audit, + "record-edit", + recordRenamed && + recordFieldEdited && + renamedNode && + oldNodeGone && + recordRendered && + recordEdge && + recordKind && + recordRows === 3 && + recordSelected + ); }; - const unionEdit = async (audit: Audit) => { selectDeclaration(audit, "ToolResultContent"); + const unionKind = audit.context.preview.querySelector(".td-inspector-kind")?.textContent === "union"; + const unionRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; await changeInput(audit, 3, "ScalarValue"); selectDeclaration(audit, "ToolResultContent"); await changeInput(audit, 4, "TextPart"); const unionVariantRenamed = audit.context.getSource().includes("ScalarValue {"); const unionPayloadEdited = audit.context.getSource().includes("ScalarValue { value: TextPart }"); - Object.assign(audit.evidence, { unionVariantRenamed, unionPayloadEdited }); - mark(audit, "union-edit", unionVariantRenamed && unionPayloadEdited); + const unionRendered = declaration(audit, "ToolResultContent")?.textContent?.includes("ScalarValue") === true; + const unionEdge = + audit.context.preview.querySelector('[data-edge][data-source="ToolResultContent"][data-target="TextPart"]') !== + null; + Object.assign(audit.evidence, { + unionVariantRenamed, + unionPayloadEdited, + unionKind, + unionRows, + unionRendered, + unionEdge, + }); + mark( + audit, + "union-edit", + unionVariantRenamed && unionPayloadEdited && unionKind && unionRows === 4 && unionRendered && unionEdge + ); }; - const aliasEdit = async (audit: Audit) => { selectDeclaration(audit, "Email"); + const aliasKind = audit.context.preview.querySelector(".td-inspector-kind")?.textContent === "alias"; + const aliasRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const aliasHasNoRowControls = + audit.context.preview.querySelector(".td-inspector-add") === null && + audit.context.preview.querySelector(".td-inspector-remove") === null; await changeInput(audit, 2, "Option"); const aliasTargetEdited = audit.context.getSource().includes("alias Email = Option"); - audit.evidence.aliasTargetEdited = aliasTargetEdited; - mark(audit, "alias-edit", aliasTargetEdited); + const aliasRendered = declaration(audit, "Email")?.textContent?.includes("Option") === true; + const aliasEdge = + audit.context.preview.querySelector('[data-edge][data-source="Email"][data-target="Option"]') !== null; + Object.assign(audit.evidence, { + aliasTargetEdited, + aliasKind, + aliasRows, + aliasHasNoRowControls, + aliasRendered, + aliasEdge, + }); + mark( + audit, + "alias-edit", + aliasTargetEdited && aliasKind && aliasRows === 1 && aliasHasNoRowControls && aliasRendered && aliasEdge + ); }; - const addRemove = async (audit: Audit) => { selectDeclaration(audit, "ConversationRequest"); const add = audit.context.preview.querySelector(".td-inspector-add"); + const sourceBefore = audit.context.getSource(); const beforeRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const beforePorts = declaration(audit, "ConversationRequest")?.querySelectorAll(".td-source-port").length ?? 0; (add instanceof HTMLButtonElement ? add : undefined)?.click(); await audit.context.settle(); const added = audit.context.getSource().includes("field: String"); selectDeclaration(audit, "ConversationRequest"); const afterAddRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; + const afterAddPorts = declaration(audit, "ConversationRequest")?.querySelectorAll(".td-source-port").length ?? 0; + const lastInputs = audit.context.preview.querySelectorAll(".td-inspector-row:last-of-type input"); + const defaultRow = lastInputs.item(0).value === "field" && lastInputs.item(1).value === "String"; const removes = audit.context.preview.querySelectorAll(".td-inspector-remove"); const remove = removes.item(removes.length - 1); (remove instanceof HTMLButtonElement ? remove : undefined)?.click(); @@ -126,10 +209,33 @@ const addRemove = async (audit: Audit) => { selectDeclaration(audit, "ConversationRequest"); const afterRemoveRows = audit.context.preview.querySelectorAll(".td-inspector-row").length; const removed = !audit.context.getSource().includes("field: String"); - Object.assign(audit.evidence, { beforeRows, afterAddRows, afterRemoveRows, rowAdded: added, rowRemoved: removed }); - mark(audit, "add-remove", added && removed && afterAddRows === beforeRows + 1 && afterRemoveRows === beforeRows); + const afterRemovePorts = declaration(audit, "ConversationRequest")?.querySelectorAll(".td-source-port").length ?? 0; + const sourceRestored = audit.context.getSource() === sourceBefore; + Object.assign(audit.evidence, { + beforeRows, + afterAddRows, + afterRemoveRows, + rowAdded: added, + rowRemoved: removed, + beforePorts, + afterAddPorts, + afterRemovePorts, + defaultRow, + sourceRestored, + }); + mark( + audit, + "add-remove", + added && + removed && + defaultRow && + sourceRestored && + afterAddRows === beforeRows + 1 && + afterRemoveRows === beforeRows && + afterAddPorts === beforePorts + 1 && + afterRemovePorts === beforePorts + ); }; - const iconButtons = (audit: Audit) => { selectDeclaration(audit, "ConversationRequest"); const close = button(audit, "Close properties"); @@ -140,13 +246,11 @@ const iconButtons = (audit: Audit) => { Object.assign(audit.evidence, { closeIcon, removeIconCount, removeButtonsLabelled }); mark(audit, "icon-buttons", closeIcon && removeIconCount === removes.length && removeButtonsLabelled); }; - const addNode = async (audit: Audit, label: string) => { button(audit, "Add type")?.click(); button(audit, label)?.click(); await audit.context.settle(); }; - const addDeleteNodes = async (audit: Audit) => { button(audit, "Add type")?.click(); const creatorButtons = audit.context.preview.querySelectorAll(".td-node-creator button").length; @@ -248,12 +352,11 @@ const fitResetPan = (audit: Audit) => { ); }; -const drawRelationship = async (audit: Audit) => { - selectDeclaration(audit, "ConversationRequest"); +const dragConnection = async (audit: Audit, sourceName: string, rowIndex: number, targetName: string) => { const port = audit.context.preview.querySelector( - '[data-decl="ConversationRequest"] .td-source-port[data-row-index="0"]' + `[data-decl="${sourceName}"] .td-source-port[data-row-index="${String(rowIndex)}"]` ); - const target = audit.context.preview.querySelector('[data-decl="AgentConfig"] .td-target-port'); + const target = audit.context.preview.querySelector(`[data-decl="${targetName}"] .td-target-port`); const svg = port instanceof SVGCircleElement ? port.ownerSVGElement : undefined; const sourceBox = port?.getBoundingClientRect(); const targetBox = target?.getBoundingClientRect(); @@ -265,9 +368,61 @@ const drawRelationship = async (audit: Audit) => { audit.context.preview.querySelector(".td-connection-preview")?.getAttribute("d")?.includes(" C ") === true; target?.dispatchEvent(pointer("pointerup", end, 0)); await audit.context.settle(); - const relationshipSource = audit.context.getSource().includes("prompt: AgentConfig"); - Object.assign(audit.evidence, { connectionPreview, relationshipSource }); - mark(audit, "draw-relationship", connectionPreview && relationshipSource); + return connectionPreview; +}; + +const drawRelationship = async (audit: Audit) => { + selectDeclaration(audit, "ConversationRequest"); + const connectionPreview = await dragConnection(audit, "ConversationRequest", 0, "TextPart"); + const relationshipSource = audit.context.getSource().includes("prompt: TextPart"); + const relationshipRendered = + declaration(audit, "ConversationRequest")?.textContent?.includes("prompt: TextPart") === true; + const relationshipEdge = + audit.context.preview.querySelector('[data-edge][data-source="ConversationRequest"][data-target="TextPart"]') !== + null; + const relationshipClosed = audit.context.preview.querySelector(".td-connection-preview") === null; + Object.assign(audit.evidence, { + connectionPreview, + relationshipSource, + relationshipRendered, + relationshipEdge, + relationshipClosed, + }); + mark( + audit, + "draw-relationship", + connectionPreview && relationshipSource && relationshipRendered && relationshipEdge && relationshipClosed + ); +}; + +const genericRelationshipRecovery = async (audit: Audit) => { + const connectionPreview = await dragConnection(audit, "ToolResult", -1, "Option"); + const genericSource = audit.context.getSource().includes("option: Option"); + const genericTargetRendered = declaration(audit, "Option") !== undefined; + const genericEdge = + audit.context.preview.querySelector('[data-edge][data-source="ToolResult"][data-target="Option"]') !== null; + const genericRendered = declaration(audit, "ToolResult")?.textContent?.includes("option: Option") === true; + const fatalErrorHidden = audit.context.preview.ownerDocument.getElementById("error-panel")?.hidden === true; + const recoveryActions = audit.context.preview.ownerDocument.querySelectorAll("#error-panel .error-action").length; + Object.assign(audit.evidence, { + genericSource, + genericTargetRendered, + genericEdge, + genericRendered, + fatalErrorHidden, + recoveryActions, + }); + mark( + audit, + "generic-relationship-recovery", + connectionPreview && + genericSource && + genericTargetRendered && + genericEdge && + genericRendered && + fatalErrorHidden && + recoveryActions === 2 + ); }; const autoLayout = (audit: Audit) => { @@ -320,6 +475,7 @@ const canvasInteractions = async (audit: Audit) => { trackpadZoom(audit); fitResetPan(audit); await drawRelationship(audit); + await genericRelationshipRecovery(audit); autoLayout(audit); exportSvg(audit); closeAndEscape(audit); @@ -335,7 +491,8 @@ export const runVisualEditorInteractions = async ( const source = context.getSource(); return { passed: audit.passed, - sourceUpdated: source.includes("ConversationRequest") && source.includes("prompt: AgentConfig"), + sourceUpdated: + source.includes("ConversationRequest") && source.includes("prompt: TextPart") && source.includes("Option"), evidence: audit.evidence, }; }; diff --git a/packages/vscode/src/webview/main.ts b/packages/vscode/src/webview/main.ts index 130f572..fe74407 100644 --- a/packages/vscode/src/webview/main.ts +++ b/packages/vscode/src/webview/main.ts @@ -57,7 +57,10 @@ const recoveryElements = () => { const restore = document.getElementById("restore-valid-source"); const openSource = document.getElementById("open-source"); const complete = - errorPanel !== null && error !== null && restore instanceof HTMLButtonElement && openSource instanceof HTMLButtonElement; + errorPanel !== null && + error !== null && + restore instanceof HTMLButtonElement && + openSource instanceof HTMLButtonElement; return complete ? { errorPanel, error, restore, openSource } : undefined; }; diff --git a/packages/vscode/test/electron/macos-launcher.sh b/packages/vscode/test/electron/macos-launcher.sh new file mode 100755 index 0000000..ed0e062 --- /dev/null +++ b/packages/vscode/test/electron/macos-launcher.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# [VSCODE-E2E-DARWIN-LAUNCHER] Start the GUI binary without inherited extension-host mode. +unset ELECTRON_RUN_AS_NODE +unset VSCODE_CODE_CACHE_PATH VSCODE_CRASH_REPORTER_PROCESS_TYPE VSCODE_CWD +unset VSCODE_ESM_ENTRYPOINT VSCODE_HANDLES_UNCAUGHT_ERRORS VSCODE_IPC_HOOK VSCODE_NLS_CONFIG VSCODE_PID +exec "${TYPEDIAGRAM_VSCODE_APP_PATH:?}/Contents/MacOS/Code" "$@" diff --git a/packages/vscode/test/electron/runner.mjs b/packages/vscode/test/electron/runner.mjs index 25754ea..fbccd3b 100644 --- a/packages/vscode/test/electron/runner.mjs +++ b/packages/vscode/test/electron/runner.mjs @@ -14,7 +14,11 @@ const REPO_ROOT = resolve(PKG_ROOT, "../.."); const RUN_ID = `${String(process.pid)}-${String(Date.now())}`; const RESULT_PATH = resolve(PKG_ROOT, `.vscode-test/electron-result-${RUN_ID}.ok`); const HARNESS_PATH = resolve(__dirname, "harness"); -const PROFILE_PATH = resolve(PKG_ROOT, `.vscode-test/vsix-profile-${RUN_ID}`); +const DARWIN_LAUNCHER = resolve(__dirname, "macos-launcher.sh"); +const PROFILE_PATH = + process.platform === "darwin" + ? resolve("/tmp", `td-vsix-${RUN_ID}`) + : resolve(PKG_ROOT, `.vscode-test/vsix-profile-${RUN_ID}`); const USER_DATA_PATH = resolve(PROFILE_PATH, "user-data"); const EXTENSIONS_PATH = resolve(PROFILE_PATH, "extensions"); @@ -88,18 +92,14 @@ async function main() { const configuredExecutable = process.env["TYPEDIAGRAM_VSCODE_EXECUTABLE_PATH"]; const configuredCli = configuredExecutable?.endsWith("/bin/code") === true; const vscodeExecutablePath = - appPath === undefined ? (configuredExecutable ?? (await downloadAndUnzipVSCode())) : "/usr/bin/open"; + appPath === undefined ? (configuredExecutable ?? (await downloadAndUnzipVSCode())) : DARWIN_LAUNCHER; rmSync(PROFILE_PATH, { recursive: true, force: true }); mkdirSync(USER_DATA_PATH, { recursive: true }); mkdirSync(EXTENSIONS_PATH, { recursive: true }); installVsix(configuredExecutable ?? vscodeExecutablePath, configuredCli, appPath, vsixPath); const profileArgs = [`--user-data-dir=${USER_DATA_PATH}`, `--extensions-dir=${EXTENSIONS_PATH}`]; const launchArgs = - appPath === undefined - ? configuredCli - ? ["--wait", "--new-window", ...profileArgs] - : profileArgs - : ["-W", "-n", "-a", appPath, "--args", ...profileArgs]; + appPath === undefined ? (configuredCli ? ["--wait", "--new-window", ...profileArgs] : profileArgs) : profileArgs; rmSync(RESULT_PATH, { force: true }); const exitCode = await runTests({ diff --git a/packages/vscode/test/electron/suite/extension.spec.cjs b/packages/vscode/test/electron/suite/extension.spec.cjs index a261bf4..d56080f 100644 --- a/packages/vscode/test/electron/suite/extension.spec.cjs +++ b/packages/vscode/test/electron/suite/extension.spec.cjs @@ -2,55 +2,67 @@ // expected contributions, and the markdown injection grammar applies to typediagram // fences inside a real .md file opened in the editor. const assert = require("node:assert"); +const { readFileSync } = require("node:fs"); const path = require("node:path"); const vscode = require("vscode"); suite("typediagram extension inside a real VS Code", () => { const extensionId = "nimblesite.typediagram"; const findExt = () => vscode.extensions.getExtension(extensionId); + const samplePath = path.resolve(__dirname, "../../../examples/sample.td"); + const sampleSource = readFileSync(samplePath, "utf8"); + let visualDoc; test("extension is installed and activatable", async () => { const ext = findExt(); assert.ok(ext, `${extensionId} was not installed from the freshly packaged VSIX`); assert.strictEqual(ext.packageJSON.name, "typediagram"); - assert.ok(ext.extensionPath.includes("vsix-profile"), `unexpected extension path: ${ext.extensionPath}`); + assert.ok(ext.extensionPath.includes("td-vsix-"), `unexpected profile path: ${ext.extensionPath}`); + assert.ok( + ext.extensionPath.endsWith("extensions/nimblesite.typediagram-0.0.0-dev"), + `unexpected extension path: ${ext.extensionPath}` + ); await ext.activate(); assert.strictEqual(ext.isActive, true); }); - test("opens the sample as a visual editor and reports the live panel through commands", async () => { - const docPath = path.resolve(__dirname, "../../../examples/sample.td"); - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(docPath)); - await vscode.window.showTextDocument(doc); + test("opens an isolated sample copy as a visual editor and reports the live panel through commands", async () => { + visualDoc = await vscode.workspace.openTextDocument({ language: "typediagram", content: sampleSource }); + await vscode.window.showTextDocument(visualDoc); await vscode.commands.executeCommand("typediagram.preview"); const status = await vscode.commands.executeCommand("typediagram.editorStatus"); assert.deepStrictEqual(status, { visualEditor: true, openPanels: 1 }); }); test("runs every main canvas interaction inside the packaged VSIX webview", async () => { - const docPath = path.resolve(__dirname, "../../../examples/sample.td"); - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(docPath)); + const doc = + visualDoc ?? (await vscode.workspace.openTextDocument({ language: "typediagram", content: sampleSource })); await vscode.window.showTextDocument(doc); await vscode.commands.executeCommand("typediagram.preview"); const result = await vscode.commands.executeCommand("typediagram.testVisualEditorInteractions"); - assert.deepStrictEqual(result.passed, [ - "canvas-chrome", - "record-edit", - "invalid-edit", - "union-edit", - "alias-edit", - "add-remove", - "icon-buttons", - "node-add-delete", - "drag-snap-persist", - "zoom-in-out", - "trackpad-zoom", - "fit-reset-pan", - "draw-relationship", - "auto-layout", - "export-svg", - "close-and-escape", - ]); + assert.deepStrictEqual( + result.passed, + [ + "canvas-chrome", + "invalid-edit", + "record-edit", + "union-edit", + "alias-edit", + "add-remove", + "icon-buttons", + "node-add-delete", + "drag-snap-persist", + "zoom-in-out", + "trackpad-zoom", + "fit-reset-pan", + "draw-relationship", + "generic-relationship-recovery", + "auto-layout", + "export-svg", + "close-and-escape", + ], + JSON.stringify(result, null, 2) + ); assert.strictEqual(result.sourceUpdated, true); const e = result.evidence; @@ -59,17 +71,42 @@ suite("typediagram extension inside a real VS Code", () => { assert.strictEqual(e.grid, true); assert.strictEqual(e.shadow, true); assert.ok(e.nodeCount >= 10); + assert.ok(e.ports > 30); + assert.strictEqual(e.nodeKinds, true); + assert.strictEqual(e.toolbarLabel, true); + assert.strictEqual(e.legendText, true); + assert.strictEqual(e.chromeInitiallyClosed, true); assert.strictEqual(e.recordRenamed, true); assert.strictEqual(e.recordFieldEdited, true); + assert.strictEqual(e.recordKind, true); + assert.strictEqual(e.recordRows, 3); + assert.strictEqual(e.recordSelected, true); + assert.strictEqual(e.renamedNode, true); + assert.strictEqual(e.oldNodeGone, true); + assert.strictEqual(e.recordRendered, true); + assert.strictEqual(e.recordEdge, true); assert.strictEqual(e.invalidRejected, true); assert.strictEqual(e.invalidToast, true); assert.strictEqual(e.unionVariantRenamed, true); assert.strictEqual(e.unionPayloadEdited, true); + assert.strictEqual(e.unionKind, true); + assert.strictEqual(e.unionRows, 4); + assert.strictEqual(e.unionRendered, true); + assert.strictEqual(e.unionEdge, true); assert.strictEqual(e.aliasTargetEdited, true); + assert.strictEqual(e.aliasKind, true); + assert.strictEqual(e.aliasRows, 1); + assert.strictEqual(e.aliasHasNoRowControls, true); + assert.strictEqual(e.aliasRendered, true); + assert.strictEqual(e.aliasEdge, true); assert.strictEqual(e.rowAdded, true); assert.strictEqual(e.rowRemoved, true); assert.strictEqual(e.afterAddRows, e.beforeRows + 1); assert.strictEqual(e.afterRemoveRows, e.beforeRows); + assert.strictEqual(e.afterAddPorts, e.beforePorts + 1); + assert.strictEqual(e.afterRemovePorts, e.beforePorts); + assert.strictEqual(e.defaultRow, true); + assert.strictEqual(e.sourceRestored, true); assert.strictEqual(e.closeIcon, true); assert.ok(e.removeIconCount >= 1); assert.strictEqual(e.removeButtonsLabelled, true); @@ -96,6 +133,15 @@ suite("typediagram extension inside a real VS Code", () => { assert.match(e.panTransform, /translate\(50px, 35px\) scale\(1\)/); assert.strictEqual(e.connectionPreview, true); assert.strictEqual(e.relationshipSource, true); + assert.strictEqual(e.relationshipRendered, true); + assert.strictEqual(e.relationshipEdge, true); + assert.strictEqual(e.relationshipClosed, true); + assert.strictEqual(e.genericSource, true); + assert.strictEqual(e.genericTargetRendered, true); + assert.strictEqual(e.genericEdge, true); + assert.strictEqual(e.genericRendered, true); + assert.strictEqual(e.fatalErrorHidden, true); + assert.strictEqual(e.recoveryActions, 2); assert.strictEqual(e.autoX, 0); assert.strictEqual(e.autoY, 0); assert.strictEqual(e.autoStateEmpty, true); @@ -104,13 +150,23 @@ suite("typediagram extension inside a real VS Code", () => { assert.strictEqual(e.inspectorEscaped, true); assert.match(doc.getText(), /type ConversationRequest/); - assert.match(doc.getText(), /prompt: AgentConfig/); + assert.match(doc.getText(), /prompt: TextPart/); + assert.match(doc.getText(), /option: Option/); assert.match(doc.getText(), /ScalarValue \{ value: TextPart \}/); assert.match(doc.getText(), /alias Email = Option/); assert.match(doc.getText(), /union NewUnion/); assert.match(doc.getText(), /alias NewAlias = String/); assert.doesNotMatch(doc.getText(), /type NewRecord/); assert.doesNotMatch(doc.getText(), /field: String/); + assert.doesNotMatch(doc.getText(), /prompt: List/g) ?? []).length, 1); + assert.deepStrictEqual(await vscode.commands.executeCommand("typediagram.editorStatus"), { + visualEditor: true, + openPanels: 1, + }); + assert.strictEqual(readFileSync(samplePath, "utf8"), sampleSource); }); test("package.json declares markdown injection grammar and markdown-it plugin", () => { diff --git a/packages/vscode/test/extension.test.ts b/packages/vscode/test/extension.test.ts index f4a5275..cec1c99 100644 --- a/packages/vscode/test/extension.test.ts +++ b/packages/vscode/test/extension.test.ts @@ -156,6 +156,112 @@ describe("[VSCODE-EXT] activate", () => { ); }); + it("replaces the entire prior visual source when a field relationship follows node creation", async () => { + const initial = `type AuditEvent { + id: Uuid + createdAt: DateTime + amount: Decimal + parent: Option + history: List +} +`; + const added = `typeDiagram + +${initial.trimEnd()} + +type NewRecord { + field: String +} +`; + const connected = added.replace("field: String", "field: AuditEvent"); + const endOf = (source: string) => { + const lines = source.split("\n"); + return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 }; + }; + const doc = { ...makeDoc(initial), positionAt: () => endOf(initial) }; + mock.window.activeTextEditor = { document: doc }; + await activateExtension(); + mock.commands._handler?.(); + + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.({ kind: "edit", source: added }); + handler?.({ kind: "edit", source: connected }); + await vi.waitFor(() => { + expect(mock.workspace.applyEdit).toHaveBeenCalledTimes(2); + }); + + const secondEdit = mock.workspace.applyEdit.mock.calls[1]?.[0] as mock.WorkspaceEdit; + const replacement = secondEdit.replace.mock.calls[0]; + const range = replacement?.[1] as mock.Range; + expect(replacement?.[2]).toBe(connected); + expect(range.start).toEqual(new mock.Position(0, 0)); + expect(range.end).toEqual(endOf(added)); + expect(range.end).not.toEqual(endOf(initial)); + }); + + it("undoes an invalid visual edit without retaining or duplicating its diagnostics", async () => { + const valid = "typeDiagram\ntype Person {\n age: Int\n active: Bool\n}\n"; + const invalid = `${valid}age\nactive\n`; + const endOf = (source: string) => { + const lines = source.split("\n"); + return new mock.Position(lines.length - 1, lines.at(-1)?.length ?? 0); + }; + const doc = { ...makeDoc(valid), positionAt: () => endOf(valid) }; + mock.window.activeTextEditor = { document: doc }; + await activateExtension(); + mock.commands._handler?.(); + + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.({ kind: "edit", source: invalid }); + handler?.({ kind: "edit", source: valid }); + await vi.waitFor(() => { + expect(mock.workspace.applyEdit).toHaveBeenCalledTimes(2); + }); + + const invalidEdit = mock.workspace.applyEdit.mock.calls[0]?.[0] as mock.WorkspaceEdit; + const recoveryEdit = mock.workspace.applyEdit.mock.calls[1]?.[0] as mock.WorkspaceEdit; + const invalidReplacement = invalidEdit.replace.mock.calls[0]; + const recoveryReplacement = recoveryEdit.replace.mock.calls[0]; + const recoveryRange = recoveryReplacement?.[1] as mock.Range; + expect(invalidReplacement?.[2]).toBe(invalid); + expect(recoveryReplacement?.[2]).toBe(valid); + expect(recoveryRange.start).toEqual(new mock.Position(0, 0)); + expect(recoveryRange.end).toEqual(endOf(invalid)); + expect(recoveryRange.end).not.toEqual(endOf(valid)); + }); + + it("consumes visual-edit echoes without dropping genuine external document changes", async () => { + await activateExtension(); + const doc = makeDoc("typeDiagram\ntype X { a: Int }"); + const first = "typeDiagram\ntype X { a: String }\n"; + const second = "typeDiagram\ntype X { a: Text }\n"; + mock.window.activeTextEditor = { document: doc }; + mock.commands._handler?.(); + mock.mockPanel.webview.postMessage.mockClear(); + mock.workspace.applyEdit + .mockImplementationOnce(() => { + mock.workspace._changeCb?.({ document: makeDoc(first) }); + return Promise.resolve(true); + }) + .mockImplementationOnce(() => { + mock.workspace._changeCb?.({ document: makeDoc(second) }); + return Promise.resolve(true); + }); + const handler = mock.mockPanel.webview._messageHandlers.at(-1); + handler?.({ kind: "edit", source: first }); + handler?.({ kind: "edit", source: second }); + await vi.waitFor(() => { + expect(mock.workspace.applyEdit).toHaveBeenCalledTimes(2); + }); + expect(mock.mockPanel.webview.postMessage).not.toHaveBeenCalled(); + mock.workspace._changeCb?.({ document: makeDoc("typeDiagram\ntype X { a: External }\n") }); + expect(mock.mockPanel.webview.postMessage).toHaveBeenCalledOnce(); + expect(mock.mockPanel.webview.postMessage).toHaveBeenCalledWith({ + kind: "update", + source: "typeDiagram\ntype X { a: External }\n", + }); + }); + it("opens the source document as an escape hatch from an invalid visual edit", async () => { await activateExtension(); const doc = makeDoc("typeDiagram\ntype X { a: Int }"); diff --git a/packages/vscode/test/webview-html.test.ts b/packages/vscode/test/webview-html.test.ts index f8e509c..338a74e 100644 --- a/packages/vscode/test/webview-html.test.ts +++ b/packages/vscode/test/webview-html.test.ts @@ -1,7 +1,7 @@ // [VSCODE-WEBVIEW-HTML-TEST] Tests for webview HTML generation. import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { parser } from "typediagram-core"; +import { parser, renderToString } from "typediagram-core"; import { webviewHtml } from "../src/webview-html.js"; describe("[VSCODE-WEBVIEW-HTML] webviewHtml", () => { @@ -54,11 +54,14 @@ describe("[VSCODE-WEBVIEW-HTML] webviewHtml", () => { expect(html).toContain("Open source"); }); - it("ships a parseable sample for the visual editor recovery workflow", () => { + it("ships a renderable sample for the visual editor recovery workflow", async () => { const source = readFileSync(new URL("../examples/sample.td", import.meta.url), "utf8"); - const result = parser.parse(source); - expect(result.ok).toBe(true); - expect(result.ok ? result.value.declarations.length : 0).toBeGreaterThan(8); + const parsed = parser.parse(source); + const rendered = await renderToString(source); + expect(parsed.ok).toBe(true); + expect(parsed.ok ? parsed.value.decls.length : 0).toBeGreaterThan(8); + expect(rendered.ok).toBe(true); + expect(rendered.ok ? rendered.value : "").toContain(""); expect(source).toContain("alias Email = String"); }); diff --git a/packages/web/e2e/visual-editor.spec.ts b/packages/web/e2e/visual-editor.spec.ts index 304526d..a7abbaf 100644 --- a/packages/web/e2e/visual-editor.spec.ts +++ b/packages/web/e2e/visual-editor.spec.ts @@ -24,7 +24,8 @@ const dispatchPointerDrag = async (source: Locator, from: Point, to: Point, rele const hiddenOnDown = inspector?.hidden === true; svg?.dispatchEvent(pointer("pointermove", points.to, 1)); const hiddenOnMove = inspector?.hidden === true; - const release = points.releaseSelector === undefined ? svg : element.ownerDocument.querySelector(points.releaseSelector); + const release = + points.releaseSelector === undefined ? svg : element.ownerDocument.querySelector(points.releaseSelector); release?.dispatchEvent(pointer("pointerup", points.to, 0)); return { hiddenOnDown, hiddenOnMove, hiddenOnUp: inspector?.hidden === true }; }, @@ -51,6 +52,18 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { await expect(page.locator(".td-canvas-legend .td-legend-item")).toHaveCount(3); await expect(page.locator("#preview svg #td-grid")).toHaveCount(1); await expect(page.locator("#preview svg #td-ambient-shadow")).toHaveCount(1); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(11); + expect(await page.locator("#preview .td-port").count()).toBeGreaterThan(40); + await expect(page.locator(".td-node-creator")).toBeHidden(); + await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator(".td-canvas-toolbar")).toHaveAttribute("role", "toolbar"); + await expect(page.locator(".td-canvas-toolbar")).toHaveAttribute("aria-label", "Canvas controls"); + await expect(page.locator(".td-canvas-legend")).toHaveAttribute("aria-label", "Diagram legend"); + await expect(page.locator(".td-canvas-legend .td-legend-item")).toHaveText(["Type", "Union", "Alias"]); + const initialZoomPercent = Number((await page.locator(".td-zoom-value").textContent())?.replace("%", "") ?? "0"); + expect(initialZoomPercent).toBeGreaterThan(0); + expect(initialZoomPercent).toBeLessThan(100); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute("style", /scale\(/); const canvasBackground = await page .locator("#preview") @@ -63,6 +76,10 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { await expect(request).toHaveClass(/td-selected/); await expect(page.locator(".td-inspector")).toBeVisible(); await expect(page.locator(".td-inspector-kind")).toHaveText("record"); + await expect(page.locator(".td-inspector-row")).toHaveCount(3); + await expect(page.locator(".td-inspector input").first()).toHaveValue("ChatRequest"); + await expect(page.getByRole("button", { name: "Delete ChatRequest" })).toHaveText("Delete type"); + await expect(page.locator("#preview .td-selected")).toHaveCount(1); await expect(page.getByRole("button", { name: "Close properties" }).locator('svg[aria-hidden="true"]')).toHaveCount( 1 ); @@ -72,6 +89,12 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { await declarationInput.blur(); await expect(page.locator("#editor")).toHaveValue(/type ConversationRequest/); await expect(page.locator('[data-decl="ConversationRequest"]')).toHaveCount(1); + await expect(page.locator('[data-decl="ChatRequest"]')).toHaveCount(0); + await expect(page.locator("#editor")).not.toHaveValue(/type ChatRequest/); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(11); + await expect + .poll(() => page.evaluate(() => localStorage.getItem("td-playground-source"))) + .toContain("type ConversationRequest"); await page.locator('[data-decl="ConversationRequest"]').click(); const firstFieldName = page.locator(".td-inspector-row").first().locator("input").first(); @@ -81,64 +104,154 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { await invalidType.fill("List<"); await invalidType.blur(); await expect(page.locator(".td-editor-toast")).toBeVisible(); + await expect(page.locator(".td-editor-toast")).not.toBeEmpty(); + await expect(invalidType).toHaveValue("List<"); await expect(page.locator("#editor")).not.toHaveValue(/prompt: List"); await validType.blur(); await expect(page.locator("#editor")).toHaveValue(/prompt: Option/); + await expect(page.locator(".td-inspector-row").first().locator("input").first()).toHaveValue("prompt"); + await expect(page.locator(".td-inspector-row").first().locator("input").nth(1)).toHaveValue("Option"); + await expect(page.locator('[data-edge][data-source="ConversationRequest"][data-target="Option"]')).toHaveCount(1); + + const sourceBeforeTab = await page.locator("#editor").inputValue(); + await page.locator(".td-inspector-row").first().locator("input").first().focus(); + await page.keyboard.press("Tab"); + await expect(page.locator(".td-inspector-row").first().locator("input").nth(1)).toBeFocused(); + await expect(page.locator("#preview.td-visual-editor")).toHaveCount(1); + await expect(page.locator("#editor")).toHaveValue(sourceBeforeTab); await page.getByRole("button", { name: "Close properties" }).click(); await page.locator('[data-decl="ToolResultContent"]').click(); await expect(page.locator(".td-inspector-kind")).toHaveText("union"); + await expect(page.locator(".td-inspector-row")).toHaveCount(4); + await expect(page.locator(".td-inspector-add")).toHaveText("+ Add row"); + await expect(page.locator(".td-inspector-delete")).toHaveAttribute("aria-label", "Delete ToolResultContent"); const scalarRow = page.locator(".td-inspector-row").nth(1); await scalarRow.locator("input").first().fill("ScalarValue"); await scalarRow.locator("input").first().blur(); await page.locator(".td-inspector-row").nth(1).locator("input").nth(1).fill("TextPart"); await page.locator(".td-inspector-row").nth(1).locator("input").nth(1).blur(); await expect(page.locator("#editor")).toHaveValue(/ScalarValue \{ value: TextPart \}/); + await expect(page.locator('[data-decl="ToolResultContent"]')).toContainText("ScalarValue"); + await expect(page.locator('[data-decl="ToolResultContent"]')).not.toContainText("Scalar { value: String }"); + await expect(page.locator('[data-edge][data-source="ToolResultContent"][data-target="TextPart"]')).toHaveCount(1); + const unionRowsBeforeAdd = await page.locator(".td-inspector-row").count(); + await page.locator(".td-inspector-add").click(); + await expect(page.locator(".td-inspector-row")).toHaveCount(unionRowsBeforeAdd + 1); + await expect(page.locator(".td-inspector-row").last().locator("input").first()).toHaveValue("Variant"); + await expect(page.locator(".td-inspector-row").last().locator("input").nth(1)).toHaveValue(""); + await expect(page.locator("#editor")).toHaveValue(/ Variant\n/); + await page.locator(".td-inspector-row").last().locator("input").first().fill("Cancelled"); + await page.locator(".td-inspector-row").last().locator("input").first().blur(); + await expect(page.locator("#editor")).toHaveValue(/ Cancelled\n/); + await expect(page.locator('[data-decl="ToolResultContent"]')).toContainText("Cancelled"); + await page.locator(".td-inspector-row").last().locator(".td-inspector-remove").click(); + await expect(page.locator(".td-inspector-row")).toHaveCount(unionRowsBeforeAdd); + await expect(page.locator("#editor")).not.toHaveValue(/ Cancelled\n/); await page.getByRole("button", { name: "Close properties" }).click(); await page.locator('[data-decl="Email"]').click(); await expect(page.locator(".td-inspector-kind")).toHaveText("alias"); + await expect(page.locator(".td-inspector-row")).toHaveCount(1); + await expect(page.locator(".td-inspector-add")).toHaveCount(0); + await expect(page.locator(".td-inspector-remove")).toHaveCount(0); await page.locator(".td-inspector-row input").nth(1).fill("Option"); await page.locator(".td-inspector-row input").nth(1).blur(); await expect(page.locator("#editor")).toHaveValue(/alias Email = Option/); + await expect(page.locator('[data-decl="Email"]')).toContainText("Option"); + await expect(page.locator('[data-edge][data-source="Email"][data-target="Option"]')).toHaveCount(1); await page.getByRole("button", { name: "Close properties" }).click(); await page.locator('[data-decl="ConversationRequest"]').click(); const rows = page.locator(".td-inspector-row"); const beforeAdd = await rows.count(); + const portsBeforeAdd = await page.locator('[data-decl="ConversationRequest"] .td-source-port').count(); await page.locator(".td-inspector-add").click(); await expect(page.locator("#editor")).toHaveValue(/field: String/); await expect(rows).toHaveCount(beforeAdd + 1); + await expect(rows.last().locator("input").first()).toHaveValue("field"); + await expect(rows.last().locator("input").nth(1)).toHaveValue("String"); + await expect(page.locator('[data-decl="ConversationRequest"] .td-source-port')).toHaveCount(portsBeforeAdd + 1); const beforeRemove = await rows.count(); await expect(rows.last().locator('.td-inspector-remove svg[aria-hidden="true"]')).toHaveCount(1); await expect(rows.last().locator(".td-inspector-remove")).toHaveAttribute("aria-label", "Remove row"); await rows.last().locator(".td-inspector-remove").click(); await expect(page.locator(".td-inspector-row")).toHaveCount(beforeRemove - 1); + await expect(page.locator("#editor")).not.toHaveValue(/field: String/); + await expect(page.locator('[data-decl="ConversationRequest"] .td-source-port')).toHaveCount(portsBeforeAdd); const addType = page.getByRole("button", { name: "Add type" }); await addType.click(); await expect(page.locator(".td-node-creator")).toBeVisible(); await expect(page.locator(".td-node-creator button")).toHaveCount(3); + await addType.click(); + await expect(page.locator(".td-node-creator")).toBeHidden(); + await addType.click(); + await expect(page.locator(".td-node-creator")).toBeVisible(); await page.getByRole("button", { name: "Add record type" }).click(); await expect(page.locator("#editor")).toHaveValue(/type NewRecord/); + await expect(page.locator("#editor")).toHaveValue(/type NewRecord \{\n field: String\n\}/); await expect(page.locator('[data-decl="NewRecord"]')).toHaveCount(1); + await expect(page.locator(".td-node-creator")).toBeHidden(); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(12); await addType.click(); await page.getByRole("button", { name: "Add union type" }).click(); await expect(page.locator("#editor")).toHaveValue(/union NewUnion/); + await expect(page.locator("#editor")).toHaveValue(/union NewUnion \{\n Variant\n\}/); await expect(page.locator('[data-decl="NewUnion"]')).toHaveCount(1); + await expect(page.locator(".td-node-creator")).toBeHidden(); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(13); await addType.click(); await page.getByRole("button", { name: "Add alias type" }).click(); await expect(page.locator("#editor")).toHaveValue(/alias NewAlias = String/); await expect(page.locator('[data-decl="NewAlias"]')).toHaveCount(1); + await expect(page.locator(".td-node-creator")).toBeHidden(); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(14); await page.getByRole("button", { name: "Close properties" }).click(); await page.locator('[data-decl="NewRecord"]').click(); + await expect(page.locator(".td-inspector input").first()).toHaveValue("NewRecord"); + await expect(page.locator(".td-inspector-row input").first()).toHaveValue("field"); + await page.locator(".td-inspector-row input").first().fill("part"); + await page.locator(".td-inspector-row input").first().blur(); + await page.locator(".td-inspector-row input").nth(1).fill("TextPart"); + await page.locator(".td-inspector-row input").nth(1).blur(); + await expect(page.locator("#editor")).toHaveValue(/type NewRecord \{\n part: TextPart\n\}/); + await expect(page.locator('[data-edge][data-source="NewRecord"][data-target="TextPart"]')).toHaveCount(1); await expect(page.getByRole("button", { name: "Delete NewRecord" })).toHaveText("Delete type"); await page.getByRole("button", { name: "Delete NewRecord" }).click(); await expect(page.locator("#editor")).not.toHaveValue(/type NewRecord/); await expect(page.locator('[data-decl="NewRecord"]')).toHaveCount(0); await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(13); + + await page.locator('[data-decl="NewUnion"]').click(); + await expect(page.locator(".td-inspector-kind")).toHaveText("union"); + await page.locator(".td-inspector-row input").first().fill("Ready"); + await page.locator(".td-inspector-row input").first().blur(); + await page.locator(".td-inspector-row input").nth(1).fill("TextPart"); + await page.locator(".td-inspector-row input").nth(1).blur(); + await expect(page.locator("#editor")).toHaveValue(/union NewUnion \{\n Ready\(TextPart\)\n\}/); + await expect(page.locator('[data-edge][data-source="NewUnion"][data-target="TextPart"]')).toHaveCount(1); + await expect(page.getByRole("button", { name: "Delete NewUnion" })).toHaveText("Delete type"); + await page.getByRole("button", { name: "Delete NewUnion" }).click(); + await expect(page.locator("#editor")).not.toHaveValue(/union NewUnion/); + await expect(page.locator('[data-decl="NewUnion"]')).toHaveCount(0); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(12); + + await page.locator('[data-decl="NewAlias"]').click(); + await expect(page.locator(".td-inspector-kind")).toHaveText("alias"); + await page.locator(".td-inspector-row input").nth(1).fill("TextPart"); + await page.locator(".td-inspector-row input").nth(1).blur(); + await expect(page.locator("#editor")).toHaveValue(/alias NewAlias = TextPart/); + await expect(page.locator('[data-edge][data-source="NewAlias"][data-target="TextPart"]')).toHaveCount(1); + await expect(page.getByRole("button", { name: "Delete NewAlias" })).toHaveText("Delete type"); + await page.getByRole("button", { name: "Delete NewAlias" }).click(); + await expect(page.locator("#editor")).not.toHaveValue(/alias NewAlias/); + await expect(page.locator('[data-decl="NewAlias"]')).toHaveCount(0); + await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(11); const movable = page.locator('[data-decl="ConversationRequest"]'); const movableBox = await movable.boundingBox(); @@ -159,17 +272,42 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { expect(movedPosition.y).toBeGreaterThan(0); expect(movedPosition.x % 8).toBe(0); expect(movedPosition.y % 8).toBe(0); + await expect(movable).toHaveAttribute( + "transform", + `translate(${String(movedPosition.x)} ${String(movedPosition.y)})` + ); + await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator("#preview .td-selected")).toHaveCount(1); + await expect(movable).toHaveClass(/td-selected/); await expect .poll(() => page.evaluate(() => localStorage.getItem("td-playground-positions"))) .toContain("ConversationRequest"); + const persistedSource = await page.locator("#editor").inputValue(); + await page.reload(); + await page.waitForSelector('[data-decl="ConversationRequest"]'); + await expect(page.locator("#editor")).toHaveValue(persistedSource); + await expect(page.locator('[data-decl="ConversationRequest"]')).toHaveAttribute( + "transform", + `translate(${String(movedPosition.x)} ${String(movedPosition.y)})` + ); + await expect(page.locator('[data-decl="ChatRequest"]')).toHaveCount(0); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(11); + await expect(page.locator(".td-inspector")).toBeHidden(); + const transformBeforeZoom = await page.locator("#preview .viewport-wrapper").getAttribute("style"); + const zoomBeforeButton = Number((await page.locator(".td-zoom-value").textContent())?.replace("%", "") ?? "0"); await page.getByRole("button", { name: "Zoom in" }).click(); const transformAfterZoom = await page.locator("#preview .viewport-wrapper").getAttribute("style"); + const zoomAfterButton = Number((await page.locator(".td-zoom-value").textContent())?.replace("%", "") ?? "0"); expect(transformAfterZoom).not.toBe(transformBeforeZoom); - await expect(page.locator(".td-zoom-value")).not.toHaveText("100%"); + expect(zoomAfterButton).toBeGreaterThan(zoomBeforeButton); await page.getByRole("button", { name: "Zoom out" }).click(); + await expect(page.locator(".td-zoom-value")).toHaveText(`${String(zoomBeforeButton)}%`); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute("style", transformBeforeZoom ?? ""); await page.getByRole("button", { name: "Fit diagram to view" }).click(); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute("style", /scale\(/); + await expect(page.locator(".td-zoom-value")).not.toHaveText("100%"); await page.getByRole("button", { name: "Reset canvas" }).click(); await expect(page.locator(".td-zoom-value")).toHaveText("100%"); @@ -201,6 +339,19 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { /translate\(50px, 35px\) scale\(1\)/ ); await page.getByRole("button", { name: "Reset canvas" }).click(); + await page.locator("#preview").focus(); + await page.locator("#preview").dispatchEvent("keydown", { key: "+", bubbles: true }); + await expect(page.locator(".td-zoom-value")).toHaveText("112%"); + await page.locator("#preview").dispatchEvent("keydown", { key: "-", bubbles: true }); + await expect(page.locator(".td-zoom-value")).toHaveText("100%"); + await page.locator("#preview").dispatchEvent("keydown", { key: "f", bubbles: true }); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute("style", /scale\(/); + await page.locator("#preview").dispatchEvent("keydown", { key: "0", bubbles: true }); + await expect(page.locator(".td-zoom-value")).toHaveText("100%"); + await expect(page.locator("#preview .viewport-wrapper")).toHaveAttribute( + "style", + /translate\(0px, 0px\) scale\(1\)/ + ); const sourcePort = page.locator('[data-decl="ConversationRequest"] .td-source-port[data-row-index="0"]'); const target = page.locator('[data-decl="AgentConfig"] .td-target-port'); @@ -218,9 +369,42 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { '[data-decl="AgentConfig"] .td-target-port' ); await expect(page.locator("#editor")).toHaveValue(/prompt: AgentConfig/); + await expect(page.locator('[data-edge][data-source="ConversationRequest"][data-target="AgentConfig"]')).toHaveCount( + 1 + ); + await expect(page.locator('[data-decl="ConversationRequest"]')).toContainText("prompt: AgentConfig"); + await expect(page.locator(".td-connection-preview")).toHaveCount(0); + await expect(page.locator("#preview .td-selected")).toHaveCount(0); + await expect(page.locator(".td-inspector")).toBeHidden(); + + const genericPort = page.locator('[data-decl="ToolResult"] .td-source-port[data-row-index="-1"]'); + const genericTarget = page.locator('[data-decl="Option"] .td-target-port'); + const genericPortBox = await genericPort.boundingBox(); + const genericTargetBox = await genericTarget.boundingBox(); + expect(genericPortBox).not.toBeNull(); + expect(genericTargetBox).not.toBeNull(); + await dispatchPointerDrag( + genericPort, + { x: (genericPortBox?.x ?? 0) + 2, y: (genericPortBox?.y ?? 0) + 2 }, + { + x: (genericTargetBox?.x ?? 0) + (genericTargetBox?.width ?? 0) / 2, + y: (genericTargetBox?.y ?? 0) + (genericTargetBox?.height ?? 0) / 2, + }, + '[data-decl="Option"] .td-target-port' + ); + await expect(page.locator("#editor")).toHaveValue(/option: Option/); + await expect(page.locator('[data-decl="Option"]')).toHaveCount(1); + await expect(page.locator('[data-edge][data-source="ToolResult"][data-target="Option"]')).toHaveCount(1); + await expect(page.locator('[data-decl="ToolResult"]')).toContainText("option: Option"); + await expect(page.locator("#preview > .viewport-wrapper > svg")).toHaveCount(1); + await expect(page.locator(".td-connection-preview")).toHaveCount(0); + await expect(page.locator("#preview .td-selected")).toHaveCount(0); + await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator(".td-editor-toast")).toBeHidden(); await page.getByRole("button", { name: "Restore automatic layout" }).click(); await expect.poll(() => page.evaluate(() => localStorage.getItem("td-playground-positions"))).toBe("{}"); + await expect(page.locator("#preview svg [data-decl]")).toHaveCount(11); expect( await page.locator('[data-decl="ConversationRequest"]').evaluate((element) => ({ x: Number((element as SVGGElement).dataset.editorX), @@ -230,14 +414,29 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { const downloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: "Export SVG" }).click(); - expect((await downloadPromise).suggestedFilename()).toBe("type-diagram.svg"); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("type-diagram.svg"); + expect(await download.failure()).toBeNull(); await page.locator('[data-decl="ConversationRequest"]').click(); + await expect(page.locator(".td-inspector")).toBeVisible(); + await expect(page.locator(".td-inspector input").first()).toHaveValue("ConversationRequest"); await page.getByRole("button", { name: "Close properties" }).click(); await expect(page.locator(".td-inspector")).toBeHidden(); await page.locator('[data-decl="ConversationRequest"]').click(); await expect(page.locator("#preview")).toBeFocused(); + await expect(page.locator(".td-inspector")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.locator(".td-inspector")).toBeHidden(); + await expect(page.locator("#preview.td-visual-editor")).toHaveCount(1); + await expect(page.locator("#editor")).toHaveValue(/type ConversationRequest/); + await expect(page.locator("#editor")).toHaveValue(/prompt: AgentConfig/); + await expect(page.locator("#editor")).toHaveValue(/ScalarValue \{ value: TextPart \}/); + await expect(page.locator("#editor")).toHaveValue(/alias Email = Option/); + await expect(page.locator("#editor")).toHaveValue(/option: Option/); + await expect(page.locator("#editor")).not.toHaveValue(/type NewRecord/); + await expect(page.locator("#editor")).not.toHaveValue(/union NewUnion/); + await expect(page.locator("#editor")).not.toHaveValue(/alias NewAlias/); + await expect(page.locator("#editor")).not.toHaveValue(/prompt: List Date: Sun, 19 Jul 2026 04:28:45 +1000 Subject: [PATCH 13/14] Add Typeshed conversion and function declarations --- .mcp.json | 2 +- README.md | 6 +- coverage-thresholds.json | 6 +- docs/specs/api.md | 2 + docs/specs/cli.md | 24 +- docs/specs/converters.md | 7 +- docs/specs/getting-started.md | 5 +- docs/specs/language-reference.md | 21 +- docs/specs/typeshed-conversion.md | 123 +++++++ package-lock.json | 36 ++ packages/cli/package.json | 3 +- packages/cli/src/generation-config.ts | 5 +- packages/cli/src/result.ts | 1 + packages/cli/src/typeshed-bin.ts | 5 + packages/cli/src/typeshed-cli.ts | 106 ++++++ .../cli/test/config-generation.e2e.test.ts | 2 +- packages/cli/test/typeshed.e2e.test.ts | 82 +++++ packages/cli/vitest.config.ts | 2 +- packages/typediagram/package.json | 2 + packages/typediagram/scripts/bundle-size.mjs | 13 +- .../typediagram/scripts/typeshed-corpus.mjs | 58 ++++ .../typediagram/src/converters/emit-decls.ts | 6 +- packages/typediagram/src/converters/fsharp.ts | 4 +- packages/typediagram/src/converters/go.ts | 4 +- packages/typediagram/src/converters/index.ts | 6 +- packages/typediagram/src/converters/php.ts | 4 +- packages/typediagram/src/converters/python.ts | 4 +- .../typediagram/src/converters/rust-tdbin.ts | 10 +- packages/typediagram/src/converters/rust.ts | 8 +- packages/typediagram/src/converters/types.ts | 3 +- .../typediagram/src/converters/typescript.ts | 4 +- .../src/converters/typeshed-decls.ts | 325 ++++++++++++++++++ .../src/converters/typeshed-tree.ts | 76 ++++ .../src/converters/typeshed-type-ref.ts | 167 +++++++++ .../typediagram/src/converters/typeshed.ts | 98 ++++++ .../typediagram/src/editor/source-editor.ts | 45 ++- packages/typediagram/src/editor/styles.ts | 3 +- .../typediagram/src/editor/visual-editor.ts | 48 ++- packages/typediagram/src/layout/elk.ts | 18 +- packages/typediagram/src/layout/types.ts | 4 +- packages/typediagram/src/model/build.ts | 50 ++- packages/typediagram/src/model/builder.ts | 36 +- packages/typediagram/src/model/edges.ts | 42 ++- packages/typediagram/src/model/index.ts | 9 +- packages/typediagram/src/model/json.ts | 61 +++- packages/typediagram/src/model/print.ts | 22 +- packages/typediagram/src/model/types.ts | 34 +- packages/typediagram/src/parser/ast.ts | 19 +- packages/typediagram/src/parser/function.ts | 109 ++++++ packages/typediagram/src/parser/lexer.ts | 15 +- .../typediagram/src/parser/parse-common.ts | 72 ++++ packages/typediagram/src/parser/parser.ts | 93 ++--- packages/typediagram/src/render-svg/render.ts | 6 +- .../test/converters/typeshed.test.ts | 234 +++++++++++++ .../test/function.integration.test.ts | 80 +++++ .../typediagram/test/visual-editor.test.ts | 8 +- packages/vscode/src/preview-panel.ts | 17 +- .../vscode/src/webview/interaction-test.ts | 12 +- packages/web/e2e/converter.spec.ts | 30 +- packages/web/e2e/visual-editor.spec.ts | 14 +- packages/web/eleventy/_data/docs.ts | 1 + packages/web/eleventy/converter.njk | 8 +- packages/web/eleventy/docs-index.njk | 4 +- packages/web/src/converter-highlight.ts | 4 +- packages/web/src/converter-render.ts | 4 +- packages/web/src/converter.ts | 2 + packages/web/test/converter-render.test.ts | 1 + 67 files changed, 2122 insertions(+), 213 deletions(-) create mode 100644 docs/specs/typeshed-conversion.md create mode 100644 packages/cli/src/typeshed-bin.ts create mode 100644 packages/cli/src/typeshed-cli.ts create mode 100644 packages/cli/test/typeshed.e2e.test.ts create mode 100644 packages/typediagram/scripts/typeshed-corpus.mjs create mode 100644 packages/typediagram/src/converters/typeshed-decls.ts create mode 100644 packages/typediagram/src/converters/typeshed-tree.ts create mode 100644 packages/typediagram/src/converters/typeshed-type-ref.ts create mode 100644 packages/typediagram/src/converters/typeshed.ts create mode 100644 packages/typediagram/src/parser/function.ts create mode 100644 packages/typediagram/src/parser/parse-common.ts create mode 100644 packages/typediagram/test/converters/typeshed.test.ts create mode 100644 packages/typediagram/test/function.integration.test.ts diff --git a/.mcp.json b/.mcp.json index c0e2ef4..0d80126 100644 --- a/.mcp.json +++ b/.mcp.json @@ -4,4 +4,4 @@ "url": "http://localhost:4040/mcp" } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 0969b26..1500873 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ typeDiagram is a tiny, language-neutral DSL for describing **algebraic data types** — records, tagged unions, generics, aliases. From one `.td` file, you get: -- **Source code** in TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, and Protobuf — DTOs, data classes, discriminated unions, pattern-matchable enums — generated from the same definition, always in sync. +- **Source code** in Typeshed, TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, and Protobuf — DTOs, data classes, free functions, discriminated unions, and enums — converted through one model. - **SVG diagrams** with automatic orthogonal layout — no dragging, no fiddling, versionable in git. -- **Round-trip conversion** from existing TypeScript/Python/Rust/Go/C#/F#/Dart/PHP/Protobuf back to the DSL, so you can retrofit an existing codebase. +- **Round-trip conversion** from existing Typeshed/TypeScript/Python/Rust/Go/C#/F#/Dart/PHP/Protobuf back to the DSL, so you can retrofit an existing codebase. This is not a diagramming tool dressed up with a text input like Mermaid or PlantUML. typeDiagram is a **shared schema for your data model** — the diagram is a side effect, not the goal. The primary output is code, in as many languages as you need, kept strictly in sync by construction. @@ -79,6 +79,8 @@ Three constructs: `type` (records), `union` (tagged sum types), `alias` (newtype ```sh typediagram schema.td > diagram.svg # DSL → SVG typediagram --from typescript types.ts > diagram.svg # TS → SVG +typediagram --from typeshed --emit td module.pyi # .pyi → typeDiagram +typediagram-typeshed /path/to/typeshed generated/ # complete checkout typediagram --to rust schema.td > types.rs # DSL → Rust ``` diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 664b0a4..d9664d4 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -4,10 +4,10 @@ "default_threshold": 90, "projects": { "packages/typediagram": { - "statements": 97.14, + "statements": 97.22, "branches": 91.05, "functions": 98.8, - "lines": 97.11 + "lines": 97.18 }, "packages/cli": { "statements": 99, @@ -18,7 +18,7 @@ "packages/web": { "statements": 97.7878787878788, "branches": 96.46835443037975, - "functions": 98.05660377358491, + "functions": 99, "lines": 97.7012987012987 }, "packages/vscode": { diff --git a/docs/specs/api.md b/docs/specs/api.md index 912d047..01b621f 100644 --- a/docs/specs/api.md +++ b/docs/specs/api.md @@ -173,6 +173,7 @@ import { converters } from "typediagram-core"; // Parse other languages const model = converters.typescript.fromSource(tsCode); const model = converters.python.fromSource(pyCode); +const model = converters.typeshed.fromSource(pyiCode); const model = converters.rust.fromSource(rsCode); const model = converters.go.fromSource(goCode); const model = converters.csharp.fromSource(csCode); @@ -184,6 +185,7 @@ const model = converters.protobuf.fromSource(protoCode); // Emit other languages const tsCode = converters.typescript.toSource(model); const pyCode = converters.python.toSource(model); +const pyiCode = converters.typeshed.toSource(model); const rsCode = converters.rust.toSource(model); const goCode = converters.go.toSource(model); const csCode = converters.csharp.toSource(model); diff --git a/docs/specs/cli.md b/docs/specs/cli.md index 559e969..7ba30b2 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -21,15 +21,15 @@ If `file` is omitted, reads from stdin. Output goes to stdout. Errors go to stde ## Options -| Flag | Value | Description | -| -------------- | ------------------------------------------------------------------- | ------------------------------------------------- | -| `--config` | JSON file | Generate every configured language output | -| `--watch` | | Regenerate configured outputs after `.td` changes | -| `--from` | `typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from language source to SVG | -| `--to` | `typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from typeDiagram to language source | -| `--theme` | `light\|dark` | Color theme (default: `light`) | -| `--font-size` | number | Font size in pixels | -| `-h`, `--help` | | Show help | +| Flag | Value | Description | +| -------------- | ----------------------------------------------------------------------------- | ------------------------------------------------- | +| `--config` | JSON file | Generate every configured language output | +| `--watch` | | Regenerate configured outputs after `.td` changes | +| `--from` | `typeshed\|typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from language source to SVG | +| `--to` | `typescript\|python\|rust\|go\|csharp\|fsharp\|dart\|protobuf\|php` | Convert from typeDiagram to language source | +| `--theme` | `light\|dark` | Color theme (default: `light`) | +| `--font-size` | number | Font size in pixels | +| `-h`, `--help` | | Show help | `--from` and `--to` are mutually exclusive. @@ -82,6 +82,12 @@ typediagram --from typescript types.ts > diagram.svg # Python dataclasses → SVG typediagram --from python models.py > diagram.svg +# One Typeshed stub → typeDiagram source +typediagram --from typeshed --emit td stdlib/dataclasses.pyi > dataclasses.td + +# Complete Typeshed checkout → mirrored .td tree +typediagram-typeshed /path/to/typeshed /path/to/generated-typeshed + # Rust structs/enums → SVG typediagram --from rust types.rs > diagram.svg diff --git a/docs/specs/converters.md b/docs/specs/converters.md index b00e56d..26da450 100644 --- a/docs/specs/converters.md +++ b/docs/specs/converters.md @@ -1,6 +1,6 @@ # Language Converters -typeDiagram includes bidirectional converters for **TypeScript**, **Python**, **Rust**, **Go**, **C#**, **F#**, **Dart**, **PHP**, and **Protobuf**. Each converter can parse existing type definitions into a typeDiagram model, and emit type definitions from a model. All nine converters losslessly round-trip the canonical home-page sample (`TD → lang → TD` is byte-for-byte identical). +typeDiagram includes converters for **Typeshed**, **TypeScript**, **Python**, **Rust**, **Go**, **C#**, **F#**, **Dart**, **PHP**, and **Protobuf**. The nine data-language converters losslessly round-trip the canonical home-page sample; Typeshed additionally imports module functions and overloads. See [Typeshed to typeDiagram](typeshed-conversion.md). ## How it works @@ -81,6 +81,10 @@ Emits `export interface` for records, discriminated unions with a `kind` field f Emits `@dataclass` for records, `str, Enum` for unions with no payloads, separate dataclass per variant + type alias for unions with payloads. +## Typeshed + +The dedicated `typeshed` converter parses `.pyi` syntax with a Python concrete syntax tree. It imports ordinary classes, protocols, dataclasses, TypedDicts, NamedTuples, enums, aliases, top-level functions, async functions, conditional declarations, and overloads. Class methods are intentionally excluded. Use `typediagram-typeshed` to mirror a complete checkout into `.td` files. + ## Rust ### What maps @@ -214,6 +218,7 @@ if (result.ok) { // Available converters converters.typescript; converters.python; +converters.typeshed; converters.rust; converters.go; converters.csharp; diff --git a/docs/specs/getting-started.md b/docs/specs/getting-started.md index 40065e0..b629883 100644 --- a/docs/specs/getting-started.md +++ b/docs/specs/getting-started.md @@ -29,8 +29,9 @@ echo 'type User { name: String }' | typediagram > diagram.svg # From a file typediagram schema.td > diagram.svg -# From existing TypeScript/Python/Rust/Go/C#/F#/Dart/PHP/Protobuf code +# From existing Typeshed/TypeScript/Python/Rust/Go/C#/F#/Dart/PHP/Protobuf code typediagram --from typescript types.ts > diagram.svg +typediagram --from typeshed --emit td module.pyi > module.td ``` ## Your first diagram @@ -70,7 +71,7 @@ You'll see three connected boxes: `User` links to `Option` (via the `email` f | **Web playground** | Browser-based editor with syntax highlighting, live preview, pan/zoom, and a Hooks tab with live presets | | **CLI** | `typediagram` binary — pipe source in, SVG out | | **VS Code extension** | Live `.td` preview, markdown preview rendering, **Export to PDF** with vector diagrams | -| **Converters** | Bidirectional: TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, Protobuf ↔ typeDiagram | +| **Converters** | Typeshed, TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, Protobuf ↔ typeDiagram | | **Node.js API** | `renderToString()`, `parse()`, converter APIs, **render hooks** for SVG customisation | ## Markdown → PDF (VS Code) diff --git a/docs/specs/language-reference.md b/docs/specs/language-reference.md index 5700abb..324cd61 100644 --- a/docs/specs/language-reference.md +++ b/docs/specs/language-reference.md @@ -1,6 +1,6 @@ # Language Reference -typeDiagram has three constructs: **type** (records), **union** (tagged sum types), and **alias** (newtypes). That's it. +typeDiagram has four constructs: **type** (records), **union** (tagged sum types), **alias** (newtypes), and **function** (free-function/service signatures). It has no class methods. ## Records (`type`) @@ -96,6 +96,19 @@ alias UserId = Uuid alias Callback = Option ``` +## Functions (`function`) + +Functions carry typed parameters, return types, optional generics, async state, and overloads. See [Typeshed to typeDiagram](typeshed-conversion.md) for the full import pipeline. + +```typediagram +function fetch(request: Request, fallback: Option) -> Response + +function read { + (path: String) -> Bytes + async (path: String, timeout: Float) -> Bytes +} +``` + ## Built-in types These primitive types are always available (no declaration needed): @@ -171,10 +184,14 @@ References to undeclared types (like `CountryCode` or any name not declared in t ``` Diagram = ("typeDiagram")? Declaration* -Declaration = Record | Union | Alias +Declaration = Record | Union | Alias | Function Record = "type" Name Generics? "{" Field* "}" Union = "union" Name Generics? "{" Variant* "}" Alias = "alias" Name Generics? "=" TypeRef +Function = ("async")? "function" Name Generics? Signature + | "function" Name Generics? "{" Signature* "}" +Signature = ("async")? "(" Parameter* ")" "->" TypeRef +Parameter = Name ":" TypeRef Field = Name ":" TypeRef Variant = Name ("=" Number)? ("{" Field* "}")? TypeRef = Name ("<" TypeRef ("," TypeRef)* ">")? diff --git a/docs/specs/typeshed-conversion.md b/docs/specs/typeshed-conversion.md new file mode 100644 index 0000000..b647aa4 --- /dev/null +++ b/docs/specs/typeshed-conversion.md @@ -0,0 +1,123 @@ +# Typeshed to typeDiagram + +`[TYPESHED-*]` defines how [python/typeshed](https://github.com/python/typeshed) `.pyi` files become typeDiagram models, DSL files, and SVG diagrams. The importer is available in the library, the normal CLI, the bulk CLI, and the web converter. + +## Quick start + +Convert one stub: + +```sh +typediagram --from typeshed --emit td stdlib/dataclasses.pyi > dataclasses.td +typediagram --from typeshed stdlib/dataclasses.pyi > dataclasses.svg +``` + +Convert a complete typeshed checkout while preserving its `stdlib/` and `stubs/` directory structure: + +```sh +typediagram-typeshed /path/to/typeshed /path/to/generated-typeshed +``` + +Every `.pyi` containing declarations produces a matching `.td`; import-only, constant-only, and re-export-only modules are reported and skipped. Output files are written atomically. + +Programmatic conversion uses the same public converter registry as every other language: + +```ts +import { converters, model } from "typediagram-core"; + +const analyzed = converters.typeshed.analyzeSource(stubSource); +if (analyzed.ok) { + const td = model.printSource(analyzed.value.model); + const { declarationsConverted, methodsSkipped } = analyzed.value.stats; +} +``` + +`converters.typeshed.fromSource(source)` returns only the model when conversion statistics are not needed. `converters.typeshed.toSource(model)` emits a `.pyi` representation. + +## Conversion pipeline + +`[TYPESHED-AST]` parses Python with `@lezer/python`, a browser-safe Python grammar. Conversion never uses regular expressions to interpret structured stub syntax. + +```text +.pyi source + → Python concrete syntax tree + → module declaration extraction + → type annotation normalization + → overload/class merge and generic-arity normalization + → resolved typeDiagram Model + → .td / JSON / layout / SVG +``` + +The extractor walks module declarations and declarations inside module-level `if`/`elif`/`else` version and platform gates. Conditional definitions with the same name are merged. It does not descend from a class into its methods. + +## Declaration mapping + +| Typeshed syntax | typeDiagram result | +| ----------------------------------------------------- | ------------------------------------------- | +| ordinary `class` / `Protocol` | `type`, with annotated class fields | +| `@dataclass class` | `type`, with dataclass fields | +| `TypedDict` / `NamedTuple` class | `type`, with declared fields | +| `Enum`, `IntEnum`, `StrEnum`, `Flag`, `IntFlag` | `union`, with assignment names as variants | +| module `def` / `async def` | `function` | +| repeated `@overload def name` | one `function name` with several signatures | +| `name: TypeAlias = T` | `alias name = T` | +| `type Name[T] = T` | generic `alias Name = T` | +| inferable legacy alias / `NewType` / `TypeAliasType` | `alias` | +| class method, property, static method, or constructor | deliberately omitted | +| module constant or `TypeVar` declaration | omitted | + +`[TYPESHED-DATACLASS]` does not depend on executing decorators. A dataclass is read structurally from its syntax tree, so frozen, slotted, generic, and decorator-call forms work in the browser. `ClassVar` members are not instance data and are omitted. + +`[TYPESHED-METHODS]` is the boundary between data and behavior: module functions are retained; functions nested in classes are counted in `methodsSkipped` and never become fields or function declarations. This applies equally to dataclasses, protocols, TypedDicts, NamedTuples, enums, and ordinary classes. + +## Function markup + +`[DSL-FUNCTION]` adds free functions to the typeDiagram grammar. One signature uses a compact declaration: + +```typediagram +function fetch(request: Request, limit: Int) -> Response +async function refresh(request: Request) -> Response +``` + +Overloads share one node: + +```typediagram +function open { + (path: String) -> Bytes + (descriptor: Int, mode: String) -> Bytes + async (path: String, timeout: Float) -> Bytes +} +``` + +Functions participate in parsing, model resolution, automatic parameter/return edges, JSON round-tripping, source printing, layout, SVG rendering, and render hooks. Existing data-language emitters continue to emit records/unions/aliases only; the typeshed emitter preserves function signatures as `.pyi` declarations. + +## Type normalization + +Python primitives and common containers map directly: `str → String`, `int → Int`, `float → Float`, `bool → Bool`, `bytes → Bytes`, `list → List`, `dict/Mapping → Map`, and `T | None → Option`. Qualified names use their final component. + +The importer preserves unfamiliar typing constructs as external type references, so `Callable`, `Protocol`, `LiteralString`, `TypeGuard`, and package-specific types still appear in the diagram. `Annotated`, `Final`, `Required`, `NotRequired`, and `ReadOnly` unwrap to their payload type; `ClassVar` fields are dropped; literal values reduce to their scalar type where a literal cannot be represented by a typeDiagram type reference. + +Python permits special and variadic generic arities that typeDiagram does not. `[TYPESHED-ARITY]` computes the largest observed arity for every declaration, adds synthetic generic parameters when required, and pads shorter references with `Any`. This keeps every emitted `.td` valid and round-trippable without discarding the referenced declaration. + +## Full-corpus verification + +`[TYPESHED-CORPUS]` audits an external checkout without making network access part of normal CI: + +```sh +cd packages/typediagram +npm run test:typeshed -- /path/to/typeshed +``` + +The gate parses each `.pyi`, converts every eligible declaration, prints `.td`, parses it again, rebuilds the model, and fails if any eligible file does not round-trip. On the typeshed `main` archive tested on 2026-07-19: + +| Measure | Result | +| ------------------------------------------ | ---------------------: | +| `.pyi` files scanned | 5,214 | +| files with convertible declarations | 4,508 | +| declaration-free files | 706 | +| eligible files converted and round-tripped | 4,508 / 4,508 (100%) | +| declaration statements converted | 40,467 / 40,467 (100%) | +| merged model declarations emitted | 38,666 | +| class methods deliberately skipped | 53,468 | +| syntax/conversion failures | 0 | + +Normal unit/integration coverage uses realistic dataclass, TypedDict, NamedTuple, enum, alias, conditional, overload, async, malformed-source, and empty-source cases. The bulk CLI has a filesystem E2E test. Playwright drives the public web converter, pastes a typeshed dataclass with a method plus a module function, and asserts that the generated DSL and SVG retain the data/function while excluding the method. diff --git a/package-lock.json b/package-lock.json index 6c921fb..9f35249 100644 --- a/package-lock.json +++ b/package-lock.json @@ -633,6 +633,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -7378,6 +7413,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { + "@lezer/python": "^1.1.19", "elkjs": "^0.11.1" }, "devDependencies": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 30ace94..80f1169 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,8 @@ }, "type": "module", "bin": { - "typediagram": "./dist/bin.js" + "typediagram": "./dist/bin.js", + "typediagram-typeshed": "./dist/typeshed-bin.js" }, "files": [ "dist", diff --git a/packages/cli/src/generation-config.ts b/packages/cli/src/generation-config.ts index a3e5aae..a3a67ee 100644 --- a/packages/cli/src/generation-config.ts +++ b/packages/cli/src/generation-config.ts @@ -3,8 +3,7 @@ import { watch, type FSWatcher } from "node:fs"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import { converters, model as modelLayer, parser } from "typediagram-core"; -import type { Result } from "./result.js"; -import { err, ok } from "./result.js"; +import { err, errorMessage, ok, type Result } from "./result.js"; type GenerationError = { readonly message: string }; type OutputTarget = { readonly language: converters.Language; readonly path: string }; @@ -21,8 +20,6 @@ const recordValue = (value: unknown): value is Record => const languageValue = (value: string): value is converters.Language => converters.LANGUAGES.some((language) => language === value); -const errorMessage = (error: unknown) => String(error); - const readText = async (path: string): Promise> => { try { return ok(await readFile(path, "utf8")); diff --git a/packages/cli/src/result.ts b/packages/cli/src/result.ts index 209b0f8..5e78a83 100644 --- a/packages/cli/src/result.ts +++ b/packages/cli/src/result.ts @@ -3,3 +3,4 @@ export type Result = { readonly ok: true; readonly value: T } | { readonly export const ok = (value: T): Result => ({ ok: true, value }); export const err = (error: E): Result => ({ ok: false, error }); +export const errorMessage = (error: unknown) => String(error); diff --git a/packages/cli/src/typeshed-bin.ts b/packages/cli/src/typeshed-bin.ts new file mode 100644 index 0000000..312e684 --- /dev/null +++ b/packages/cli/src/typeshed-bin.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env node +// [TYPESHED-BULK-BIN] Dedicated typeshed repository converter entry point. +import { typeshedMain } from "./typeshed-cli.js"; + +void typeshedMain(process.argv.slice(2), process.stdout, process.stderr).then((code) => process.exit(code)); diff --git a/packages/cli/src/typeshed-cli.ts b/packages/cli/src/typeshed-cli.ts new file mode 100644 index 0000000..fead1ef --- /dev/null +++ b/packages/cli/src/typeshed-cli.ts @@ -0,0 +1,106 @@ +// [TYPESHED-BULK] Mirror a typeshed checkout into one .td file per non-empty .pyi. +import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import { converters, model } from "typediagram-core"; +import { err, errorMessage, ok, type Result } from "./result.js"; + +interface BulkError { + readonly message: string; +} + +interface FileOutcome { + readonly kind: "converted" | "empty"; + readonly declarations: number; +} + +const pyiPaths = (tree: string, names: string[]) => + names.filter((name) => name.endsWith(".pyi")).map((name) => join(tree, name)); + +const stubFiles = async (root: string): Promise> => { + try { + const stdlib = join(root, "stdlib"); + const stubs = join(root, "stubs"); + const [stdlibEntries, stubEntries] = await Promise.all([ + readdir(stdlib, { recursive: true }), + readdir(stubs, { recursive: true }), + ]); + return ok([...pyiPaths(stdlib, stdlibEntries), ...pyiPaths(stubs, stubEntries)]); + } catch (error) { + return err({ message: `cannot scan typeshed root ${root}: ${errorMessage(error)}` }); + } +}; + +const outputPath = (sourceRoot: string, outputRoot: string, source: string) => { + const path = relative(sourceRoot, source); + return join(outputRoot, `${path.slice(0, -4)}.td`); +}; + +const atomicWrite = async (path: string, content: string): Promise> => { + try { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.${String(process.pid)}.tmp`; + await writeFile(temporary, content, "utf8"); + await rename(temporary, path); + return ok(true); + } catch (error) { + return err({ message: `cannot write ${path}: ${errorMessage(error)}` }); + } +}; + +const convertFile = async ( + sourceRoot: string, + outputRoot: string, + path: string +): Promise> => { + try { + const analyzed = converters.typeshed.analyzeSource(await readFile(path, "utf8")); + if (!analyzed.ok && analyzed.error.every((diagnostic) => diagnostic.message === "No typeshed declarations found")) { + return ok({ kind: "empty", declarations: 0 }); + } + if (!analyzed.ok) { + return err({ message: `${path}: ${analyzed.error.map((diagnostic) => diagnostic.message).join("; ")}` }); + } + const written = await atomicWrite( + outputPath(sourceRoot, outputRoot, path), + model.printSource(analyzed.value.model) + ); + return written.ok ? ok({ kind: "converted", declarations: analyzed.value.model.decls.length }) : written; + } catch (error) { + return err({ message: `cannot convert ${path}: ${errorMessage(error)}` }); + } +}; + +const convertAll = async (sourceRoot: string, outputRoot: string, files: string[]) => { + const outcomes: FileOutcome[] = []; + for (const file of files) { + const converted = await convertFile(sourceRoot, outputRoot, file); + if (!converted.ok) { + return converted; + } + outcomes.push(converted.value); + } + return ok(outcomes); +}; + +const summary = (outcomes: FileOutcome[]) => { + const converted = outcomes.filter((outcome) => outcome.kind === "converted").length; + const empty = outcomes.length - converted; + const declarations = outcomes.reduce((total, outcome) => total + outcome.declarations, 0); + return `converted ${String(converted)} typeshed files (${String(declarations)} declarations); skipped ${String(empty)} files without declarations\n`; +}; + +export const typeshedMain = async ( + argv: readonly string[], + stdout: NodeJS.WritableStream, + stderr: NodeJS.WritableStream +) => { + const sourceRoot = argv[0] === undefined ? undefined : resolve(argv[0]); + const outputRoot = argv[1] === undefined ? undefined : resolve(argv[1]); + if (sourceRoot === undefined || outputRoot === undefined) { + stderr.write("usage: typediagram-typeshed \n"); + return 1; + } + const files = await stubFiles(sourceRoot); + const converted = files.ok ? await convertAll(sourceRoot, outputRoot, files.value) : files; + return converted.ok ? (stdout.write(summary(converted.value)), 0) : (stderr.write(`${converted.error.message}\n`), 1); +}; diff --git a/packages/cli/test/config-generation.e2e.test.ts b/packages/cli/test/config-generation.e2e.test.ts index 551776c..a313a87 100644 --- a/packages/cli/test/config-generation.e2e.test.ts +++ b/packages/cli/test/config-generation.e2e.test.ts @@ -89,7 +89,7 @@ describe("[CLI-CONFIG-GENERATE] configured ADT generation", () => { await writeFile(sourcePath, `${source(" id: Int\n name: String")}active`, "utf8"); await vi.waitFor(() => { - expect(err.text()).toContain("expected 'type', 'union', 'untagged union', or 'alias'"); + expect(err.text()).toContain("expected 'type', 'union', 'untagged union', 'alias', or 'function'"); }); expect(await readFile(typeScriptPath, "utf8")).toBe(lastGoodTypeScript); expect(await readFile(rustPath, "utf8")).toBe(lastGoodRust); diff --git a/packages/cli/test/typeshed.e2e.test.ts b/packages/cli/test/typeshed.e2e.test.ts new file mode 100644 index 0000000..1106a85 --- /dev/null +++ b/packages/cli/test/typeshed.e2e.test.ts @@ -0,0 +1,82 @@ +// [TYPESHED-BULK-TEST] Black-box repository conversion through the public CLI function. +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { typeshedMain } from "../src/typeshed-cli.js"; +import { makeStream } from "./helpers.js"; + +const createStubRoot = async (source: string) => { + const root = await mkdtemp(join(tmpdir(), "typediagram-typeshed-invalid-")); + await Promise.all([ + mkdir(join(root, "stdlib"), { recursive: true }), + mkdir(join(root, "stubs"), { recursive: true }), + ]); + await writeFile(join(root, "stdlib", "sample.pyi"), source, "utf8"); + return root; +}; + +describe("[TYPESHED-BULK] typeshed repository conversion", () => { + it("mirrors stdlib and third-party stubs, skips empty modules, and retains no methods", async () => { + const root = await mkdtemp(join(tmpdir(), "typediagram-typeshed-source-")); + const output = await mkdtemp(join(tmpdir(), "typediagram-typeshed-output-")); + await mkdir(join(root, "stdlib"), { recursive: true }); + await mkdir(join(root, "stubs", "package", "package"), { recursive: true }); + await writeFile( + join(root, "stdlib", "sample.pyi"), + "class Payload:\n value: str\n def encode(self) -> bytes: ...\n\ndef fetch(payload: Payload) -> bytes: ...\n", + "utf8" + ); + await writeFile(join(root, "stdlib", "empty.pyi"), "from sample import Payload\n", "utf8"); + await writeFile( + join(root, "stubs", "package", "package", "__init__.pyi"), + "from typing import TypedDict\nclass Config(TypedDict):\n enabled: bool\n", + "utf8" + ); + + const stdout = makeStream(); + const stderr = makeStream(); + const code = await typeshedMain([root, output], stdout.stream, stderr.stream); + const stdlib = await readFile(join(output, "stdlib", "sample.td"), "utf8"); + const thirdParty = await readFile(join(output, "stubs", "package", "package", "__init__.td"), "utf8"); + expect(code).toBe(0); + expect(stderr.text()).toBe(""); + expect(stdout.text()).toBe("converted 2 typeshed files (3 declarations); skipped 1 files without declarations\n"); + expect(stdlib).toContain("type Payload"); + expect(stdlib).toContain("function fetch(payload: Payload) -> Bytes"); + expect(stdlib).not.toContain("encode"); + expect(thirdParty).toContain("type Config"); + }); + + it("reports missing arguments and invalid roots without partial success", async () => { + const stdout = makeStream(); + const stderr = makeStream(); + expect(await typeshedMain([], stdout.stream, stderr.stream)).toBe(1); + expect(stderr.text()).toContain("usage: typediagram-typeshed"); + const missingOutputError = makeStream(); + expect(await typeshedMain(["/typeshed"], stdout.stream, missingOutputError.stream)).toBe(1); + expect(missingOutputError.text()).toContain("usage: typediagram-typeshed"); + const missingError = makeStream(); + expect(await typeshedMain(["/missing/typeshed", "/tmp/output"], stdout.stream, missingError.stream)).toBe(1); + expect(missingError.text()).toContain("cannot scan typeshed root"); + const invalidRoot = await createStubRoot("def broken(: ..."); + const invalidOutput = await mkdtemp(join(tmpdir(), "typediagram-typeshed-invalid-output-")); + const invalidError = makeStream(); + expect(await typeshedMain([invalidRoot, invalidOutput], stdout.stream, invalidError.stream)).toBe(1); + expect(invalidError.text()).toContain("Invalid typeshed/Python stub syntax"); + const validRoot = await createStubRoot("class Valid:\n value: str\n"); + const blockedOutput = join(await mkdtemp(join(tmpdir(), "typediagram-typeshed-blocked-")), "output"); + await writeFile(blockedOutput, "not a directory", "utf8"); + const writeError = makeStream(); + expect(await typeshedMain([validRoot, blockedOutput], stdout.stream, writeError.stream)).toBe(1); + expect(writeError.text()).toContain("cannot write"); + const unreadableRoot = await mkdtemp(join(tmpdir(), "typediagram-typeshed-unreadable-")); + await Promise.all([ + mkdir(join(unreadableRoot, "stdlib", "directory.pyi"), { recursive: true }), + mkdir(join(unreadableRoot, "stubs"), { recursive: true }), + ]); + const readError = makeStream(); + expect(await typeshedMain([unreadableRoot, invalidOutput], stdout.stream, readError.stream)).toBe(1); + expect(readError.text()).toContain("cannot convert"); + }); +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index dfc20ac..53fef4a 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -3,5 +3,5 @@ import { createVitestConfig } from "../../scripts/vitest-config-base"; export default createVitestConfig({ configDir: __dirname, project: "packages/cli", - exclude: ["src/bin.ts"], + exclude: ["src/bin.ts", "src/typeshed-bin.ts"], }); diff --git a/packages/typediagram/package.json b/packages/typediagram/package.json index d5dbd90..0e8a734 100644 --- a/packages/typediagram/package.json +++ b/packages/typediagram/package.json @@ -61,6 +61,7 @@ "scripts": { "build": "tsc -p tsconfig.build.json", "test": "vitest run --coverage", + "test:typeshed": "npm run build && node scripts/typeshed-corpus.mjs", "test:watch": "vitest", "typecheck": "tsc --noEmit -p tsconfig.build.json", "check-banned-deps": "node scripts/check-banned-deps.mjs", @@ -68,6 +69,7 @@ "check": "npm run check-banned-deps && npm run typecheck && npm run test" }, "dependencies": { + "@lezer/python": "^1.1.19", "elkjs": "^0.11.1" }, "devDependencies": { diff --git a/packages/typediagram/scripts/bundle-size.mjs b/packages/typediagram/scripts/bundle-size.mjs index 17f179c..9520167 100644 --- a/packages/typediagram/scripts/bundle-size.mjs +++ b/packages/typediagram/scripts/bundle-size.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -// [CI-BUNDLE-SIZE] Fail if the framework bundle (excluding elkjs) exceeds the -// budget. Uses esbuild to tree-shake and measure the output size. +// [CI-BUNDLE-SIZE] Fail if framework-owned code (excluding heavyweight runtime +// engines supplied as package dependencies) exceeds the budget. // Budget was 50 KB with 6 converters. Dart + Protobuf converters added // ~8-10 KB each of parser/emitter logic, so the budget was raised to 75 KB. // Tuple variants, explicit discriminants, and untagged unions pushed the @@ -8,21 +8,22 @@ // parser + emitter filtering overhead, which took the budget to 80 KB. // Semantic scalars (DateTime/Uuid/Decimal) across all 9 converters plus // codegen unknown-type validation [MODEL-CODEGEN-UNKNOWN] measure ~81 KB, -// so the current budget is 84 KB. +// so that budget was 84 KB. The Typeshed AST adapter, function declarations, +// and emitters add ~8 KB; @lezer/python remains an external runtime engine. import { build } from "esbuild"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const entry = resolve(here, "..", "src", "index.ts"); -const BUDGET_KB = 84; +const BUDGET_KB = 93; const result = await build({ entryPoints: [entry], bundle: true, format: "esm", platform: "node", - external: ["elkjs", "elkjs/*"], + external: ["elkjs", "elkjs/*", "@lezer/python", "@lezer/python/*"], write: false, minify: true, metafile: true, @@ -32,7 +33,7 @@ const bytes = result.outputFiles.reduce((sum, f) => sum + f.contents.length, 0); const kb = bytes / 1024; const rounded = Math.round(kb * 100) / 100; -console.log(`bundle size (excl. elkjs): ${rounded} KB`); +console.log(`bundle size (excl. elkjs/@lezer): ${rounded} KB`); kb > BUDGET_KB ? (console.error(`OVER BUDGET: ${rounded} KB > ${BUDGET_KB} KB`), process.exit(1)) diff --git a/packages/typediagram/scripts/typeshed-corpus.mjs b/packages/typediagram/scripts/typeshed-corpus.mjs new file mode 100644 index 0000000..07d6cad --- /dev/null +++ b/packages/typediagram/scripts/typeshed-corpus.mjs @@ -0,0 +1,58 @@ +// [TYPESHED-CORPUS] Reproducible full-checkout conversion and DSL round-trip gate. +import { readFile, readdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { typeshed } from "../dist/converters/typeshed.js"; +import { buildModel } from "../dist/model/build.js"; +import { printSource } from "../dist/model/print.js"; +import { parse } from "../dist/parser/parser.js"; + +const listStubs = async (root) => { + const trees = [join(root, "stdlib"), join(root, "stubs")]; + const entries = await Promise.all(trees.map((tree) => readdir(tree, { recursive: true }))); + return entries.flatMap((names, index) => + names.filter((name) => name.endsWith(".pyi")).map((name) => join(trees[index], name)) + ); +}; + +const roundTrips = (model) => { + const parsed = parse(printSource(model)); + return parsed.ok && buildModel(parsed.value).ok; +}; + +const auditFile = async (path) => { + const analyzed = typeshed.analyzeSource(await readFile(path, "utf8")); + const empty = + !analyzed.ok && analyzed.error.every((diagnostic) => diagnostic.message === "No typeshed declarations found"); + return !analyzed.ok + ? { kind: empty ? "empty" : "error", declarations: 0, methods: 0, roundTrip: empty } + : { + kind: "eligible", + declarations: analyzed.value.stats.declarationsConverted, + methods: analyzed.value.stats.methodsSkipped, + roundTrip: roundTrips(analyzed.value.model), + }; +}; + +const buildReport = (outcomes) => { + const eligible = outcomes.filter((outcome) => outcome.kind === "eligible"); + const roundTrips = eligible.filter((outcome) => outcome.roundTrip).length; + return { + files: outcomes.length, + eligibleFiles: eligible.length, + emptyFiles: outcomes.filter((outcome) => outcome.kind === "empty").length, + errorFiles: outcomes.filter((outcome) => outcome.kind === "error").length, + roundTrips, + eligibleFileCoverage: eligible.length === 0 ? 0 : roundTrips / eligible.length, + declarationsConverted: eligible.reduce((total, outcome) => total + outcome.declarations, 0), + methodsSkipped: eligible.reduce((total, outcome) => total + outcome.methods, 0), + }; +}; + +const rootArg = process.argv[2]; +const root = rootArg === undefined ? undefined : resolve(rootArg); +const outcomes = root === undefined ? undefined : await Promise.all((await listStubs(root)).map(auditFile)); +const report = outcomes === undefined ? undefined : buildReport(outcomes); +process.stdout.write( + report === undefined ? "usage: npm run test:typeshed -- \n" : `${JSON.stringify(report, null, 2)}\n` +); +process.exitCode = report !== undefined && report.errorFiles === 0 && report.eligibleFileCoverage === 1 ? 0 : 1; diff --git a/packages/typediagram/src/converters/emit-decls.ts b/packages/typediagram/src/converters/emit-decls.ts index 45a31ba..ae42443 100644 --- a/packages/typediagram/src/converters/emit-decls.ts +++ b/packages/typediagram/src/converters/emit-decls.ts @@ -10,7 +10,7 @@ import { type ResolvedAlias, type ResolvedRecord, type ResolvedUnion, - visibleDeclsForTarget, + visibleDataDeclsForTarget, } from "../model/types.js"; import type { Language } from "./types.js"; @@ -29,7 +29,7 @@ export interface EmitDeclsOptions { } const emitOne = (d: Model["decls"][number], emit: DeclEmitters): string[] => - d.kind === "record" ? emit.record(d) : d.kind === "union" ? emit.union(d) : emit.alias(d); + d.kind === "record" ? emit.record(d) : d.kind === "union" ? emit.union(d) : d.kind === "alias" ? emit.alias(d) : []; /** * Walk `model`'s decls visible to `language`, emit each via `emit` (a blank @@ -42,7 +42,7 @@ export const emitDecls = ( options: EmitDeclsOptions = {} ): string => { const lines = [...(options.prelude ?? [])]; - for (const d of visibleDeclsForTarget(model.decls, language)) { + for (const d of visibleDataDeclsForTarget(model.decls, language)) { lines.push(...emitOne(d, emit), ""); } const joined = lines.join("\n"); diff --git a/packages/typediagram/src/converters/fsharp.ts b/packages/typediagram/src/converters/fsharp.ts index 25af9c3..c2639e4 100644 --- a/packages/typediagram/src/converters/fsharp.ts +++ b/packages/typediagram/src/converters/fsharp.ts @@ -1,7 +1,7 @@ // [CONV-FS] F# <-> typeDiagram bidirectional converter. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; +import { type Model, type ResolvedTypeRef, visibleDataDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { mapBuiltinName, parseTypeRef, splitGenericArgs } from "./parse-typeref.js"; @@ -177,7 +177,7 @@ const mapTdToFs = (t: ResolvedTypeRef): string => { const toFSharp = (model: Model): string => { const lines: string[] = []; - for (const d of visibleDeclsForTarget(model.decls, "fsharp")) { + for (const d of visibleDataDeclsForTarget(model.decls, "fsharp")) { const genericsStr = d.generics.length > 0 ? `<${d.generics.map((g) => `'${g}`).join(", ")}>` : ""; if (d.kind === "record") { diff --git a/packages/typediagram/src/converters/go.ts b/packages/typediagram/src/converters/go.ts index 133d769..72c1aad 100644 --- a/packages/typediagram/src/converters/go.ts +++ b/packages/typediagram/src/converters/go.ts @@ -9,7 +9,7 @@ // Option <-> *T. Generics use Go 1.18+ type parameters (`[T any]`). import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import { modelReferencesType, type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; +import { modelReferencesType, type Model, type ResolvedTypeRef, visibleDataDeclsForTarget } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; import { mapBuiltinName, parseTypeRef, resolveFieldTypes } from "./parse-typeref.js"; @@ -309,7 +309,7 @@ const goGenericsInstance = (generics: string[]): string => (generics.length === const variantStructName = (unionName: string, variantName: string): string => `${unionName}${variantName}`; const toGo = (model: Model): string => { - const visible = visibleDeclsForTarget(model.decls, "go"); + const visible = visibleDataDeclsForTarget(model.decls, "go"); const lines: string[] = ["package types", ""]; if (modelReferencesType(visible, "DateTime")) { lines.push('import "time"', ""); diff --git a/packages/typediagram/src/converters/index.ts b/packages/typediagram/src/converters/index.ts index 7c5ac01..a11c46d 100644 --- a/packages/typediagram/src/converters/index.ts +++ b/packages/typediagram/src/converters/index.ts @@ -1,6 +1,7 @@ // [CONV] Barrel export for all language converters. import { typescript } from "./typescript.js"; import { python } from "./python.js"; +import { typeshed } from "./typeshed.js"; import { rust } from "./rust.js"; import { go } from "./go.js"; import { csharp } from "./csharp.js"; @@ -10,7 +11,9 @@ import { protobuf } from "./protobuf.js"; import { php } from "./php.js"; import type { Converter, Language } from "./types.js"; -export { typescript, python, rust, go, csharp, fsharp, dart, protobuf, php }; +export { typescript, python, typeshed, rust, go, csharp, fsharp, dart, protobuf, php }; +export type { TypeshedAnalysis, TypeshedConverter } from "./typeshed.js"; +export type { TypeshedStats } from "./typeshed-decls.js"; export { parseTypeRef, printTypeRef } from "./parse-typeref.js"; export type { Converter, Language } from "./types.js"; @@ -20,6 +23,7 @@ export type { Converter, Language } from "./types.js"; export const byLanguage: Record = { typescript, python, + typeshed, rust, go, csharp, diff --git a/packages/typediagram/src/converters/php.ts b/packages/typediagram/src/converters/php.ts index 6a43aab..333d126 100644 --- a/packages/typediagram/src/converters/php.ts +++ b/packages/typediagram/src/converters/php.ts @@ -1,7 +1,7 @@ // [CONV-PHP] PHP DTO <-> typeDiagram bidirectional converter. import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; -import { type Model, type ResolvedTypeRef, visibleDeclsForTarget } from "../model/types.js"; +import { type Model, type ResolvedTypeRef, visibleDataDeclsForTarget } from "../model/types.js"; import { ModelBuilder, alias, record, union } from "../model/builder.js"; import type { Converter } from "./types.js"; import { mapBuiltinName, parseTypeRef } from "./parse-typeref.js"; @@ -311,7 +311,7 @@ const renderAlias = (name: string, generics: readonly string[], target: Resolved }; const toPhp = (model: Model): string => { - const blocks = visibleDeclsForTarget(model.decls, "php").flatMap((decl) => { + const blocks = visibleDataDeclsForTarget(model.decls, "php").flatMap((decl) => { if (decl.kind === "record") { return [renderRecord(decl.name, decl.generics, decl.fields)]; } diff --git a/packages/typediagram/src/converters/python.ts b/packages/typediagram/src/converters/python.ts index d62a4cf..e8499a3 100644 --- a/packages/typediagram/src/converters/python.ts +++ b/packages/typediagram/src/converters/python.ts @@ -12,7 +12,7 @@ import type { Diagnostic } from "../parser/diagnostics.js"; import { type Result, err } from "../result.js"; import { modelReferencesType, - visibleDeclsForTarget, + visibleDataDeclsForTarget, type Model, type ResolvedDecl, type ResolvedTypeRef, @@ -464,7 +464,7 @@ const emitBareEnum = (name: string, variants: readonly { name: string }[]): stri const toPython = (model: Model, opts?: PythonOpts): string => { const pydantic = opts?.style === "pydantic"; - const decls = visibleDeclsForTarget(model.decls, "python"); + const decls = visibleDataDeclsForTarget(model.decls, "python"); const lines: string[] = pydantic ? buildPydanticImports(decls) : buildDataclassImports(decls); const emitRecord = pydantic ? emitPydanticRecord : emitDataclassRecord; diff --git a/packages/typediagram/src/converters/rust-tdbin.ts b/packages/typediagram/src/converters/rust-tdbin.ts index 12a8470..8f6c220 100644 --- a/packages/typediagram/src/converters/rust-tdbin.ts +++ b/packages/typediagram/src/converters/rust-tdbin.ts @@ -10,7 +10,7 @@ // columnar emission in rust-tdbin-columnar.ts / rust-tdbin-columns.ts, and // the layout manifest/hash in rust-tdbin-hash.ts. import type { Diagnostic } from "../parser/diagnostics.js"; -import { type Model, type ResolvedDecl, visibleDeclsForTarget } from "../model/types.js"; +import { type Model, type ResolvedDataDecl, type ResolvedDecl, visibleDataDeclsForTarget } from "../model/types.js"; import { emitRustDecl } from "./rust.js"; import { err, ok, type Result } from "../result.js"; import { classifyRecord, classifyUnion, diag, type Layout } from "./rust-tdbin-plan.js"; @@ -98,7 +98,7 @@ const declBlocks = (ctx: EmitCtx, d: ResolvedDecl): Result => { const layout: Layout = options?.layout ?? 1; - const visible = visibleDeclsForTarget(model.decls, "rust"); + const visible = visibleDataDeclsForTarget(model.decls, "rust"); const ctx: EmitCtx = { decls: model.decls, layout, @@ -118,7 +118,7 @@ export const emitRustCodec = (model: Model, options?: RustCodecOptions): Result< /** Records derive `Default` so required pointer fields can decode null as the * schema default ([TDBIN-PTR-NULL]); unions get a generated `impl Default`. */ -const deriveFor = (d: ResolvedDecl): string => +const deriveFor = (d: ResolvedDataDecl): string => d.kind === "record" ? "#[derive(Debug, Clone, PartialEq, Default)]\n" : d.kind === "union" @@ -127,7 +127,7 @@ const deriveFor = (d: ResolvedDecl): string => /** Emit one ADT type with its doc comment first, then the derive, then the body * (from the shared Rust converter) — the order rustc/clippy expect. */ -const emitTypeWithDocs = (d: ResolvedDecl): string => { +const emitTypeWithDocs = (d: ResolvedDataDecl): string => { const [doc, ...rest] = emitRustDecl(d, true); return `${doc ?? ""}\n${deriveFor(d)}${rest.join("\n")}`; }; @@ -141,6 +141,6 @@ export const generateRustModule = (model: Model, options?: RustCodecOptions): Re if (!codec.ok) { return codec; } - const types = visibleDeclsForTarget(model.decls, "rust").map(emitTypeWithDocs).join("\n"); + const types = visibleDataDeclsForTarget(model.decls, "rust").map(emitTypeWithDocs).join("\n"); return ok(`${types}\n${codec.value}\n`); }; diff --git a/packages/typediagram/src/converters/rust.ts b/packages/typediagram/src/converters/rust.ts index 8ba1427..0f5e569 100644 --- a/packages/typediagram/src/converters/rust.ts +++ b/packages/typediagram/src/converters/rust.ts @@ -5,11 +5,11 @@ import { formatVariantName, withDiscriminant } from "../variant.js"; import { isTupleVariantFields, type Model, - type ResolvedDecl, + type ResolvedDataDecl, type ResolvedField, type ResolvedTypeRef, type ResolvedVariant, - visibleDeclsForTarget, + visibleDataDeclsForTarget, } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; @@ -295,7 +295,7 @@ const emitRustVariant = (v: ResolvedVariant, docs: boolean): string => { /** [CONV-RUST-DECL] Emit one Rust type declaration (no derives). Shared by the * type converter and the TDBIN codec generator so neither duplicates it. With * `docs`, prepends `///` comments so the output is `missing_docs`-clean. */ -export const emitRustDecl = (d: ResolvedDecl, docs = false): string[] => { +export const emitRustDecl = (d: ResolvedDataDecl, docs = false): string[] => { const genericsStr = d.generics.length > 0 ? `<${d.generics.join(", ")}>` : ""; const lead = docs ? [rustDoc("", d.name, d.kind)] : []; const field = (f: ResolvedField): string => @@ -320,7 +320,7 @@ export const emitRustDecl = (d: ResolvedDecl, docs = false): string[] => { }; const toRust = (model: Model): string => - visibleDeclsForTarget(model.decls, "rust") + visibleDataDeclsForTarget(model.decls, "rust") .flatMap((d) => emitRustDecl(d)) .join("\n"); diff --git a/packages/typediagram/src/converters/types.ts b/packages/typediagram/src/converters/types.ts index dcf97cc..d5f788c 100644 --- a/packages/typediagram/src/converters/types.ts +++ b/packages/typediagram/src/converters/types.ts @@ -3,7 +3,8 @@ import type { Diagnostic } from "../parser/diagnostics.js"; import type { Result } from "../result.js"; import type { Model } from "../model/types.js"; -export type Language = "typescript" | "python" | "rust" | "go" | "csharp" | "fsharp" | "dart" | "protobuf" | "php"; +export type Language = + "typescript" | "python" | "typeshed" | "rust" | "go" | "csharp" | "fsharp" | "dart" | "protobuf" | "php"; export interface PythonOpts { readonly style?: "dataclass" | "pydantic"; diff --git a/packages/typediagram/src/converters/typescript.ts b/packages/typediagram/src/converters/typescript.ts index 0c709b4..32f3c22 100644 --- a/packages/typediagram/src/converters/typescript.ts +++ b/packages/typediagram/src/converters/typescript.ts @@ -17,7 +17,7 @@ import { type Model, type ResolvedTypeRef, type ResolvedVariant, - visibleDeclsForTarget, + visibleDataDeclsForTarget, } from "../model/types.js"; import { ModelBuilder, record, union, alias } from "../model/builder.js"; import type { Converter } from "./types.js"; @@ -279,7 +279,7 @@ const mapUntaggedVariantToTs = (variant: ResolvedVariant): string => { const toTypeScript = (model: Model): string => { const lines: string[] = []; - const decls = visibleDeclsForTarget(model.decls, "typescript"); + const decls = visibleDataDeclsForTarget(model.decls, "typescript"); for (const d of decls) { const genericsStr = d.generics.length > 0 ? `<${d.generics.join(", ")}>` : ""; diff --git a/packages/typediagram/src/converters/typeshed-decls.ts b/packages/typediagram/src/converters/typeshed-decls.ts new file mode 100644 index 0000000..d4c9927 --- /dev/null +++ b/packages/typediagram/src/converters/typeshed-decls.ts @@ -0,0 +1,325 @@ +// [TYPESHED-DECLS] Extract module declarations from the Python syntax tree. +import type { Diagnostic } from "../parser/diagnostics.js"; +import { type Result, err, ok } from "../result.js"; +import { + ModelBuilder, + alias, + functionDecl, + ref, + record, + union, + type FieldSpec, + type FunctionSignatureSpec, +} from "../model/builder.js"; +import { + walkDeclRefs, + type Model, + type ResolvedDecl, + type ResolvedFunctionSignature, + type ResolvedTypeRef, +} from "../model/types.js"; +import { isClassVar, pythonTypeRef } from "./typeshed-type-ref.js"; +import { + childOf, + childrenOf, + classMembers, + definitionOf, + descendantNames, + firstErrorNode, + firstNamedChild, + isTypeDiagramKeyword, + moduleNodes, + namedChildrenOf, + parsePythonStub, + safeTypeName, + textOf, + type PythonNode, +} from "./typeshed-tree.js"; + +export interface TypeshedStats { + declarationsSeen: number; + declarationsConverted: number; + methodsSkipped: number; +} + +export interface TypeshedAnalysis { + model: Model; + stats: TypeshedStats; +} + +interface Candidate { + decl: ResolvedDecl; + offset: number; +} + +export const analyzeTypeshedSource = (source: string): Result => { + const tree = parsePythonStub(source); + const parseError = firstErrorNode(tree.topNode); + if (parseError !== undefined) { + return err([syntaxDiagnostic(source, parseError)]); + } + const extracted = extractCandidates(moduleNodes(tree.topNode), source); + if (extracted.candidates.length === 0) { + return err([emptyDiagnostic()]); + } + const builder = new ModelBuilder(); + const decls = mergeCandidates(extracted.candidates).map((candidate) => candidate.decl); + normalizeDeclaredArities(decls).forEach((decl) => builder.add(decl)); + const built = builder.build(); + return built.ok ? ok({ model: built.value, stats: extracted.stats }) : built; +}; + +const extractCandidates = (nodes: PythonNode[], source: string) => { + const candidates = nodes.flatMap((node) => candidateFor(node, source)); + const classes = nodes.map(definitionOf).filter((node): node is PythonNode => node?.name === "ClassDefinition"); + const methodsSkipped = classes.reduce((total, node) => total + countMethods(node), 0); + const stats = { declarationsSeen: candidates.length, declarationsConverted: candidates.length, methodsSkipped }; + return { candidates, stats }; +}; + +const candidateFor = (node: PythonNode, source: string): Candidate[] => { + const definition = definitionOf(node); + return definition?.name === "ClassDefinition" + ? [classCandidate(definition, source)] + : definition?.name === "FunctionDefinition" + ? [functionCandidate(definition, source)] + : node.name === "TypeDefinition" + ? [typeAliasCandidate(node, source)] + : node.name === "AssignStatement" + ? assignmentCandidate(node, source) + : []; +}; + +const classCandidate = (node: PythonNode, source: string): Candidate => { + const name = definitionName(node, source); + const generics = definitionGenerics(node, source); + const bases = classBases(node, source); + const decl = bases.some(isEnumBase) + ? union(name, enumVariants(node, source), generics) + : record(name, classFields(node, source), generics); + return { decl, offset: node.from }; +}; + +const definitionName = (node: PythonNode, source: string) => + safeTypeName(textOf(source, childrenOf(node).find((child) => child.name === "VariableName") ?? node)); + +const definitionGenerics = (node: PythonNode, source: string) => { + const modern = childOf(node, "TypeParamList"); + if (modern !== undefined) { + return genericNames(modern, source); + } + const genericBase = classBases(node, source).find((base) => base.name === "Generic"); + return genericBase?.args.map((arg) => arg.name) ?? []; +}; + +const genericNames = (node: PythonNode, source: string) => + childrenOf(node) + .filter((child) => child.name === "TypeParam") + .flatMap((param) => descendantNames(param, "VariableName").slice(0, 1)) + .map((name) => textOf(source, name)); + +const classBases = (node: PythonNode, source: string) => { + const args = childOf(node, "ArgList"); + return args === undefined ? [] : namedChildrenOf(args).map((child) => pythonTypeRef(child, source)); +}; + +const isEnumBase = (base: ResolvedTypeRef) => ["Enum", "IntEnum", "StrEnum", "Flag", "IntFlag"].includes(base.name); + +const classFields = (node: PythonNode, source: string) => + classMembers(node).flatMap((member) => (member.name === "AssignStatement" ? annotatedField(member, source) : [])); + +const annotatedField = (node: PythonNode, source: string): FieldSpec[] => { + const name = childrenOf(node).find((child) => child.name === "VariableName"); + const type = childOf(node, "TypeDef"); + return name === undefined || type === undefined || isClassVar(firstNamedChild(type), source) + ? [] + : [{ name: safeMemberName(textOf(source, name)), type: pythonTypeRef(type, source) }]; +}; + +const enumVariants = (node: PythonNode, source: string) => + classMembers(node).flatMap((member) => { + const name = member.name === "AssignStatement" ? childOf(member, "VariableName") : undefined; + return name === undefined || textOf(source, name).startsWith("_") + ? [] + : [{ name: safeMemberName(textOf(source, name)), fields: [] }]; + }); + +const functionCandidate = (node: PythonNode, source: string): Candidate => ({ + decl: functionDecl(definitionName(node, source), [functionSignature(node, source)], definitionGenerics(node, source)), + offset: node.from, +}); + +const functionSignature = (node: PythonNode, source: string): FunctionSignatureSpec => ({ + params: functionParams(childOf(node, "ParamList"), source), + returns: pythonTypeRef(childOf(node, "TypeDef"), source), + ...(childrenOf(node).some((child) => child.name === "async") ? { async: true } : {}), +}); + +const functionParams = (params: PythonNode | undefined, source: string) => { + const children = params === undefined ? [] : childrenOf(params); + return children.flatMap((child, index) => paramFor(children, child, index, source)); +}; + +const paramFor = (siblings: PythonNode[], node: PythonNode, index: number, source: string): FieldSpec[] => { + if (node.name !== "VariableName") { + return []; + } + const typeNode = siblings.at(index + 1)?.name === "TypeDef" ? siblings.at(index + 1) : undefined; + const type = pythonTypeRef(typeNode, source); + const marker = siblings.at(index - 1)?.name; + const wrapped = marker === "**" ? mapParam(type) : marker === "*" ? listParam(type) : type; + return [{ name: safeMemberName(textOf(source, node)), type: wrapped }]; +}; + +const listParam = (type: ResolvedTypeRef) => ({ ...type, name: "List", args: [type] }); + +const mapParam = (type: ResolvedTypeRef) => ({ ...type, name: "Map", args: [ref("String"), type] }); + +const typeAliasCandidate = (node: PythonNode, source: string): Candidate => { + const named = namedChildrenOf(node); + const name = named.find((child) => child.name === "VariableName"); + const target = named.at(-1); + const generics = childOf(node, "TypeParamList"); + return { + decl: alias( + safeTypeName(textOf(source, name ?? node)), + pythonTypeRef(target, source), + generics === undefined ? [] : genericNames(generics, source) + ), + offset: node.from, + }; +}; + +const assignmentCandidate = (node: PythonNode, source: string): Candidate[] => { + const name = childOf(node, "VariableName"); + const target = assignmentTarget(node); + const annotation = childOf(node, "TypeDef"); + const explicit = pythonTypeRef(annotation, source).name === "TypeAlias"; + return name === undefined || target === undefined || (!explicit && !isImplicitAlias(name, target, source)) + ? [] + : [{ decl: alias(safeTypeName(textOf(source, name)), pythonTypeRef(target, source)), offset: node.from }]; +}; + +const assignmentTarget = (node: PythonNode) => { + const children = childrenOf(node); + const assign = children.findIndex((child) => child.name === "AssignOp"); + return children.slice(assign + 1).find((child) => !child.type.isAnonymous); +}; + +const isImplicitAlias = (name: PythonNode, target: PythonNode, source: string) => { + const value = textOf(source, name); + const allCaps = value === value.toUpperCase() && value !== value.toLowerCase(); + const callee = target.name === "CallExpression" ? textOf(source, firstNamedChild(target) ?? target) : ""; + const typeShape = ["VariableName", "MemberExpression", "BinaryExpression", "String"].includes(target.name); + return !allCaps && (typeShape || callee.endsWith("NewType") || callee.endsWith("TypeAliasType")); +}; + +const countMethods = (node: PythonNode) => + classMembers(node).filter((member) => definitionOf(member)?.name === "FunctionDefinition").length; + +const safeMemberName = (name: string) => (isTypeDiagramKeyword(name) ? `${name}_` : name); + +const mergeCandidates = (candidates: Candidate[]) => { + const merged = new Map(); + candidates + .sort((left, right) => left.offset - right.offset) + .forEach((candidate) => { + mergeCandidate(merged, candidate); + }); + return [...merged.values()]; +}; + +const mergeCandidate = (merged: Map, candidate: Candidate) => { + const current = merged.get(candidate.decl.name); + merged.set( + candidate.decl.name, + current === undefined ? candidate : { ...current, decl: mergeDecl(current.decl, candidate.decl) } + ); +}; + +const mergeDecl = (left: ResolvedDecl, right: ResolvedDecl): ResolvedDecl => + left.kind === "function" && right.kind === "function" + ? { ...left, signatures: uniqueSignatures([...left.signatures, ...right.signatures]) } + : left.kind === "record" && right.kind === "record" + ? { ...left, fields: uniqueNamed([...left.fields, ...right.fields]) } + : left.kind === "union" && right.kind === "union" + ? { ...left, variants: uniqueNamed([...left.variants, ...right.variants]) } + : left; + +const uniqueNamed = (values: T[]) => + values.filter((value, index) => values.findIndex((candidate) => candidate.name === value.name) === index); + +const uniqueSignatures = (values: ResolvedFunctionSignature[]) => + values.filter( + (value, index) => values.findIndex((candidate) => signatureKey(candidate) === signatureKey(value)) === index + ); + +const signatureKey = (signature: ResolvedFunctionSignature) => + `${signature.async === true ? "async" : "sync"}|${signature.params.map((param) => `${param.name}:${refKey(param.type)}`).join(",")}|${refKey(signature.returns)}`; + +const refKey = (type: ResolvedTypeRef): string => `${type.name}<${type.args.map(refKey).join(",")}>`; + +const normalizeDeclaredArities = (decls: ResolvedDecl[]) => { + const arities = new Map(decls.map((decl) => [decl.name, decl.generics.length])); + decls.forEach((decl) => { + walkDeclRefs(decl, (type) => { + recordArity(arities, type); + }); + }); + decls.forEach((decl) => { + extendGenerics(decl, arities.get(decl.name) ?? decl.generics.length); + }); + decls.forEach((decl) => { + walkDeclRefs(decl, (type) => { + padDeclaredRef(type, arities); + }); + }); + return decls; +}; + +const recordArity = (arities: Map, type: ResolvedTypeRef) => { + const current = arities.get(type.name); + switch (current) { + case undefined: + break; + default: + arities.set(type.name, Math.max(current, type.args.length)); + } +}; + +const extendGenerics = (decl: ResolvedDecl, arity: number) => { + while (decl.generics.length < arity) { + decl.generics.push(uniqueGeneric(decl.generics, decl.generics.length + 1)); + } +}; + +const uniqueGeneric = (generics: string[], index: number): string => { + const candidate = `_T${String(index)}`; + return generics.includes(candidate) ? uniqueGeneric(generics, index + 1) : candidate; +}; + +const padDeclaredRef = (type: ResolvedTypeRef, arities: ReadonlyMap) => { + const arity = arities.get(type.name) ?? type.args.length; + while (type.args.length < arity) { + type.args.push(ref("Any")); + } +}; + +const syntaxDiagnostic = (source: string, node: PythonNode): Diagnostic => { + const lines = source.slice(0, node.from).split("\n"); + return { + severity: "error", + message: "Invalid typeshed/Python stub syntax", + line: lines.length, + col: (lines.at(-1)?.length ?? 0) + 1, + length: Math.max(1, node.to - node.from), + }; +}; + +const emptyDiagnostic = (): Diagnostic => ({ + severity: "error", + message: "No typeshed declarations found", + line: 0, + col: 0, + length: 0, +}); diff --git a/packages/typediagram/src/converters/typeshed-tree.ts b/packages/typediagram/src/converters/typeshed-tree.ts new file mode 100644 index 0000000..4b64a87 --- /dev/null +++ b/packages/typediagram/src/converters/typeshed-tree.ts @@ -0,0 +1,76 @@ +// [TYPESHED-AST] Browser-safe Python syntax-tree helpers; no source regex parsing. +import { parser as pythonParser } from "@lezer/python"; + +export type PythonNode = ReturnType["topNode"]; + +export const parsePythonStub = (source: string) => pythonParser.parse(source); + +const TYPE_DIAGRAM_KEYWORDS = new Set(["type", "union", "untagged", "alias", "function", "async", "typeDiagram"]); + +export const isTypeDiagramKeyword = (name: string) => TYPE_DIAGRAM_KEYWORDS.has(name); + +export const safeTypeName = (name: string) => + isTypeDiagramKeyword(name) ? `${name.charAt(0).toUpperCase()}${name.slice(1)}` : name; + +export const childrenOf = (node: PythonNode) => { + const children: PythonNode[] = []; + for (let child = node.firstChild; child !== null; child = child.nextSibling) { + children.push(child); + } + return children; +}; + +const SYNTAX_NODES = new Set([":", ",", ".", "(", ")", "[", "]", "{", "}"]); + +export const namedChildrenOf = (node: PythonNode) => + childrenOf(node).filter((child) => !child.type.isAnonymous && !SYNTAX_NODES.has(child.name)); + +export const childOf = (node: PythonNode, name: string) => childrenOf(node).find((child) => child.name === name); + +export const textOf = (source: string, node: PythonNode) => source.slice(node.from, node.to); + +export const firstNamedChild = (node: PythonNode) => namedChildrenOf(node)[0]; + +export const descendantNames = (node: PythonNode, name: string) => { + const found: PythonNode[] = []; + walkNode(node, (candidate) => { + found.push(...(candidate.name === name ? [candidate] : [])); + }); + return found; +}; + +export const walkNode = (node: PythonNode, visit: (node: PythonNode) => void) => { + visit(node); + childrenOf(node).forEach((child) => { + walkNode(child, visit); + }); +}; + +export const firstErrorNode = (node: PythonNode) => { + let error: PythonNode | undefined; + walkNode(node, (candidate) => { + error = error ?? (candidate.type.isError ? candidate : undefined); + }); + return error; +}; + +export const definitionOf = (node: PythonNode) => + node.name === "DecoratedStatement" + ? namedChildrenOf(node).find((child) => child.name === "ClassDefinition" || child.name === "FunctionDefinition") + : node; + +const moduleBodyNodes = (node: PythonNode): PythonNode[] => + node.name === "IfStatement" + ? childrenOf(node) + .filter((child) => child.name === "Body") + .flatMap((body) => childrenOf(body).flatMap(moduleBodyNodes)) + : [node]; + +export const moduleNodes = (root: PythonNode) => childrenOf(root).flatMap(moduleBodyNodes); + +const classBodyNodes = (node: PythonNode): PythonNode[] => { + const body = childOf(node, "Body"); + return body === undefined ? [] : childrenOf(body).flatMap(moduleBodyNodes); +}; + +export const classMembers = (node: PythonNode) => classBodyNodes(node); diff --git a/packages/typediagram/src/converters/typeshed-type-ref.ts b/packages/typediagram/src/converters/typeshed-type-ref.ts new file mode 100644 index 0000000..9d0e73f --- /dev/null +++ b/packages/typediagram/src/converters/typeshed-type-ref.ts @@ -0,0 +1,167 @@ +// [TYPESHED-TYPES] Python annotation AST -> unresolved typeDiagram TypeRef. +import { ref } from "../model/builder.js"; +import type { ResolvedTypeRef } from "../model/types.js"; +import { + childrenOf, + firstNamedChild, + namedChildrenOf, + safeTypeName, + textOf, + type PythonNode, +} from "./typeshed-tree.js"; + +const NAME_MAP: Readonly> = { + bool: "Bool", + int: "Int", + float: "Float", + complex: "Float", + str: "String", + bytes: "Bytes", + bytearray: "Bytes", + None: "Unit", + list: "List", + List: "List", + dict: "Map", + Dict: "Map", + Mapping: "Map", + Optional: "Option", + Any: "Any", + type: "Type", +}; + +const WRAPPERS = new Set(["Annotated", "ClassVar", "Final", "Required", "NotRequired", "ReadOnly"]); + +export const pythonTypeRef = (node: PythonNode | undefined, source: string): ResolvedTypeRef => + node === undefined + ? ref("Any") + : node.name === "TypeDef" + ? pythonTypeRef(firstNamedChild(node), source) + : typeRefForNode(node, source); + +const typeRefForNode = (node: PythonNode, source: string): ResolvedTypeRef => { + switch (node.name) { + case "VariableName": + case "PropertyName": + return nameRef(textOf(source, node)); + case "None": + return ref("Unit"); + case "String": + return forwardRef(textOf(source, node)); + case "Number": + return ref("Int"); + case "Boolean": + return ref("Bool"); + case "MemberExpression": + return memberRef(node, source); + case "BinaryExpression": + return unionRef(node, source); + case "TupleExpression": + case "ListExpression": + return ref( + "List", + namedChildrenOf(node).map((child) => pythonTypeRef(child, source)) + ); + case "ArrayExpression": + return ref( + "Args", + namedChildrenOf(node).map((child) => pythonTypeRef(child, source)) + ); + case "Ellipsis": + return ref("Any"); + case "UnaryExpression": + return ref("Int"); + case "CallExpression": + return callRef(node, source); + default: + return fallbackRef(node, source); + } +}; + +const nameRef = (name: string) => { + const last = lastSegment(name); + const mapped = Object.hasOwn(NAME_MAP, last) ? NAME_MAP[last] : undefined; + return ref(mapped ?? safeTypeName(isIdentifier(last) ? last : "Any")); +}; + +const lastSegment = (name: string) => name.split(".").at(-1) ?? name; + +const isIdentifier = (name: string) => { + const first = name.charAt(0); + const starts = isLetter(first) || first === "_"; + return ( + starts && Array.from(name.slice(1)).every((char) => isLetter(char) || char === "_" || (char >= "0" && char <= "9")) + ); +}; + +const isLetter = (char: string) => (char >= "A" && char <= "Z") || (char >= "a" && char <= "z"); + +const forwardRef = (quoted: string) => { + const first = quoted.charAt(0); + const unquoted = first === "'" || first === '"' ? quoted.slice(1, -1) : quoted; + return nameRef(unquoted); +}; + +const memberRef = (node: PythonNode, source: string) => { + const children = childrenOf(node); + const bracket = children.findIndex((child) => child.name === "["); + return bracket < 0 ? nameRef(textOf(source, node)) : genericRef(children, bracket, source); +}; + +const genericRef = (children: PythonNode[], bracket: number, source: string) => { + const base = pythonTypeRef(children[0], source).name; + const args = children + .slice(bracket + 1) + .filter(isTypeChild) + .map((child) => pythonTypeRef(child, source)); + return normalizeGeneric(base, args); +}; + +const isTypeChild = (node: PythonNode) => + !node.type.isAnonymous && !["Comment", "[", "]", ",", "(", ")"].includes(node.name); + +const normalizeGeneric = (base: string, args: ResolvedTypeRef[]) => + WRAPPERS.has(base) && args[0] !== undefined + ? args[0] + : base === "Literal" + ? (args[0] ?? ref("Any")) + : base === "Union" + ? unionFromRefs(args) + : ref(Object.hasOwn(NAME_MAP, base) ? (NAME_MAP[base] ?? base) : safeTypeName(base), args); + +const unionRef = (node: PythonNode, source: string) => + unionFromRefs( + namedChildrenOf(node) + .filter((child) => child.name !== "BitOp") + .map((child) => pythonTypeRef(child, source)) + ); + +const unionFromRefs = (refs: ResolvedTypeRef[]) => { + const values = refs.flatMap((item) => (item.name === "Union" ? item.args : [item])); + const nonUnit = values.filter((item) => item.name !== "Unit"); + const value = nonUnit.length === 1 ? nonUnit[0] : ref("Union", nonUnit); + return nonUnit.length < values.length && value !== undefined ? ref("Option", [value]) : (value ?? ref("Any")); +}; + +const callRef = (node: PythonNode, source: string) => { + const named = namedChildrenOf(node); + const callee = pythonTypeRef(named[0], source).name; + const args = named.slice(1).flatMap((child) => namedChildrenOf(child)); + const typeArg = callee === "NewType" ? args[1] : callee === "TypeAliasType" ? args[1] : undefined; + return typeArg === undefined ? ref("Any") : pythonTypeRef(typeArg, source); +}; + +const fallbackRef = (node: PythonNode, source: string) => { + const child = firstNamedChild(node); + return child === undefined ? nameRef(textOf(source, node)) : pythonTypeRef(child, source); +}; + +export const isClassVar = (node: PythonNode | undefined, source: string): boolean => { + if (node === undefined) { + return false; + } + if (node.name === "TypeDef") { + return isClassVar(firstNamedChild(node), source); + } + const first = node.name === "MemberExpression" ? childrenOf(node)[0] : node; + return first !== undefined && pythonTypeRef(first, source).name === "ClassVar"; +}; diff --git a/packages/typediagram/src/converters/typeshed.ts b/packages/typediagram/src/converters/typeshed.ts new file mode 100644 index 0000000..4d0e084 --- /dev/null +++ b/packages/typediagram/src/converters/typeshed.ts @@ -0,0 +1,98 @@ +// [TYPESHED-CONVERT] Bidirectional .pyi/typeDiagram converter. +import type { Model, ResolvedDecl, ResolvedFunctionSignature, ResolvedTypeRef } from "../model/types.js"; +import { walkDeclRefs } from "../model/types.js"; +import { visibleDeclsForTarget } from "../model/types.js"; +import type { Converter } from "./types.js"; +import { analyzeTypeshedSource, type TypeshedAnalysis } from "./typeshed-decls.js"; + +export interface TypeshedConverter extends Converter { + analyzeSource: typeof analyzeTypeshedSource; +} + +const TD_TO_PYI: Readonly> = { + Bool: "bool", + Int: "int", + Float: "float", + String: "str", + Bytes: "bytes", + Unit: "None", + List: "list", + Map: "dict", + Option: "Optional", +}; + +const printRef = (type: ResolvedTypeRef): string => { + const name = TD_TO_PYI[type.name] ?? type.name; + return type.args.length === 0 ? name : `${name}[${type.args.map(printRef).join(", ")}]`; +}; + +const generics = (decl: ResolvedDecl) => (decl.generics.length === 0 ? "" : `[${decl.generics.join(", ")}]`); + +const emitRecord = (decl: Extract) => { + const body = + decl.fields.length === 0 ? [" ..."] : decl.fields.map((field) => ` ${field.name}: ${printRef(field.type)}`); + return [`class ${decl.name}${generics(decl)}:`, ...body]; +}; + +const emitUnion = (decl: Extract) => [ + `class ${decl.name}(Enum):`, + ...(decl.variants.length === 0 ? [" ..."] : decl.variants.map((variant) => ` ${variant.name} = ...`)), +]; + +const printSignature = (name: string, signature: ResolvedFunctionSignature) => { + const params = signature.params.map((param) => `${param.name}: ${printRef(param.type)}`).join(", "); + return `${signature.async === true ? "async " : ""}def ${name}(${params}) -> ${printRef(signature.returns)}: ...`; +}; + +const emitFunction = (decl: Extract) => + decl.signatures.flatMap((signature) => [ + ...(decl.signatures.length > 1 ? ["@overload"] : []), + printSignature(decl.name, signature), + ]); + +const emitDecl = (decl: ResolvedDecl) => + decl.kind === "record" + ? emitRecord(decl) + : decl.kind === "union" + ? emitUnion(decl) + : decl.kind === "alias" + ? [`type ${decl.name}${generics(decl)} = ${printRef(decl.target)}`] + : emitFunction(decl); + +const importsFor = (decls: readonly ResolvedDecl[]) => { + const names = [ + ...(decls.some((decl) => decl.kind === "union") ? ["from enum import Enum"] : []), + ...(decls.some((decl) => decl.kind === "function" && decl.signatures.length > 1) + ? ["from typing import overload"] + : []), + ...(decls.some((decl) => usesType(decl, "Option")) ? ["from typing import Optional"] : []), + ]; + return names.length === 0 ? [] : [...names, ""]; +}; + +const usesType = (decl: ResolvedDecl, name: string) => { + let found = false; + const visit = (type: ResolvedTypeRef) => { + found = found || type.name === name; + type.args.forEach(visit); + }; + walkDeclRefs(decl, visit); + return found; +}; + +const toTypeshed = (model: Model) => { + const decls = visibleDeclsForTarget(model.decls, "typeshed"); + return [...importsFor(decls), ...decls.flatMap((decl) => [...emitDecl(decl), ""])].join("\n"); +}; + +export type { TypeshedAnalysis }; + +export const typeshed: TypeshedConverter = { + language: "typeshed", + analyzeSource: analyzeTypeshedSource, + fromSource: (source) => { + const analyzed = analyzeTypeshedSource(source); + return analyzed.ok ? { ok: true, value: analyzed.value.model } : analyzed; + }, + toSource: toTypeshed, +}; diff --git a/packages/typediagram/src/editor/source-editor.ts b/packages/typediagram/src/editor/source-editor.ts index 525ce7a..eb14f1c 100644 --- a/packages/typediagram/src/editor/source-editor.ts +++ b/packages/typediagram/src/editor/source-editor.ts @@ -3,13 +3,13 @@ import { parse, formatDiagnostics } from "../parser/index.js"; import type { TypeRef } from "../parser/ast.js"; import { buildModel } from "../model/build.js"; import { printSource } from "../model/print.js"; -import type { Model, ResolvedDecl, ResolvedTypeRef } from "../model/types.js"; +import { walkDeclRefs, type Model, type ResolvedDecl, type ResolvedTypeRef } from "../model/types.js"; import { err, ok } from "../result.js"; import { runWhen, runWhenDefined } from "./effects.js"; export type EditorFailure = { message: string }; export type RowPatch = { name?: string; type?: string }; -export type DeclarationKind = ResolvedDecl["kind"]; +export type DeclarationKind = Exclude; const editorModel = (source: string) => { const parsed = parse(source); @@ -65,19 +65,13 @@ const renameRef = (ref: ResolvedTypeRef, before: string, after: string) => { }; const visitRefs = (decl: ResolvedDecl, visit: (ref: ResolvedTypeRef) => void) => { - const refs = - decl.kind === "record" - ? decl.fields.map((field) => field.type) - : decl.kind === "union" - ? decl.variants.flatMap((variant) => variant.fields.map((field) => field.type)) - : [decl.target]; - refs.forEach(visit); + walkDeclRefs(decl, visit); }; -const uniqueName = (model: Model, base: string, suffix = 1): string => { - const name = suffix === 1 ? base : `${base}${String(suffix)}`; - return findDecl(model, name) === undefined ? name : uniqueName(model, base, suffix + 1); -}; +const uniqueName = (names: readonly string[], base: string) => + Array.from({ length: names.length + 1 }, (_, index) => (index === 0 ? base : `${base}${String(index + 1)}`)).find( + (name) => !names.includes(name) + ) ?? base; const stringRef = (): ResolvedTypeRef => ({ name: "String", @@ -86,18 +80,29 @@ const stringRef = (): ResolvedTypeRef => ({ }); const newDeclaration = (model: Model, kind: DeclarationKind): ResolvedDecl => { + const names = model.decls.map((decl) => decl.name); switch (kind) { case "record": return { kind, - name: uniqueName(model, "NewRecord"), + name: uniqueName(names, "NewRecord"), generics: [], fields: [{ name: "field", type: stringRef() }], }; case "union": - return { kind, name: uniqueName(model, "NewUnion"), generics: [], variants: [{ name: "Variant", fields: [] }] }; + return { + kind, + name: uniqueName(names, "NewUnion"), + generics: [], + variants: [{ name: "Variant", fields: [] }], + }; case "alias": - return { kind, name: uniqueName(model, "NewAlias"), generics: [], target: stringRef() }; + return { + kind, + name: uniqueName(names, "NewAlias"), + generics: [], + target: stringRef(), + }; } }; @@ -246,16 +251,20 @@ export const connectDeclarations = (source: string, from: string, rowIndex: numb return !result.ok ? result : exists ? ok(printSource(result.value)) : missingDecl(decl === undefined ? from : target); }; +const newRecordRow = (names: readonly string[]) => ({ name: uniqueName(names, "field"), type: stringRef() }); + +const newUnionVariant = (names: readonly string[]) => ({ name: uniqueName(names, "Variant"), fields: [] }); + export const addRow = (source: string, declName: string) => { const result = modelOrFailure(source); const decl = result.ok ? findDecl(result.value, declName) : undefined; runWhenDefined(decl, (current) => { switch (current.kind) { case "record": - current.fields.push({ name: "field", type: targetRef("String") }); + current.fields.push(newRecordRow(current.fields.map((field) => field.name))); break; case "union": - current.variants.push({ name: "Variant", fields: [] }); + current.variants.push(newUnionVariant(current.variants.map((variant) => variant.name))); } }); return !result.ok ? result : sourceResult(result.value, decl, declName); diff --git a/packages/typediagram/src/editor/styles.ts b/packages/typediagram/src/editor/styles.ts index 72b5960..b5860f6 100644 --- a/packages/typediagram/src/editor/styles.ts +++ b/packages/typediagram/src/editor/styles.ts @@ -24,8 +24,9 @@ export const VISUAL_EDITOR_CSS = ` .td-legend-item{display:flex;align-items:center;gap:6px}.td-legend-swatch{width:3px;height:13px;background:var(--td-primary)}.td-legend-union{background:var(--td-secondary)}.td-legend-alias{background:var(--td-tertiary)} .td-inspector{position:absolute;z-index:10;top:18px;right:18px;width:min(320px,calc(100% - 36px));max-height:calc(100% - 92px);overflow:auto;padding:16px;background:rgba(34,42,61,.88);backdrop-filter:blur(12px);box-shadow:0 20px 40px rgba(0,0,0,.4);border-radius:3px;color:var(--td-text)} .td-inspector[hidden]{display:none}.td-inspector-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.td-inspector-kind{color:var(--td-primary);font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase}.td-icon-button{display:inline-grid;place-items:center;width:32px;height:32px;padding:0;border:1px solid rgba(135,146,154,.22);border-radius:4px;background:rgba(19,27,46,.72);color:var(--td-muted);cursor:pointer;transition:background 120ms ease,color 120ms ease,border-color 120ms ease}.td-icon-button svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round}.td-icon-button:hover{border-color:rgba(142,213,255,.38);background:var(--td-bright);color:var(--td-text)}.td-inspector-close{flex:0 0 auto} -.td-inspector label{display:block;margin:10px 0 5px;color:var(--td-muted);font-size:10px;letter-spacing:.08em;text-transform:uppercase}.td-inspector input{width:100%;height:34px;padding:0 9px;border:0;border-bottom:2px solid transparent;border-radius:2px;background:#131b2e;color:var(--td-text);font:12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:0}.td-inspector input:focus{border-bottom-color:var(--td-primary)} +.td-inspector label{display:block;margin:10px 0 5px;color:var(--td-muted);font-size:10px;letter-spacing:.08em;text-transform:uppercase}.td-inspector input{box-sizing:border-box;width:100%;height:34px;padding:0 9px;border:0;border-bottom:2px solid transparent;border-radius:2px;background:#131b2e;color:var(--td-text);font:12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:0}.td-inspector input:focus{border-bottom-color:var(--td-primary)} .td-inspector-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.2fr) 34px;gap:7px;align-items:end}.td-inspector-row--union{grid-template-columns:minmax(0,1fr) minmax(0,1.2fr) 34px}.td-inspector-remove{width:34px;height:34px}.td-inspector-remove:hover{border-color:rgba(255,180,171,.38);color:#ffb4ab}.td-inspector-add{width:100%;height:34px;margin-top:12px;border:0;border-radius:3px;background:#174966;color:var(--td-primary);font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}.td-inspector-add:hover{background:#205c7d}.td-inspector-delete{width:100%;height:34px;margin-top:8px;border:0;border-radius:3px;background:rgba(255,180,171,.08);color:#ffb4ab;font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}.td-inspector-delete:hover{background:rgba(255,180,171,.16)} +.td-inspector-row label{margin-bottom:0} .td-editor-toast{position:absolute;z-index:12;top:18px;left:50%;transform:translateX(-50%);padding:9px 12px;border-radius:3px;background:rgba(49,57,77,.9);box-shadow:0 14px 32px rgba(0,0,0,.32);color:#ffb4ab;font-size:11px;pointer-events:none} @media(max-width:700px){.td-canvas-legend{display:none}.td-inspector{top:10px;right:10px}.td-canvas-toolbar{left:10px;bottom:10px}.td-node-creator{left:10px;bottom:56px}.td-canvas-button{min-width:32px;padding:0 7px}} `; diff --git a/packages/typediagram/src/editor/visual-editor.ts b/packages/typediagram/src/editor/visual-editor.ts index 8dfca15..3b823cb 100644 --- a/packages/typediagram/src/editor/visual-editor.ts +++ b/packages/typediagram/src/editor/visual-editor.ts @@ -1,7 +1,7 @@ // [EDITOR-CANVAS] Direct manipulation for rendered typeDiagram SVGs. import { parse } from "../parser/index.js"; import { buildModel } from "../model/build.js"; -import type { ResolvedDecl, ResolvedTypeRef } from "../model/types.js"; +import type { ResolvedDataDecl, ResolvedTypeRef } from "../model/types.js"; import { addDeclaration, connectDeclarations } from "./source-editor.js"; import { createCanvasChrome, type CanvasChrome } from "./controls.js"; import { installVisualEditorStyles } from "./styles.js"; @@ -42,7 +42,7 @@ const snap = (value: number) => Math.round(value / 8) * 8; const refText = (ref: ResolvedTypeRef): string => ref.args.length === 0 ? ref.name : `${ref.name}<${ref.args.map(refText).join(", ")}>`; -const declRows = (decl: ResolvedDecl): EditorRow[] => { +const declRows = (decl: ResolvedDataDecl): EditorRow[] => { switch (decl.kind) { case "record": return decl.fields.map((field) => ({ name: field.name, type: refText(field.type) })); @@ -60,7 +60,9 @@ const inspectNode = (source: string, name: string): EditorNode | undefined => { const parsed = parse(source); const built = parsed.ok ? buildModel(parsed.value) : undefined; const decl = built?.ok === true ? built.value.decls.find((candidate) => candidate.name === name) : undefined; - return decl === undefined ? undefined : { name: decl.name, kind: decl.kind, rows: declRows(decl) }; + return decl === undefined || decl.kind === "function" + ? undefined + : { name: decl.name, kind: decl.kind, rows: declRows(decl) }; }; const positionRecord = (positions: Map) => Object.fromEntries(positions.entries()); @@ -122,10 +124,15 @@ const decoratePorts = (node: SVGGElement) => { const y = numberData(node, "y"); const width = numberData(node, "width"); const height = numberData(node, "height"); + const declarationPort = Array.from( + node.ownerSVGElement?.querySelectorAll("g[data-decl] > text") ?? [] + ).some((text) => text.textContent.includes("<")) + ? [circle(x + width, y + 16, "td-source-port", -1)] + : []; node.querySelectorAll(":scope > .td-port").forEach((port) => { port.remove(); }); - node.append(circle(x, y + height / 2, "td-target-port", -2), circle(x + width, y + 16, "td-source-port", -1)); + node.append(circle(x, y + height / 2, "td-target-port", -2), ...declarationPort); node.querySelectorAll("g[data-row-index]").forEach((row) => { const rowY = numberData(row, "rowY"); const rowHeight = numberData(row, "rowHeight"); @@ -383,6 +390,23 @@ const createEditorState = (options: VisualEditorOptions): EditorState => ({ polylines: new WeakMap(), }); +const trackSource = (options: VisualEditorOptions) => { + let source = options.getSource(); + return { + options: { + ...options, + getSource: () => source, + onSourceChange: (next: string) => { + source = next; + options.onSourceChange(next); + }, + }, + sync: () => { + source = options.getSource(); + }, + }; +}; + const installSvg = (svg: SVGSVGElement, context: EditorContext, container: HTMLElement) => { const tracking = { ...context, svg }; svg.querySelectorAll("g[data-decl]").forEach((node) => { @@ -447,20 +471,24 @@ const installEditorEvents = (container: HTMLElement, viewport: ViewportControls, installKeys(container, viewport, chrome); }; +const editorRefresh = (container: HTMLElement, context: EditorContext) => () => { + refreshEditor(container, context); +}; + export const createVisualEditor = (container: HTMLElement, options: VisualEditorOptions) => { installVisualEditorStyles(container.ownerDocument); container.classList.add("td-visual-editor"); + const tracked = trackSource(options); const viewport = createViewport(container); - const state = createEditorState(options); - const chrome = createEditorChrome(container, viewport, state, options); - const context: EditorContext = { state, options, chrome, viewport }; - const refresh = () => { - refreshEditor(container, context); - }; + const state = createEditorState(tracked.options); + const chrome = createEditorChrome(container, viewport, state, tracked.options); + const context: EditorContext = { state, options: tracked.options, chrome, viewport }; + const refresh = editorRefresh(container, context); installEditorEvents(container, viewport, chrome); return Object.assign(viewport, { refresh, setContent: (html: string) => { + tracked.sync(); setViewportContent(container, html); refresh(); }, diff --git a/packages/typediagram/src/layout/elk.ts b/packages/typediagram/src/layout/elk.ts index 12099b2..45611b7 100644 --- a/packages/typediagram/src/layout/elk.ts +++ b/packages/typediagram/src/layout/elk.ts @@ -6,6 +6,7 @@ import { type Edge, type Model, type ResolvedDecl, + type ResolvedFunctionSignature, type ResolvedTypeRef, } from "../model/types.js"; import { formatVariantName } from "../variant.js"; @@ -96,10 +97,15 @@ function printRefShort(t: ResolvedTypeRef): string { function declHeader(d: ResolvedDecl): string { const generics = d.generics.length === 0 ? "" : `<${d.generics.join(", ")}>`; - const tag = d.kind === "record" ? "" : d.kind === "union" ? "union " : "alias "; + const tag = d.kind === "record" ? "" : d.kind === "union" ? "union " : d.kind === "alias" ? "alias " : "function "; return `${tag}${d.name}${generics}`; } +function functionRow(signature: ResolvedFunctionSignature): string { + const params = signature.params.map((param) => rowText(param.name, param.type)).join(", "); + return `${signature.async === true ? "async " : ""}(${params}) → ${printRefShort(signature.returns)}`; +} + function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, padY: number): PreNode[] { const rowH = fontSize * 1.4 + padY * 0.5; const headerH = fontSize * 1.4 + HEADER_PAD_Y * 2; @@ -139,7 +145,7 @@ function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, pa rows.push({ text: variantHeader, y, height: rowH }); y += rowH; } - } else { + } else if (d.kind === "alias") { const text = `= ${printRefShort(d.target)}`; const m = measureText(text, fontSize); if (m.w > widest) { @@ -147,6 +153,14 @@ function buildPreNodes(decls: ResolvedDecl[], fontSize: number, padX: number, pa } rows.push({ text, y, height: rowH }); y += rowH; + } else { + for (const signature of d.signatures) { + const text = functionRow(signature); + const m = measureText(text, fontSize); + widest = Math.max(widest, m.w); + rows.push({ text, y, height: rowH }); + y += rowH; + } } const width = Math.ceil(widest + padX * 2); diff --git a/packages/typediagram/src/layout/types.ts b/packages/typediagram/src/layout/types.ts index 086cb12..5740439 100644 --- a/packages/typediagram/src/layout/types.ts +++ b/packages/typediagram/src/layout/types.ts @@ -4,7 +4,7 @@ export interface NodeBox { /** Decl name. */ declName: string; /** Decl kind for renderer dispatch. */ - declKind: "record" | "union" | "alias"; + declKind: "record" | "union" | "alias" | "function"; /** Pixel position (top-left). */ x: number; y: number; @@ -35,7 +35,7 @@ export interface EdgeRoute { points: Array<{ x: number; y: number }>; /** Display label, may be empty. */ label: string; - kind: "field" | "variantPayload" | "genericArg"; + kind: "field" | "variantPayload" | "genericArg" | "parameter" | "return"; } export interface LaidOutGraph { diff --git a/packages/typediagram/src/model/build.ts b/packages/typediagram/src/model/build.ts index 26de6ac..e038628 100644 --- a/packages/typediagram/src/model/build.ts +++ b/packages/typediagram/src/model/build.ts @@ -1,4 +1,15 @@ -import type { AliasDecl, Declaration, Diagram, Field, RecordDecl, TypeRef, UnionDecl, Variant } from "../parser/ast.js"; +import type { + AliasDecl, + Declaration, + Diagram, + Field, + FunctionDecl, + FunctionSignature, + RecordDecl, + TypeRef, + UnionDecl, + Variant, +} from "../parser/ast.js"; import { DiagnosticBag, type Diagnostic } from "../parser/diagnostics.js"; import { type Result, err, ok } from "../result.js"; import { withDiscriminant } from "../variant.js"; @@ -9,6 +20,8 @@ import { type ResolvedAlias, type ResolvedDecl, type ResolvedField, + type ResolvedFunction, + type ResolvedFunctionSignature, type ResolvedRecord, type ResolvedRefKind, type ResolvedTypeRef, @@ -71,7 +84,40 @@ function resolveDecl( if (d.kind === "union") { return resolveUnion(d, declMap, externals, generics, bag); } - return resolveAlias(d, declMap, externals, generics, bag); + return d.kind === "alias" + ? resolveAlias(d, declMap, externals, generics, bag) + : resolveFunction(d, declMap, externals, generics, bag); +} + +function resolveFunction( + d: FunctionDecl, + declMap: Map, + externals: Set, + generics: Set, + bag: DiagnosticBag +): ResolvedFunction { + return { + kind: "function", + name: d.name, + generics: [...d.generics], + signatures: d.signatures.map((signature) => resolveSignature(signature, d.name, declMap, externals, generics, bag)), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), + }; +} + +function resolveSignature( + signature: FunctionSignature, + ownerName: string, + declMap: Map, + externals: Set, + generics: Set, + bag: DiagnosticBag +): ResolvedFunctionSignature { + return { + params: signature.params.map((param) => resolveField(param, ownerName, declMap, externals, generics, bag)), + returns: resolveTypeRef(signature.returns, ownerName, declMap, externals, generics, bag), + ...(signature.async === true ? { async: true as const } : {}), + }; } function resolveRecord( diff --git a/packages/typediagram/src/model/builder.ts b/packages/typediagram/src/model/builder.ts index d546048..af4f967 100644 --- a/packages/typediagram/src/model/builder.ts +++ b/packages/typediagram/src/model/builder.ts @@ -9,6 +9,8 @@ import { type ResolvedAlias, type ResolvedDecl, type ResolvedField, + type ResolvedFunction, + type ResolvedFunctionSignature, type ResolvedRecord, type ResolvedRefKind, type ResolvedTypeRef, @@ -31,6 +33,12 @@ export interface UnionSpec { untagged?: boolean; } +export interface FunctionSignatureSpec { + params: FieldSpec[]; + returns: ResolvedTypeRef; + async?: boolean; +} + /** Build a TypeRef. Resolution is deferred to validate(). */ export function ref(name: string, args: ResolvedTypeRef[] = []): ResolvedTypeRef { return { name, args, resolution: { kind: "external" } }; @@ -54,6 +62,22 @@ export function alias(name: string, target: ResolvedTypeRef, generics: string[] return { kind: "alias", name, generics, target }; } +export function functionDecl( + name: string, + signatures: FunctionSignatureSpec[], + generics: string[] = [] +): ResolvedFunction { + return { kind: "function", name, generics, signatures: signatures.map(toFunctionSignature) }; +} + +function toFunctionSignature(signature: FunctionSignatureSpec): ResolvedFunctionSignature { + return { + params: signature.params.map(toField), + returns: signature.returns, + ...(signature.async === true ? { async: true as const } : {}), + }; +} + function toField(f: FieldSpec): ResolvedField { return { name: f.name, type: f.type }; } @@ -146,7 +170,17 @@ export function resolveResolutions(model: Model): Model { ), }; } - return { ...d, target: fixRef(d.target, generics, d.name) }; + if (d.kind === "alias") { + return { ...d, target: fixRef(d.target, generics, d.name) }; + } + return { + ...d, + signatures: d.signatures.map((signature) => ({ + ...signature, + params: signature.params.map((param) => ({ ...param, type: fixRef(param.type, generics, d.name) })), + returns: fixRef(signature.returns, generics, d.name), + })), + }; }); // rebuild edges from the resolved decls diff --git a/packages/typediagram/src/model/edges.ts b/packages/typediagram/src/model/edges.ts index a434777..fcf5ea3 100644 --- a/packages/typediagram/src/model/edges.ts +++ b/packages/typediagram/src/model/edges.ts @@ -1,4 +1,12 @@ -import type { Edge, ResolvedAlias, ResolvedDecl, ResolvedRecord, ResolvedTypeRef, ResolvedUnion } from "./types.js"; +import type { + Edge, + ResolvedAlias, + ResolvedDecl, + ResolvedFunction, + ResolvedRecord, + ResolvedTypeRef, + ResolvedUnion, +} from "./types.js"; interface DeclaredRef { declName: string; @@ -67,8 +75,38 @@ function aliasEdges(d: ResolvedAlias): Edge[] { })); } +function functionEdges(d: ResolvedFunction): Edge[] { + return d.signatures.flatMap((signature, row) => [ + ...signature.params.flatMap((param) => functionRefEdges(d.name, row, param.name, "parameter", param.type)), + ...functionRefEdges(d.name, row, "return", "return", signature.returns), + ]); +} + +function functionRefEdges( + sourceDeclName: string, + sourceRowIndex: number, + label: string, + kind: "parameter" | "return", + type: ResolvedTypeRef +): Edge[] { + return [...walkDeclaredRefs(type)].map((ref) => ({ + sourceDeclName, + sourceRowIndex, + sourceVariantFieldIndex: null, + targetDeclName: ref.declName, + label, + kind: ref.isHead ? kind : ("genericArg" as const), + })); +} + function declEdges(d: ResolvedDecl): Edge[] { - return d.kind === "record" ? recordEdges(d) : d.kind === "union" ? unionEdges(d) : aliasEdges(d); + return d.kind === "record" + ? recordEdges(d) + : d.kind === "union" + ? unionEdges(d) + : d.kind === "alias" + ? aliasEdges(d) + : functionEdges(d); } /** Collect the deduplicated edge set for a resolved decl list. Shared by build.ts and builder.ts. */ diff --git a/packages/typediagram/src/model/index.ts b/packages/typediagram/src/model/index.ts index 59068f7..a4f9401 100644 --- a/packages/typediagram/src/model/index.ts +++ b/packages/typediagram/src/model/index.ts @@ -1,11 +1,13 @@ export { buildModel, buildModelPartial } from "./build.js"; -export { ModelBuilder, alias, record, ref, resolveResolutions, union } from "./builder.js"; -export type { FieldSpec, UnionSpec, VariantSpec } from "./builder.js"; +export { ModelBuilder, alias, functionDecl, record, ref, resolveResolutions, union } from "./builder.js"; +export type { FieldSpec, FunctionSignatureSpec, UnionSpec, VariantSpec } from "./builder.js"; export { fromJSON, toJSON, SCHEMA_VERSION } from "./json.js"; export type { AliasJson, DeclJson, FieldJson, + FunctionJson, + FunctionSignatureJson, ModelJson, RecordJson, TypeRefJson, @@ -25,7 +27,10 @@ export { type Model, type ResolvedAlias, type ResolvedDecl, + type ResolvedDataDecl, type ResolvedField, + type ResolvedFunction, + type ResolvedFunctionSignature, type ResolvedRecord, type ResolvedRefKind, type ResolvedTypeRef, diff --git a/packages/typediagram/src/model/json.ts b/packages/typediagram/src/model/json.ts index e2cbe0e..26d6414 100644 --- a/packages/typediagram/src/model/json.ts +++ b/packages/typediagram/src/model/json.ts @@ -8,6 +8,8 @@ import type { ResolvedAlias, ResolvedDecl, ResolvedField, + ResolvedFunction, + ResolvedFunctionSignature, ResolvedRecord, ResolvedTypeRef, ResolvedUnion, @@ -21,7 +23,7 @@ export interface ModelJson { decls: DeclJson[]; } -export type DeclJson = RecordJson | UnionJson | AliasJson; +export type DeclJson = RecordJson | UnionJson | AliasJson | FunctionJson; /** [MODEL-JSON-SHAPE] JSON decls mirror the resolved model minus every `resolution` * field: strip it recursively from the type refs and reuse the resolved shapes. */ @@ -31,6 +33,11 @@ export type VariantJson = Omit & { fields: FieldJson[ export type RecordJson = Omit & { fields: FieldJson[] }; export type UnionJson = Omit & { variants: VariantJson[] }; export type AliasJson = Omit & { target: TypeRefJson }; +export type FunctionSignatureJson = Omit & { + params: FieldJson[]; + returns: TypeRefJson; +}; +export type FunctionJson = Omit & { signatures: FunctionSignatureJson[] }; export function toJSON(model: Model): ModelJson { return { @@ -67,13 +74,25 @@ function declToJson(d: ResolvedDecl): DeclJson { ), }; } - return { - kind: "alias", - name: d.name, - generics: [...d.generics], - target: refToJson(d.target), - ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), - }; + return d.kind === "alias" + ? { + kind: "alias", + name: d.name, + generics: [...d.generics], + target: refToJson(d.target), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), + } + : { + kind: "function", + name: d.name, + generics: [...d.generics], + signatures: d.signatures.map((signature) => ({ + params: signature.params.map((param) => ({ name: param.name, type: refToJson(param.type) })), + returns: refToJson(signature.returns), + ...(signature.async === true ? { async: true as const } : {}), + })), + ...(d.targeting === undefined ? {} : { targeting: { ...d.targeting } }), + }; } function refToJson(t: ResolvedTypeRef): TypeRefJson { @@ -120,7 +139,7 @@ function declFromJson(d: unknown): Result { if (typeof d !== "object" || d === null) { return fail("decl must be an object"); } - const x = d as Partial & Record; + const x = d as Partial & Record; if (typeof x.name !== "string") { return fail("decl.name must be a string"); } @@ -185,6 +204,22 @@ function declFromJson(d: unknown): Result { ...(targeting.value === undefined ? {} : { targeting: targeting.value }), }); } + if (x.kind === "function") { + if (!Array.isArray(x.signatures)) { + return fail("function.signatures must be an array"); + } + const targeting = targetingFromJson(x.targeting); + if (!targeting.ok) { + return targeting; + } + return ok({ + kind: "function", + name: x.name, + generics: x.generics, + signatures: x.signatures.map(signatureFromJson), + ...(targeting.value === undefined ? {} : { targeting: targeting.value }), + }); + } return fail(`unknown decl kind '${String(x.kind)}'`); } @@ -221,6 +256,14 @@ function fieldFromJson(f: FieldJson): { name: string; type: ResolvedTypeRef } { return { name: f.name, type: refFromJson(f.type) }; } +function signatureFromJson(signature: FunctionSignatureJson): ResolvedFunctionSignature { + return { + params: signature.params.map(fieldFromJson), + returns: refFromJson(signature.returns), + ...(signature.async === true ? { async: true as const } : {}), + }; +} + function refFromJson(t: TypeRefJson): ResolvedTypeRef { return { name: t.name, diff --git a/packages/typediagram/src/model/print.ts b/packages/typediagram/src/model/print.ts index df9842a..818fad3 100644 --- a/packages/typediagram/src/model/print.ts +++ b/packages/typediagram/src/model/print.ts @@ -1,4 +1,10 @@ -import { isTupleVariantFields, type Model, type ResolvedDecl, type ResolvedTypeRef } from "./types.js"; +import { + isTupleVariantFields, + type Model, + type ResolvedDecl, + type ResolvedFunctionSignature, + type ResolvedTypeRef, +} from "./types.js"; import { formatVariantName } from "../variant.js"; export function printSource(model: Model): string { @@ -53,7 +59,19 @@ function printDecl(d: ResolvedDecl): string { .join("\n"); return `${d.untagged === true ? "untagged union" : "union"} ${d.name}${generics} {\n${variants}\n}`; } - return `alias ${d.name}${generics} = ${printRef(d.target)}`; + if (d.kind === "alias") { + return `alias ${d.name}${generics} = ${printRef(d.target)}`; + } + const signatures = d.signatures.map(printSignature); + const async = signatures[0]?.startsWith("async ") === true ? "async " : ""; + return signatures.length === 1 + ? `${async}function ${d.name}${generics}${signatures[0]?.replace(/^async /, "") ?? ""}` + : `function ${d.name}${generics} {\n${signatures.map((signature) => ` ${signature}`).join("\n")}\n}`; +} + +function printSignature(signature: ResolvedFunctionSignature): string { + const params = signature.params.map((param) => `${param.name}: ${printRef(param.type)}`).join(", "); + return `${signature.async === true ? "async " : ""}(${params}) -> ${printRef(signature.returns)}`; } function printRef(t: ResolvedTypeRef): string { diff --git a/packages/typediagram/src/model/types.ts b/packages/typediagram/src/model/types.ts index 570c7fb..b5cb551 100644 --- a/packages/typediagram/src/model/types.ts +++ b/packages/typediagram/src/model/types.ts @@ -21,7 +21,8 @@ export interface Model { externals: string[]; } -export type ResolvedDecl = ResolvedRecord | ResolvedUnion | ResolvedAlias; +export type ResolvedDecl = ResolvedRecord | ResolvedUnion | ResolvedAlias | ResolvedFunction; +export type ResolvedDataDecl = Exclude; export interface DeclTargeting { targets?: string[]; @@ -53,6 +54,21 @@ export interface ResolvedAlias { targeting?: DeclTargeting; } +/** [DSL-FUNCTION] A free function; overloads share one diagram node. */ +export interface ResolvedFunction { + kind: "function"; + name: string; + generics: string[]; + signatures: ResolvedFunctionSignature[]; + targeting?: DeclTargeting; +} + +export interface ResolvedFunctionSignature { + params: ResolvedField[]; + returns: ResolvedTypeRef; + async?: true; +} + export interface ResolvedField { name: string; type: ResolvedTypeRef; @@ -82,7 +98,7 @@ export type ResolvedRefKind = | { kind: "typeParam"; owner: string } | { kind: "external" }; -export type EdgeKind = "field" | "variantPayload" | "genericArg"; +export type EdgeKind = "field" | "variantPayload" | "genericArg" | "parameter" | "return"; export interface Edge { /** Decl that owns the source row. */ @@ -113,6 +129,11 @@ export function visibleDeclsForTarget( return decls.filter((decl) => shouldEmitDeclToTarget(decl, target)); } +/** Data-only target emitters intentionally omit free-function service contracts. */ +export function visibleDataDeclsForTarget(decls: readonly ResolvedDecl[], target: string): ResolvedDataDecl[] { + return visibleDeclsForTarget(decls, target).filter((decl): decl is ResolvedDataDecl => decl.kind !== "function"); +} + /** Visit every type ref in a decl, recursing into nested generic args. */ export function walkDeclRefs(d: ResolvedDecl, visit: (t: ResolvedTypeRef) => void): void { if (d.kind === "record") { @@ -125,8 +146,15 @@ export function walkDeclRefs(d: ResolvedDecl, visit: (t: ResolvedTypeRef) => voi walkRef(f.type, visit); } } - } else { + } else if (d.kind === "alias") { walkRef(d.target, visit); + } else { + for (const signature of d.signatures) { + for (const param of signature.params) { + walkRef(param.type, visit); + } + walkRef(signature.returns, visit); + } } } diff --git a/packages/typediagram/src/parser/ast.ts b/packages/typediagram/src/parser/ast.ts index b3c4d08..7a275d9 100644 --- a/packages/typediagram/src/parser/ast.ts +++ b/packages/typediagram/src/parser/ast.ts @@ -10,7 +10,7 @@ export interface Diagram { span: Span; } -export type Declaration = RecordDecl | UnionDecl | AliasDecl; +export type Declaration = RecordDecl | UnionDecl | AliasDecl | FunctionDecl; export interface DeclTargeting { targets?: string[]; @@ -45,6 +45,23 @@ export interface AliasDecl { span: Span; } +/** [DSL-FUNCTION] A named free function with one or more overload signatures. */ +export interface FunctionDecl { + kind: "function"; + name: string; + generics: string[]; + signatures: FunctionSignature[]; + targeting?: DeclTargeting; + span: Span; +} + +export interface FunctionSignature { + params: Field[]; + returns: TypeRef; + async?: true; + span: Span; +} + export interface Field { name: string; type: TypeRef; diff --git a/packages/typediagram/src/parser/function.ts b/packages/typediagram/src/parser/function.ts new file mode 100644 index 0000000..df40a42 --- /dev/null +++ b/packages/typediagram/src/parser/function.ts @@ -0,0 +1,109 @@ +// [DSL-FUNCTION] Parser for free-function signatures and overload blocks. +import type { DeclTargeting, Field, FunctionDecl, FunctionSignature } from "./ast.js"; +import type { DiagnosticBag } from "./diagnostics.js"; +import type { Token } from "./lexer.js"; +import { + expectToken, + parseCommaSeparated, + parseGenericParams, + parseTypeRef, + spanBetween, + type TokenCursor, +} from "./parse-common.js"; + +interface FunctionHead { + start: Token; + name: Token; + generics: string[]; + async: boolean; +} + +export const parseFunction = ( + cur: TokenCursor, + diags: DiagnosticBag, + targeting?: DeclTargeting +): FunctionDecl | null => { + const head = parseHead(cur, diags); + if (head === null) { + return null; + } + const signatures = cur.peek().kind === "LBrace" ? parseOverloads(cur, diags) : oneSignature(cur, diags, head.async); + return signatures === null ? null : makeFunction(head, signatures, targeting, cur.peek()); +}; + +const parseHead = (cur: TokenCursor, diags: DiagnosticBag): FunctionHead | null => { + const start = cur.peek(); + const async = cur.eat("AsyncKw") !== null; + const keyword = expectToken(cur, diags, "FunctionKw", "'function'"); + const name = expectToken(cur, diags, "Ident", "function name"); + return keyword === null || name === null ? null : { start, name, generics: parseGenericParams(cur, diags), async }; +}; + +const oneSignature = (cur: TokenCursor, diags: DiagnosticBag, async: boolean) => { + const signature = parseSignature(cur, diags, async); + return signature === null ? null : [signature]; +}; + +const parseOverloads = (cur: TokenCursor, diags: DiagnosticBag) => { + cur.next(); + const signatures: FunctionSignature[] = []; + cur.eatNewlines(); + while (cur.peek().kind !== "RBrace" && cur.peek().kind !== "EOF") { + const async = cur.eat("AsyncKw") !== null; + const signature = parseSignature(cur, diags, async); + switch (signature) { + case null: + recoverSignature(cur); + break; + default: + signatures.push(signature); + } + cur.eat("Comma"); + cur.eatNewlines(); + } + expectToken(cur, diags, "RBrace", "'}'"); + return signatures; +}; + +const parseSignature = (cur: TokenCursor, diags: DiagnosticBag, async: boolean): FunctionSignature | null => { + const start = expectToken(cur, diags, "LParen", "'('"); + if (start === null) { + return null; + } + const params = parseCommaSeparated(cur, "RParen", () => parseParam(cur, diags)); + const close = expectToken(cur, diags, "RParen", "')'"); + const arrow = expectToken(cur, diags, "Arrow", "'->'"); + const returns = parseTypeRef(cur, diags); + return close === null || arrow === null || returns === null + ? null + : { params, returns, ...(async ? { async: true as const } : {}), span: spanBetween(start, cur.peek()) }; +}; + +const parseParam = (cur: TokenCursor, diags: DiagnosticBag): Field | null => { + const name = expectToken(cur, diags, "Ident", "parameter name"); + const colon = expectToken(cur, diags, "Colon", "':'"); + const type = parseTypeRef(cur, diags); + return name === null || colon === null || type === null + ? null + : { name: name.value, type, span: spanBetween(name, cur.peek()) }; +}; + +const recoverSignature = (cur: TokenCursor) => { + while (!["Newline", "RBrace", "EOF"].includes(cur.peek().kind)) { + cur.next(); + } +}; + +const makeFunction = ( + head: FunctionHead, + signatures: FunctionSignature[], + targeting: DeclTargeting | undefined, + end: Token +): FunctionDecl => ({ + kind: "function", + name: head.name.value, + generics: head.generics, + signatures, + ...(targeting === undefined ? {} : { targeting }), + span: spanBetween(head.start, end), +}); diff --git a/packages/typediagram/src/parser/lexer.ts b/packages/typediagram/src/parser/lexer.ts index 1ac5885..8bb7a81 100644 --- a/packages/typediagram/src/parser/lexer.ts +++ b/packages/typediagram/src/parser/lexer.ts @@ -5,6 +5,8 @@ export type TokenKind = | "UnionKw" | "UntaggedKw" | "AliasKw" + | "FunctionKw" + | "AsyncKw" | "TypeDiagramKw" | "Ident" | "Number" @@ -18,6 +20,7 @@ export type TokenKind = | "Comma" | "Colon" | "Equals" + | "Arrow" | "Newline" | "EOF"; @@ -35,6 +38,8 @@ const KEYWORDS: Record = { union: "UnionKw", untagged: "UntaggedKw", alias: "AliasKw", + function: "FunctionKw", + async: "AsyncKw", typeDiagram: "TypeDiagramKw", }; @@ -122,13 +127,21 @@ export function tokenize(source: string, diagnostics: DiagnosticBag): Token[] { end++; } const value = source.slice(i, end); - const kind = KEYWORDS[value] ?? "Ident"; + const keyword = KEYWORDS[value]; + const kind = typeof keyword === "string" ? keyword : "Ident"; emit(kind, value, startLine, startCol, startOffset); col += end - i; i = end; continue; } + if (c === "-" && source.charAt(i + 1) === ">") { + emit("Arrow", "->", line, col, i); + i += 2; + col += 2; + continue; + } + if (isDigit(c) || (c === "-" && isDigit(source.charAt(i + 1)))) { const startLine = line; const startCol = col; diff --git a/packages/typediagram/src/parser/parse-common.ts b/packages/typediagram/src/parser/parse-common.ts new file mode 100644 index 0000000..6a733b3 --- /dev/null +++ b/packages/typediagram/src/parser/parse-common.ts @@ -0,0 +1,72 @@ +// [PARSER-COMMON] Shared token parsers used by data and function declarations. +import type { Span, TypeRef } from "./ast.js"; +import type { DiagnosticBag } from "./diagnostics.js"; +import type { Token, TokenKind } from "./lexer.js"; + +export interface TokenCursor { + peek(offset?: number): Token; + next(): Token; + eat(kind: TokenKind): Token | null; + eatNewlines(): void; +} + +export const expectToken = (cur: TokenCursor, diags: DiagnosticBag, kind: TokenKind, what: string) => { + const token = cur.peek(); + return token.kind === kind + ? cur.next() + : (diags.error(`expected ${what}, got ${describeToken(token)}`, token.line, token.col, token.length || 1), null); +}; + +export const parseCommaSeparated = (cur: TokenCursor, closer: TokenKind, parseItem: () => T | null) => { + const items: T[] = []; + while (cur.peek().kind !== closer && cur.peek().kind !== "EOF") { + const item = parseItem(); + items.push(...(item === null ? [] : [item])); + if (item === null || cur.peek().kind !== "Comma") { + break; + } + cur.next(); + cur.eatNewlines(); + } + return items; +}; + +export const parseGenericParams = (cur: TokenCursor, diags: DiagnosticBag) => { + if (cur.peek().kind !== "LAngle") { + return []; + } + cur.next(); + const names = parseCommaSeparated( + cur, + "RAngle", + () => expectToken(cur, diags, "Ident", "generic parameter name")?.value ?? null + ); + expectToken(cur, diags, "RAngle", "'>'"); + return names; +}; + +export const parseTypeRef = (cur: TokenCursor, diags: DiagnosticBag): TypeRef | null => { + const name = expectToken(cur, diags, "Ident", "type name"); + if (name === null) { + return null; + } + const args = cur.peek().kind === "LAngle" ? parseTypeArgs(cur, diags) : []; + return { name: name.value, args, span: spanBetween(name, cur.peek()) }; +}; + +const parseTypeArgs = (cur: TokenCursor, diags: DiagnosticBag) => { + cur.next(); + const args = parseCommaSeparated(cur, "RAngle", () => parseTypeRef(cur, diags)); + expectToken(cur, diags, "RAngle", "'>'"); + return args; +}; + +export const spanBetween = (start: Token, end: Token): Span => ({ + line: start.line, + col: start.col, + offset: start.offset, + length: Math.max(0, end.offset + end.length - start.offset), +}); + +export const describeToken = (token: Token) => + token.kind === "EOF" ? "end of input" : token.kind === "Newline" ? "newline" : `${token.kind} "${token.value}"`; diff --git a/packages/typediagram/src/parser/parser.ts b/packages/typediagram/src/parser/parser.ts index 06e24ea..f30f30a 100644 --- a/packages/typediagram/src/parser/parser.ts +++ b/packages/typediagram/src/parser/parser.ts @@ -5,7 +5,6 @@ import type { Diagram, Field, RecordDecl, - Span, TypeRef, UnionDecl, Variant, @@ -16,6 +15,15 @@ import { tokenize } from "./lexer.js"; import { type Result, err, ok } from "../result.js"; import type { Diagnostic } from "./diagnostics.js"; import { withDiscriminant } from "../variant.js"; +import { parseFunction } from "./function.js"; +import { + describeToken, + expectToken, + parseCommaSeparated, + parseGenericParams, + parseTypeRef, + spanBetween, +} from "./parse-common.js"; /** The keyword, name token, and generic parameters shared by every decl form. */ interface DeclHeader { @@ -105,14 +113,20 @@ class Parser { if (t.kind === "UntaggedKw") { const next = this.cur.peek(1); if (next.kind !== "UnionKw") { - return this.errorAndRecover(`expected 'union' after 'untagged', got ${describe(next)}`, next); + return this.errorAndRecover(`expected 'union' after 'untagged', got ${describeToken(next)}`, next); } return this.parseUnion(true, targeting); } if (t.kind === "AliasKw") { return this.parseAlias(targeting); } - return this.errorAndRecover(`expected 'type', 'union', 'untagged union', or 'alias', got ${describe(t)}`, t); + if (t.kind === "FunctionKw" || t.kind === "AsyncKw") { + return parseFunction(this.cur, this.diags, targeting); + } + return this.errorAndRecover( + `expected 'type', 'union', 'untagged union', 'alias', or 'function', got ${describeToken(t)}`, + t + ); } /** Emit an error diagnostic anchored at `tok`, recover to the next decl, return null. */ @@ -229,33 +243,11 @@ class Parser { } private parseCommaSeparated(closer: TokenKind, parseItem: () => T | null): T[] { - const items: T[] = []; - while (this.cur.peek().kind !== closer && this.cur.peek().kind !== "EOF") { - const item = parseItem(); - if (item === null) { - break; - } - items.push(item); - if (this.cur.peek().kind === "Comma") { - this.cur.next(); - } else { - break; - } - } - return items; + return parseCommaSeparated(this.cur, closer, parseItem); } private parseGenericParams(): string[] { - if (this.cur.peek().kind !== "LAngle") { - return []; - } - this.cur.next(); - const names = this.parseCommaSeparated( - "RAngle", - () => this.expect("Ident", "generic parameter name")?.value ?? null - ); - this.expect("RAngle", "'>'"); - return names; + return parseGenericParams(this.cur, this.diags); } private parseBraceList(parseItem: () => T | null): T[] { @@ -367,30 +359,11 @@ class Parser { } private parseTypeRef(): TypeRef | null { - const nameTok = this.expect("Ident", "type name"); - if (nameTok === null) { - return null; - } - let args: TypeRef[] = []; - if (this.cur.peek().kind === "LAngle") { - this.cur.next(); - args = this.parseCommaSeparated("RAngle", () => this.parseTypeRef()); - this.expect("RAngle", "'>'"); - } - return { - name: nameTok.value, - args, - span: spanBetween(nameTok, this.cur.peek()), - }; + return parseTypeRef(this.cur, this.diags); } private expect(kind: TokenKind, what: string): Token | null { - const t = this.cur.peek(); - if (t.kind === kind) { - return this.cur.next(); - } - this.diags.error(`expected ${what}, got ${describe(t)}`, t.line, t.col, t.length || 1); - return null; + return expectToken(this.cur, this.diags, kind, what); } private eatSeparator(): void { @@ -426,7 +399,10 @@ class Parser { return; } depth--; - } else if (depth === 0 && (t.kind === "TypeKw" || t.kind === "UnionKw" || t.kind === "AliasKw")) { + } else if ( + depth === 0 && + (t.kind === "TypeKw" || t.kind === "UnionKw" || t.kind === "AliasKw" || t.kind === "FunctionKw") + ) { return; } this.cur.next(); @@ -434,25 +410,6 @@ class Parser { } } -function spanBetween(a: Token, b: Token): Span { - return { - line: a.line, - col: a.col, - offset: a.offset, - length: Math.max(0, b.offset + b.length - a.offset), - }; -} - -function describe(t: Token): string { - if (t.kind === "EOF") { - return "end of input"; - } - if (t.kind === "Newline") { - return "newline"; - } - return `${t.kind} "${t.value}"`; -} - export function parsePartial(source: string): { ast: Diagram; diagnostics: Diagnostic[] } { const bag = new DiagnosticBag(); const tokens = tokenize(source, bag); diff --git a/packages/typediagram/src/render-svg/render.ts b/packages/typediagram/src/render-svg/render.ts index 3817332..d4abb33 100644 --- a/packages/typediagram/src/render-svg/render.ts +++ b/packages/typediagram/src/render-svg/render.ts @@ -115,7 +115,11 @@ function applyPostHook(body: SafeSvg, ctx: RenderCtx): SafeSvg { } function accentFor(declKind: NodeBox["declKind"], theme: Theme): string { - return declKind === "union" ? theme.unionAccent : declKind === "alias" ? theme.aliasAccent : theme.recordAccent; + return declKind === "union" + ? theme.unionAccent + : declKind === "alias" || declKind === "function" + ? theme.aliasAccent + : theme.recordAccent; } interface NodeGeometry { diff --git a/packages/typediagram/test/converters/typeshed.test.ts b/packages/typediagram/test/converters/typeshed.test.ts new file mode 100644 index 0000000..450680a --- /dev/null +++ b/packages/typediagram/test/converters/typeshed.test.ts @@ -0,0 +1,234 @@ +// [TYPESHED-CONVERT-TEST] Realistic .pyi constructs -> typeDiagram model. +import { describe, expect, it } from "vitest"; +import { typeshed } from "../../src/converters/index.js"; +import { printSource } from "../../src/model/index.js"; +import { unwrap } from "../helpers.js"; +import { modelFromTd } from "../helpers.js"; + +const STUB_SOURCE = ` +from dataclasses import dataclass +from enum import IntEnum +from typing import ClassVar, Generic, NamedTuple, Protocol, TypeAlias, TypedDict, TypeVar, overload + +T = TypeVar("T") + +class Service(Protocol): + endpoint: str + def connect(self, timeout: float = ...) -> bool: ... + +@dataclass(frozen=True) +class Payload(Generic[T]): + value: T + tags: list[str] + cache: ClassVar[dict[str, T]] + def encode(self) -> bytes: ... + +class Movie(TypedDict, total=False): + title: str + year: int + +class Coordinates(NamedTuple): + x: float + y: float + +class Status(IntEnum): + READY = 1 + DONE = 2 + +PathList: TypeAlias = list[str] | None +type Lookup[K, V] = dict[K, V] + +@overload +def read(path: str) -> bytes: ... +@overload +def read(path: bytes, limit: int = ...) -> bytes: ... + +if sys.version_info >= (3, 11): + async def refresh(payload: Payload[str]) -> Movie: ... +`; + +describe("[TYPESHED-CONVERT] typeshed -> typeDiagram", () => { + it("retains classes, dataclass fields, aliases, module functions, and overloads while excluding methods", () => { + const analyzed = unwrap(typeshed.analyzeSource(STUB_SOURCE)); + const { model, stats } = analyzed; + expect(stats).toEqual({ declarationsSeen: 10, declarationsConverted: 10, methodsSkipped: 2 }); + expect(model.decls.map((decl) => decl.name)).toEqual([ + "Service", + "Payload", + "Movie", + "Coordinates", + "Status", + "PathList", + "Lookup", + "read", + "refresh", + ]); + + const service = model.decls.find((decl) => decl.name === "Service"); + const payload = model.decls.find((decl) => decl.name === "Payload"); + const status = model.decls.find((decl) => decl.name === "Status"); + const read = model.decls.find((decl) => decl.name === "read"); + expect(service?.kind === "record" ? service.fields.map((field) => field.name) : []).toEqual(["endpoint"]); + expect(payload?.kind === "record" ? payload.generics : []).toEqual(["T"]); + expect(payload?.kind === "record" ? payload.fields.map((field) => field.name) : []).toEqual(["value", "tags"]); + expect(status?.kind === "union" ? status.variants.map((variant) => variant.name) : []).toEqual(["READY", "DONE"]); + expect(read?.kind === "function" ? read.signatures : []).toHaveLength(2); + expect(read?.kind === "function" ? read.signatures[1]?.params[1]?.type.name : undefined).toBe("Int"); + expect(model.decls.some((decl) => decl.name === "connect" || decl.name === "encode")).toBe(false); + + const td = printSource(model); + expect(td).toContain("type Payload"); + expect(td).toContain("alias PathList = Option>"); + expect(td).toContain("alias Lookup = Map"); + expect(td).toContain("function read {"); + expect(td).toContain("async function refresh(payload: Payload) -> Movie"); + expect(unwrap(typeshed.fromSource(STUB_SOURCE)).decls).toHaveLength(9); + }); + + it("reports malformed stubs and source with no type declarations as Result errors", () => { + expect(typeshed.fromSource("def broken(: ...").ok).toBe(false); + expect(typeshed.fromSource("VALUE = 42\n").ok).toBe(false); + }); + + it("normalizes advanced annotations, variadics, aliases, conditional duplicates, keywords, and generic arity", () => { + const source = ` +import typing +from typing import Annotated, Callable, ClassVar, Final, Generic, Literal, Mapping, NewType, TypeAlias, TypeVar, Union +from typing_extensions import TypeAliasType + +_T2 = TypeVar("_T2") + +if sys.platform == "win32": + class Box(Generic[_T2]): + first: _T2 + type: type[object] + ignored: ClassVar[str] +else: + class Box(Generic[_T2]): + second: str + +class KeywordNames: + alias: str + function: bytes + constant = 3 + +class Mode(Flag): + _PRIVATE = 0 + READ = 1 + +AliasNew = NewType("AliasNew", int) +AliasOther = TypeAliasType("AliasOther", str) +ForwardAlias = "Box" +UPPER = str +factory_value = Factory() +Explicit: TypeAlias = Union[int, str] +type Pair[A, B] = tuple[A, B] + +def use(box: Box, expanded: Box[int, str], *args: str, **kwargs: int) -> None: ... +def untyped(value) -> typing.Any: ... + +class Exotic: + qualified: typing.Any + nothing: None + truth: Literal[True] + negative: Literal[-1] + optional: int | None + choice: int | str | None + callback: Callable[[str, int], bytes] + variadic: Callable[..., Any] + tupled: tuple[str, ...] + wrapped: Annotated[str, "metadata"] + mapping: Mapping[str, int] + raw: bytearray + complex_forward: "list[str]" + generated: Factory() +`; + const { model, stats } = unwrap(typeshed.analyzeSource(source)); + const box = model.decls.find((decl) => decl.name === "Box"); + const keywords = model.decls.find((decl) => decl.name === "KeywordNames"); + const mode = model.decls.find((decl) => decl.name === "Mode"); + const use = model.decls.find((decl) => decl.name === "use"); + const exotic = model.decls.find((decl) => decl.name === "Exotic"); + expect(stats.declarationsSeen).toBe(12); + expect(box?.kind === "record" ? box.generics : []).toEqual(["_T2", "_T3"]); + expect(box?.kind === "record" ? box.fields.map((field) => field.name) : []).toEqual(["first", "type_", "second"]); + expect(keywords?.kind === "record" ? keywords.fields.map((field) => field.name) : []).toEqual([ + "alias_", + "function_", + ]); + expect(mode?.kind === "union" ? mode.variants.map((variant) => variant.name) : []).toEqual(["READ"]); + expect(use?.kind === "function" ? use.signatures[0]?.params.map((param) => param.type.name) : []).toEqual([ + "Box", + "Box", + "List", + "Map", + ]); + expect(use?.kind === "function" ? use.signatures[0]?.params[0]?.type.args.map((arg) => arg.name) : []).toEqual([ + "Any", + "Any", + ]); + expect(model.decls.some((decl) => decl.name === "UPPER" || decl.name === "factory_value")).toBe(false); + expect(model.decls.find((decl) => decl.name === "AliasNew")?.kind).toBe("alias"); + expect(model.decls.find((decl) => decl.name === "AliasOther")?.kind).toBe("alias"); + expect(model.decls.find((decl) => decl.name === "ForwardAlias")?.kind).toBe("alias"); + expect(exotic?.kind === "record" ? exotic.fields.map((field) => field.type.name) : []).toEqual([ + "Any", + "Unit", + "Bool", + "Int", + "Option", + "Option", + "Callable", + "Callable", + "tuple", + "String", + "Map", + "Bytes", + "Any", + "Any", + ]); + expect(printSource(model)).toContain("function use(box: Box"); + }); + + it("emits complete .pyi source for records, unions, aliases, functions, overloads, async, and imports", () => { + const model = modelFromTd(` +type Empty {} +type Box { value: T } +union EmptyState {} +union State { Ready Busy } +alias Name = String +function ping(value: Int) -> Bool +function fetch { + (name: String) -> Bytes + async (name: String, fallback: Option) -> Bytes +} +@skipTargets(typeshed) +type Hidden { value: String } +`); + const pyi = typeshed.toSource(model); + expect(pyi).toContain("from enum import Enum"); + expect(pyi).toContain("from typing import overload"); + expect(pyi).toContain("from typing import Optional"); + expect(pyi).toContain("class Empty:\n ..."); + expect(pyi).toContain("class Box[T]:\n value: T"); + expect(pyi).toContain("class EmptyState(Enum):\n ..."); + expect(pyi).toContain("class State(Enum):\n Ready = ...\n Busy = ..."); + expect(pyi).toContain("type Name = str"); + expect(pyi).toContain("def ping(value: int) -> bool: ..."); + expect(pyi).toContain("@overload\ndef fetch(name: str) -> bytes: ..."); + expect(pyi).toContain("async def fetch(name: str, fallback: Optional[bytes]) -> bytes: ..."); + expect(pyi).not.toContain("Hidden"); + expect(unwrap(typeshed.fromSource(pyi)).decls.map((decl) => decl.name)).toEqual([ + "Empty", + "Box", + "EmptyState", + "State", + "Name", + "ping", + "fetch", + ]); + + const noImports = typeshed.toSource(modelFromTd("type One { value: String }")); + expect(noImports.startsWith("class One:")).toBe(true); + }); +}); diff --git a/packages/typediagram/test/function.integration.test.ts b/packages/typediagram/test/function.integration.test.ts new file mode 100644 index 0000000..6288230 --- /dev/null +++ b/packages/typediagram/test/function.integration.test.ts @@ -0,0 +1,80 @@ +// [DSL-FUNCTION-TEST] Function declarations survive every public model/render layer. +import { describe, expect, it } from "vitest"; +import { renderToString } from "../src/index.js"; +import { buildModel, fromJSON, printSource, toJSON } from "../src/model/index.js"; +import { parse } from "../src/parser/index.js"; +import { unwrap } from "./helpers.js"; + +describe("[DSL-FUNCTION] function declarations", () => { + it("parses, resolves, prints, serializes, lays out, and renders signatures and overloads", async () => { + const source = `typeDiagram + +type Request { id: Int } +type Response { body: Bytes } + +function submit(request: Request, fallback: Option) -> Response + +function read { + (request: Request) -> Bytes + async (request: Request, limit: Int) -> Response +} +`; + const ast = unwrap(parse(source)); + expect(ast.decls.map((decl) => decl.kind)).toEqual(["record", "record", "function", "function"]); + + const model = unwrap(buildModel(ast)); + const submit = model.decls.find((decl) => decl.name === "submit"); + const read = model.decls.find((decl) => decl.name === "read"); + expect(submit?.kind).toBe("function"); + expect(submit?.generics).toEqual(["T"]); + expect(submit?.kind === "function" ? submit.signatures[0]?.params.map((param) => param.name) : []).toEqual([ + "request", + "fallback", + ]); + expect(read?.kind === "function" ? read.signatures : []).toHaveLength(2); + expect(read?.kind === "function" ? read.signatures[1]?.async : undefined).toBe(true); + expect(model.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sourceDeclName: "submit", targetDeclName: "Request", kind: "parameter" }), + expect.objectContaining({ sourceDeclName: "submit", targetDeclName: "Response", kind: "return" }), + expect.objectContaining({ sourceDeclName: "read", targetDeclName: "Response", kind: "return" }), + ]) + ); + + const printed = printSource(model); + expect(printed).toContain("function submit(request: Request, fallback: Option) -> Response"); + expect(printed).toContain("function read {\n (request: Request) -> Bytes"); + expect(printed).toContain(" async (request: Request, limit: Int) -> Response"); + expect(toJSON(unwrap(fromJSON(toJSON(model))))).toEqual(toJSON(model)); + + const svg = unwrap(await renderToString(printed)); + expect(svg).toContain('data-decl="submit"'); + expect(svg).toContain('data-kind="function"'); + expect(svg).toContain("function submit<T>"); + expect(svg).toContain("request: Request"); + expect(svg).toContain("async (request: Request, limit: Int) → Response"); + }); + + it("returns diagnostics and recovers across malformed function heads, parameters, returns, and overload rows", () => { + const malformed = [ + "async type Nope {}", + "function", + "function missing(", + "function missingArrow(value: Int) String", + "function bad { nope\n (value Int) -> Unit\n () -> Unit }\ntype Recovered { ok: Bool }", + ]; + const results = malformed.map((source) => parse(source)); + expect(results.every((result) => !result.ok)).toBe(true); + expect( + results.flatMap((result) => (result.ok ? [] : result.error)).map((diagnostic) => diagnostic.message) + ).toEqual( + expect.arrayContaining([ + expect.stringContaining("expected 'function'"), + expect.stringContaining("function name"), + expect.stringContaining("expected ')'"), + expect.stringContaining("expected '->'"), + expect.stringContaining("expected '('"), + ]) + ); + }); +}); diff --git a/packages/typediagram/test/visual-editor.test.ts b/packages/typediagram/test/visual-editor.test.ts index 9e3b18f..a59fe35 100644 --- a/packages/typediagram/test/visual-editor.test.ts +++ b/packages/typediagram/test/visual-editor.test.ts @@ -366,7 +366,9 @@ describe("[EDITOR-CANVAS] shared web and VS Code interaction runtime", () => { expect(getComputedStyle(row as HTMLElement).alignItems).toBe("end"); expect(getComputedStyle(input as HTMLInputElement).boxSizing).toBe("border-box"); expect(getComputedStyle(label as HTMLLabelElement).marginBottom).toBe("0px"); - expect(getComputedStyle(close as HTMLButtonElement).height).toBe(getComputedStyle(input as HTMLInputElement).height); + expect(getComputedStyle(close as HTMLButtonElement).height).toBe( + getComputedStyle(input as HTMLInputElement).height + ); expect(getComputedStyle(close as HTMLButtonElement).width).toBe(getComputedStyle(input as HTMLInputElement).height); }); @@ -476,8 +478,8 @@ describe("[EDITOR-CANVAS] shared web and VS Code interaction runtime", () => { addRow?.click(); expect(changes).toHaveLength(2); - expect(source.match(/^ field: String$/gm)).toHaveLength(1); - expect(source.match(/^ field2: String$/gm)).toHaveLength(1); + expect(source.match(/^ {2}field: String$/gm)).toHaveLength(1); + expect(source.match(/^ {2}field2: String$/gm)).toHaveLength(1); expect(source).toContain("active: Bool\n field: String\n field2: String"); expect(await render(source)).toContain('data-decl="Account"'); }); diff --git a/packages/vscode/src/preview-panel.ts b/packages/vscode/src/preview-panel.ts index 6bdf872..c9c9a4d 100644 --- a/packages/vscode/src/preview-panel.ts +++ b/packages/vscode/src/preview-panel.ts @@ -35,10 +35,15 @@ const webviewMessage = (message: unknown): EditorMessage | OpenSourceMessage | u } }; -const applyEditorSource = async (doc: vscode.TextDocument, source: string) => { +const sourceEnd = (source: string) => { + const line = source.split("\n").length - 1; + const character = source.length - source.lastIndexOf("\n") - 1; + return new vscode.Position(line, character); +}; + +const applyEditorSource = async (doc: vscode.TextDocument, previous: string, source: string) => { const edit = new vscode.WorkspaceEdit(); - const end = doc.positionAt(doc.getText().length); - edit.replace(doc.uri, new vscode.Range(new vscode.Position(0, 0), end), source); + edit.replace(doc.uri, new vscode.Range(new vscode.Position(0, 0), sourceEnd(previous)), source); await vscode.workspace.applyEdit(edit); }; @@ -54,10 +59,12 @@ export const consumeEditorSource = (panel: vscode.WebviewPanel, source: string) }; const queueEditorSource = (panel: vscode.WebviewPanel, doc: vscode.TextDocument, source: string) => { + const sources = pendingSources.get(panel) ?? []; + const current = sources.at(-1) ?? doc.getText(); + pendingSources.set(panel, [...sources, source]); const previous = pendingEdits.get(panel) ?? Promise.resolve(); const next = previous.then(async () => { - pendingSources.set(panel, [...(pendingSources.get(panel) ?? []), source]); - await applyEditorSource(doc, source); + await applyEditorSource(doc, current, source); consumeEditorSource(panel, source); }); pendingEdits.set(panel, next); diff --git a/packages/vscode/src/webview/interaction-test.ts b/packages/vscode/src/webview/interaction-test.ts index 31684ba..bd1009a 100644 --- a/packages/vscode/src/webview/interaction-test.ts +++ b/packages/vscode/src/webview/interaction-test.ts @@ -62,7 +62,7 @@ const canvasChrome = (audit: Audit) => { (kind) => preview.querySelectorAll(`svg [data-kind="${kind}"]`).length > 0 ); const toolbarLabel = preview.querySelector(".td-canvas-toolbar")?.getAttribute("aria-label") === "Canvas controls"; - const legendText = preview.querySelector(".td-canvas-legend")?.textContent?.replace(/\s/g, "") === "TypeUnionAlias"; + const legendText = preview.querySelector(".td-canvas-legend")?.textContent.replace(/\s/g, "") === "TypeUnionAlias"; const chromeInitiallyClosed = preview.querySelector(".td-node-creator")?.hidden === true && preview.querySelector(".td-inspector")?.hidden === true; @@ -109,7 +109,7 @@ const recordEdits = async (audit: Audit) => { const recordRenamed = audit.context.getSource().includes("type ConversationRequest"); const recordFieldEdited = audit.context.getSource().includes("prompt: Option"); const recordRendered = - declaration(audit, "ConversationRequest")?.textContent?.includes("prompt: Option") === true; + declaration(audit, "ConversationRequest")?.textContent.includes("prompt: Option") === true; const recordEdge = audit.context.preview.querySelector('[data-edge][data-source="ConversationRequest"][data-target="Option"]') !== null; @@ -144,7 +144,7 @@ const unionEdit = async (audit: Audit) => { await changeInput(audit, 4, "TextPart"); const unionVariantRenamed = audit.context.getSource().includes("ScalarValue {"); const unionPayloadEdited = audit.context.getSource().includes("ScalarValue { value: TextPart }"); - const unionRendered = declaration(audit, "ToolResultContent")?.textContent?.includes("ScalarValue") === true; + const unionRendered = declaration(audit, "ToolResultContent")?.textContent.includes("ScalarValue") === true; const unionEdge = audit.context.preview.querySelector('[data-edge][data-source="ToolResultContent"][data-target="TextPart"]') !== null; @@ -171,7 +171,7 @@ const aliasEdit = async (audit: Audit) => { audit.context.preview.querySelector(".td-inspector-remove") === null; await changeInput(audit, 2, "Option"); const aliasTargetEdited = audit.context.getSource().includes("alias Email = Option"); - const aliasRendered = declaration(audit, "Email")?.textContent?.includes("Option") === true; + const aliasRendered = declaration(audit, "Email")?.textContent.includes("Option") === true; const aliasEdge = audit.context.preview.querySelector('[data-edge][data-source="Email"][data-target="Option"]') !== null; Object.assign(audit.evidence, { @@ -376,7 +376,7 @@ const drawRelationship = async (audit: Audit) => { const connectionPreview = await dragConnection(audit, "ConversationRequest", 0, "TextPart"); const relationshipSource = audit.context.getSource().includes("prompt: TextPart"); const relationshipRendered = - declaration(audit, "ConversationRequest")?.textContent?.includes("prompt: TextPart") === true; + declaration(audit, "ConversationRequest")?.textContent.includes("prompt: TextPart") === true; const relationshipEdge = audit.context.preview.querySelector('[data-edge][data-source="ConversationRequest"][data-target="TextPart"]') !== null; @@ -401,7 +401,7 @@ const genericRelationshipRecovery = async (audit: Audit) => { const genericTargetRendered = declaration(audit, "Option") !== undefined; const genericEdge = audit.context.preview.querySelector('[data-edge][data-source="ToolResult"][data-target="Option"]') !== null; - const genericRendered = declaration(audit, "ToolResult")?.textContent?.includes("option: Option") === true; + const genericRendered = declaration(audit, "ToolResult")?.textContent.includes("option: Option") === true; const fatalErrorHidden = audit.context.preview.ownerDocument.getElementById("error-panel")?.hidden === true; const recoveryActions = audit.context.preview.ownerDocument.querySelectorAll("#error-panel .error-action").length; Object.assign(audit.evidence, { diff --git a/packages/web/e2e/converter.spec.ts b/packages/web/e2e/converter.spec.ts index da31c41..c5bf0c2 100644 --- a/packages/web/e2e/converter.spec.ts +++ b/packages/web/e2e/converter.spec.ts @@ -55,10 +55,10 @@ test.describe("[WEB-CONVERTER]", () => { test("initial layout: tabs, labels, sample text, panels", async ({ page }) => { const labels = await page.$$eval(".conv-lang-tab", (tabs) => tabs.map((t) => t.textContent)); - for (const name of ["TypeScript", "Rust", "Python", "Go", "C#", "F#", "Dart", "Protobuf", "PHP"]) { + for (const name of ["TypeScript", "Rust", "Python", "Typeshed", "Go", "C#", "F#", "Dart", "Protobuf", "PHP"]) { expect(labels).toContain(name); } - expect(labels.length).toBe(9); + expect(labels.length).toBe(10); expect(await page.$eval(".conv-lang-tab--active", (el) => el.textContent)).toBe("TypeScript"); expect(await page.$eval("#conv-left-label", (el) => el.textContent)).toBe("typediagram"); expect(await page.$eval("#conv-right-label", (el) => el.textContent)).toBe("typescript"); @@ -121,6 +121,32 @@ test.describe("[WEB-CONVERTER]", () => { expect(preview).not.toContain("No Rust type definitions found"); }); + test("Typeshed + flip imports dataclasses and module functions but excludes methods", async ({ page }) => { + await page.locator('[data-lang="typeshed"]').click(); + await waitForTdCode(page, "class ChatRequest"); + await page.locator("#conv-flip").click(); + await waitForEditorContains(page, "class ChatRequest"); + await page.locator("#conv-editor").fill(`from dataclasses import dataclass + +@dataclass +class Payload: + value: str + def encode(self) -> bytes: ... + +def fetch(payload: Payload) -> bytes: ... +`); + await waitForTdCode(page, "function fetch"); + + const td = await page.$eval("#conv-td code", (el) => el.textContent); + const preview = await page.$eval("#conv-preview", (el) => el.innerHTML); + expect(td).toContain("type Payload"); + expect(td).toContain("value: String"); + expect(td).toContain("function fetch(payload: Payload) -> Bytes"); + expect(td).not.toContain("encode"); + expect(preview).toContain('data-kind="function"'); + expect(preview).toContain('data-decl="Payload"'); + }); + test("flipping back restores the last known TD source", async ({ page }) => { const original = await page.$eval("#conv-editor", (el) => (el as HTMLTextAreaElement).value); await page.locator("#conv-flip").click(); diff --git a/packages/web/e2e/visual-editor.spec.ts b/packages/web/e2e/visual-editor.spec.ts index a7abbaf..4c62211 100644 --- a/packages/web/e2e/visual-editor.spec.ts +++ b/packages/web/e2e/visual-editor.spec.ts @@ -142,14 +142,14 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { await expect(page.locator(".td-inspector-row")).toHaveCount(unionRowsBeforeAdd + 1); await expect(page.locator(".td-inspector-row").last().locator("input").first()).toHaveValue("Variant"); await expect(page.locator(".td-inspector-row").last().locator("input").nth(1)).toHaveValue(""); - await expect(page.locator("#editor")).toHaveValue(/ Variant\n/); + await expect(page.locator("#editor")).toHaveValue(/ {2}Variant\n/); await page.locator(".td-inspector-row").last().locator("input").first().fill("Cancelled"); await page.locator(".td-inspector-row").last().locator("input").first().blur(); - await expect(page.locator("#editor")).toHaveValue(/ Cancelled\n/); + await expect(page.locator("#editor")).toHaveValue(/ {2}Cancelled\n/); await expect(page.locator('[data-decl="ToolResultContent"]')).toContainText("Cancelled"); await page.locator(".td-inspector-row").last().locator(".td-inspector-remove").click(); await expect(page.locator(".td-inspector-row")).toHaveCount(unionRowsBeforeAdd); - await expect(page.locator("#editor")).not.toHaveValue(/ Cancelled\n/); + await expect(page.locator("#editor")).not.toHaveValue(/ {2}Cancelled\n/); await page.getByRole("button", { name: "Close properties" }).click(); await page.locator('[data-decl="Email"]').click(); @@ -192,14 +192,14 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { await expect(page.locator(".td-node-creator")).toBeVisible(); await page.getByRole("button", { name: "Add record type" }).click(); await expect(page.locator("#editor")).toHaveValue(/type NewRecord/); - await expect(page.locator("#editor")).toHaveValue(/type NewRecord \{\n field: String\n\}/); + await expect(page.locator("#editor")).toHaveValue(/type NewRecord \{\n {2}field: String\n\}/); await expect(page.locator('[data-decl="NewRecord"]')).toHaveCount(1); await expect(page.locator(".td-node-creator")).toBeHidden(); await expect(page.locator("#preview svg [data-decl]")).toHaveCount(12); await addType.click(); await page.getByRole("button", { name: "Add union type" }).click(); await expect(page.locator("#editor")).toHaveValue(/union NewUnion/); - await expect(page.locator("#editor")).toHaveValue(/union NewUnion \{\n Variant\n\}/); + await expect(page.locator("#editor")).toHaveValue(/union NewUnion \{\n {2}Variant\n\}/); await expect(page.locator('[data-decl="NewUnion"]')).toHaveCount(1); await expect(page.locator(".td-node-creator")).toBeHidden(); await expect(page.locator("#preview svg [data-decl]")).toHaveCount(13); @@ -217,7 +217,7 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { await page.locator(".td-inspector-row input").first().blur(); await page.locator(".td-inspector-row input").nth(1).fill("TextPart"); await page.locator(".td-inspector-row input").nth(1).blur(); - await expect(page.locator("#editor")).toHaveValue(/type NewRecord \{\n part: TextPart\n\}/); + await expect(page.locator("#editor")).toHaveValue(/type NewRecord \{\n {2}part: TextPart\n\}/); await expect(page.locator('[data-edge][data-source="NewRecord"][data-target="TextPart"]')).toHaveCount(1); await expect(page.getByRole("button", { name: "Delete NewRecord" })).toHaveText("Delete type"); await page.getByRole("button", { name: "Delete NewRecord" }).click(); @@ -232,7 +232,7 @@ test.describe("[WEB-VISUAL-EDITOR]", () => { await page.locator(".td-inspector-row input").first().blur(); await page.locator(".td-inspector-row input").nth(1).fill("TextPart"); await page.locator(".td-inspector-row input").nth(1).blur(); - await expect(page.locator("#editor")).toHaveValue(/union NewUnion \{\n Ready\(TextPart\)\n\}/); + await expect(page.locator("#editor")).toHaveValue(/union NewUnion \{\n {2}Ready\(TextPart\)\n\}/); await expect(page.locator('[data-edge][data-source="NewUnion"][data-target="TextPart"]')).toHaveCount(1); await expect(page.getByRole("button", { name: "Delete NewUnion" })).toHaveText("Delete type"); await page.getByRole("button", { name: "Delete NewUnion" }).click(); diff --git a/packages/web/eleventy/_data/docs.ts b/packages/web/eleventy/_data/docs.ts index 81a87c9..ff82d06 100644 --- a/packages/web/eleventy/_data/docs.ts +++ b/packages/web/eleventy/_data/docs.ts @@ -27,6 +27,7 @@ const handwritten: ReadonlyArray<{ slug: string; label: string; group?: string } { slug: "cli", label: "CLI" }, { slug: "multi-language-pipeline", label: "Multi-Language Pipeline" }, { slug: "converters", label: "Converters" }, + { slug: "typeshed-conversion", label: "Typeshed Conversion" }, { slug: "render-hooks", label: "Render Hooks" }, { slug: "tdbin", label: "TDBIN Binary Codec", group: "TDBIN" }, { slug: "tdbin-benchmarks", label: "Benchmarks", group: "TDBIN" }, diff --git a/packages/web/eleventy/converter.njk b/packages/web/eleventy/converter.njk index c89efcf..df96eb9 100644 --- a/packages/web/eleventy/converter.njk +++ b/packages/web/eleventy/converter.njk @@ -1,7 +1,7 @@ --- layout: base.njk -title: Convert TypeScript, Python, Rust, Go, C#, F#, Dart, PHP & Protobuf to Diagrams — typeDiagram -description: Paste TypeScript, Rust, Python, Go, C#, F#, Dart, PHP, or Protobuf code and instantly convert it to typeDiagram DSL plus an SVG diagram. Free online converter, no signup. +title: Convert Typeshed, Python, TypeScript, Rust & More to Diagrams — typeDiagram +description: Paste Typeshed stubs, Python, TypeScript, Rust, Go, C#, F#, Dart, PHP, or Protobuf code and instantly convert it to typeDiagram DSL plus an SVG diagram. permalink: /converter.html scriptSrc: /src/converter-main.ts bodyClass: converter-body @@ -17,7 +17,7 @@ structuredData: "@type": "WebApplication" name: "typeDiagram Converter" url: "https://typediagram.dev/converter.html" - description: "Convert TypeScript, Python, Rust, Go, C#, F#, Dart, PHP, or Protobuf type definitions into typeDiagram DSL and an SVG diagram." + description: "Convert Typeshed stubs, Python, TypeScript, Rust, Go, C#, F#, Dart, PHP, or Protobuf definitions into typeDiagram DSL and an SVG diagram." applicationCategory: "DeveloperApplication" operatingSystem: "Any (browser)" offers: @@ -29,7 +29,7 @@ structuredData:

Convert any language to a type diagram.

-

Paste TypeScript, Rust, Python, Go, C#, F#, Dart, PHP, or Protobuf — get typeDiagram source and SVG instantly.

+

Paste Typeshed stubs, TypeScript, Rust, Python, Go, C#, F#, Dart, PHP, or Protobuf — get typeDiagram source and SVG instantly.

diff --git a/packages/web/eleventy/docs-index.njk b/packages/web/eleventy/docs-index.njk index a960aa7..2a30c54 100644 --- a/packages/web/eleventy/docs-index.njk +++ b/packages/web/eleventy/docs-index.njk @@ -2,7 +2,7 @@ layout: base.njk permalink: /docs/index.html title: "typeDiagram Docs — DSL, Code Generation, TDBIN, and API" -description: "typeDiagram documentation: getting started, language reference, CLI, multi-language code generation, TDBIN binary serialization, converters, render hooks, and Node.js API." +description: "typeDiagram documentation: getting started, Typeshed conversion, language reference, CLI, multi-language code generation, TDBIN, converters, render hooks, and Node.js API." stylesHref: /src/styles.css navLogoLink: / navPlaygroundHref: /#playground @@ -13,7 +13,7 @@ structuredData: "@context": "https://schema.org" "@type": "TechArticle" headline: "typeDiagram Documentation" - description: "typeDiagram documentation: getting started, language reference, CLI, multi-language code generation, TDBIN binary serialization, converters, render hooks, and Node.js API." + description: "typeDiagram documentation: getting started, Typeshed conversion, language reference, CLI, multi-language code generation, TDBIN, converters, render hooks, and Node.js API." url: "https://typediagram.dev/docs/" inLanguage: "en" --- diff --git a/packages/web/src/converter-highlight.ts b/packages/web/src/converter-highlight.ts index db62d52..50e65f7 100644 --- a/packages/web/src/converter-highlight.ts +++ b/packages/web/src/converter-highlight.ts @@ -141,12 +141,14 @@ const PROTOBUF_RULES = table( /[<>{}:;,=()[\]]/g ); -type SupportedLang = "typescript" | "rust" | "python" | "go" | "csharp" | "fsharp" | "dart" | "protobuf" | "php"; +type SupportedLang = + "typescript" | "rust" | "python" | "typeshed" | "go" | "csharp" | "fsharp" | "dart" | "protobuf" | "php"; const LANG_RULES: Record = { typescript: TYPESCRIPT_RULES, rust: RUST_RULES, python: PYTHON_RULES, + typeshed: PYTHON_RULES, go: GO_RULES, csharp: CSHARP_RULES, fsharp: FSHARP_RULES, diff --git a/packages/web/src/converter-render.ts b/packages/web/src/converter-render.ts index 603600b..90b2182 100644 --- a/packages/web/src/converter-render.ts +++ b/packages/web/src/converter-render.ts @@ -1,7 +1,8 @@ // [WEB-CONV-RENDER] Pipeline: language source ↔ typeDiagram source + SVG. // Lazy-loads the typediagram module like render-pane.ts. -export type SupportedLang = "typescript" | "python" | "rust" | "go" | "csharp" | "fsharp" | "dart" | "protobuf" | "php"; +export type SupportedLang = + "typescript" | "python" | "typeshed" | "rust" | "go" | "csharp" | "fsharp" | "dart" | "protobuf" | "php"; const getTheme = () => window.matchMedia("(prefers-color-scheme: dark)").matches ? ("dark" as const) : ("light" as const); @@ -18,6 +19,7 @@ const loadConverterPipeline = async () => { const converterMap = { typescript: core.converters.typescript, python: core.converters.python, + typeshed: core.converters.typeshed, rust: core.converters.rust, go: core.converters.go, csharp: core.converters.csharp, diff --git a/packages/web/src/converter.ts b/packages/web/src/converter.ts index 46779eb..4de10f1 100644 --- a/packages/web/src/converter.ts +++ b/packages/web/src/converter.ts @@ -15,6 +15,7 @@ const LANG_LABELS: Record = { typescript: "TypeScript", rust: "Rust", python: "Python", + typeshed: "Typeshed", go: "Go", csharp: "C#", fsharp: "F#", @@ -27,6 +28,7 @@ const LANGUAGES: readonly SupportedLang[] = [ "typescript", "rust", "python", + "typeshed", "go", "csharp", "fsharp", diff --git a/packages/web/test/converter-render.test.ts b/packages/web/test/converter-render.test.ts index d4a30a1..f86124b 100644 --- a/packages/web/test/converter-render.test.ts +++ b/packages/web/test/converter-render.test.ts @@ -15,6 +15,7 @@ vi.mock("typediagram-core", () => ({ converters: { typescript: { fromSource: mockFromSource, toSource: mockToSource, language: "typescript" }, python: { fromSource: mockFromSource, toSource: mockToSource, language: "python" }, + typeshed: { fromSource: mockFromSource, toSource: mockToSource, language: "typeshed" }, rust: { fromSource: mockFromSource, toSource: mockToSource, language: "rust" }, go: { fromSource: mockFromSource, toSource: mockToSource, language: "go" }, csharp: { fromSource: mockFromSource, toSource: mockToSource, language: "csharp" }, From 54c4453925ff678bc4ca5d26e5eeccf8ee02da60 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:03:01 +1000 Subject: [PATCH 14/14] Fix CodeQL security alerts --- packages/cli/src/typeshed-cli.ts | 16 +++++++++++----- packages/cli/test/typeshed.e2e.test.ts | 3 ++- packages/vscode/src/webview/main.ts | 4 ++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/typeshed-cli.ts b/packages/cli/src/typeshed-cli.ts index fead1ef..6481df1 100644 --- a/packages/cli/src/typeshed-cli.ts +++ b/packages/cli/src/typeshed-cli.ts @@ -1,5 +1,5 @@ // [TYPESHED-BULK] Mirror a typeshed checkout into one .td file per non-empty .pyi. -import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join, relative, resolve } from "node:path"; import { converters, model } from "typediagram-core"; import { err, errorMessage, ok, type Result } from "./result.js"; @@ -37,10 +37,16 @@ const outputPath = (sourceRoot: string, outputRoot: string, source: string) => { const atomicWrite = async (path: string, content: string): Promise> => { try { - await mkdir(dirname(path), { recursive: true }); - const temporary = `${path}.${String(process.pid)}.tmp`; - await writeFile(temporary, content, "utf8"); - await rename(temporary, path); + const parent = dirname(path); + await mkdir(parent, { recursive: true }); + const temporaryRoot = await mkdtemp(join(parent, ".typediagram-")); + try { + const temporary = join(temporaryRoot, "output.td"); + await writeFile(temporary, content, "utf8"); + await rename(temporary, path); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } return ok(true); } catch (error) { return err({ message: `cannot write ${path}: ${errorMessage(error)}` }); diff --git a/packages/cli/test/typeshed.e2e.test.ts b/packages/cli/test/typeshed.e2e.test.ts index 1106a85..785f194 100644 --- a/packages/cli/test/typeshed.e2e.test.ts +++ b/packages/cli/test/typeshed.e2e.test.ts @@ -1,5 +1,5 @@ // [TYPESHED-BULK-TEST] Black-box repository conversion through the public CLI function. -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -46,6 +46,7 @@ describe("[TYPESHED-BULK] typeshed repository conversion", () => { expect(stdlib).toContain("function fetch(payload: Payload) -> Bytes"); expect(stdlib).not.toContain("encode"); expect(thirdParty).toContain("type Config"); + expect(await readdir(join(output, "stdlib"))).toEqual(["sample.td"]); }); it("reports missing arguments and invalid roots without partial success", async () => { diff --git a/packages/vscode/src/webview/main.ts b/packages/vscode/src/webview/main.ts index fe74407..cfa8d62 100644 --- a/packages/vscode/src/webview/main.ts +++ b/packages/vscode/src/webview/main.ts @@ -93,6 +93,10 @@ const boot = (preview: HTMLElement, elements: RecoveryElements) => { return settled; }; window.addEventListener("message", (event: MessageEvent) => { + switch (event.origin === window.location.origin) { + case false: + return; + } const update = isUpdate(event.data) ? event.data : undefined; switch (update) { case undefined: