diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8f036a..6214661 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, feat-atlas] pull_request: branches: [main] @@ -50,7 +50,7 @@ jobs: libxdo-dev - name: Stub atlas bundle for cargo check - run: mkdir -p src-tauri/resources/atlas + run: mkdir -p src-tauri/resources/atlas && touch src-tauri/resources/atlas/.keep - name: Check working-directory: src-tauri diff --git a/.gitignore b/.gitignore index f429c3b..de16335 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ dist-installers # Rust / Tauri build output src-tauri/target +# Atlas bundle — generated by scripts/bundle-atlas.mjs, not committed +src-tauri/resources/atlas/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/package-lock.json b/package-lock.json index 1353e38..96acbf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,15 @@ { "name": "tempest", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempest", - "version": "0.1.0", + "version": "0.1.1", + "workspaces": [ + "packages/atlas" + ], "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", @@ -2330,6 +2333,10 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tempest/atlas": { + "resolved": "packages/atlas", + "link": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2375,6 +2382,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2423,6 +2440,23 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -2476,6 +2510,92 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "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/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "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==", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", @@ -2515,6 +2635,16 @@ "addons/*" ] }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -2572,6 +2702,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "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/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -2602,6 +2742,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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==", + "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" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -2642,6 +2799,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2722,6 +2889,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2773,6 +2950,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -2847,6 +3031,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3109,6 +3313,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -3204,6 +3417,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -3214,6 +3433,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/lowlight": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", @@ -3248,6 +3474,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -4243,6 +4479,23 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4253,9 +4506,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4579,6 +4830,13 @@ "node": ">=10" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4598,6 +4856,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -4659,6 +4931,20 @@ } } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4676,6 +4962,45 @@ "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-sitter-wasms": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz", + "integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==", + "license": "Unlicense", + "dependencies": { + "tree-sitter-wasms": "^0.1.11" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -4716,6 +5041,13 @@ "node": ">=14.17" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -4966,37 +5298,1187 @@ } } }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" - }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", - "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "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==", + "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" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://opencollective.com/vitest" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "node_modules/vite-node/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": "ISC" + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "node_modules/vite-node/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", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/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==", + "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" + } + }, + "node_modules/vite-node/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" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "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/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==", + "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" + } + }, + "node_modules/vitest/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", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/web-tree-sitter": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", + "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", + "license": "MIT", + "peerDependencies": { + "@types/emscripten": "^1.40.0" + }, + "peerDependenciesMeta": { + "@types/emscripten": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/atlas": { + "name": "@tempest/atlas", + "version": "1.1.6", + "license": "MIT", + "dependencies": { + "ignore": "^7.0.5", + "jsonc-parser": "^3.3.1", + "picomatch": "^4.0.3", + "tree-sitter-wasms": "^0.1.11", + "web-tree-sitter": "^0.25.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.0", + "@types/node": "^20.19.30", + "@types/picomatch": "^4.0.2", + "typescript": "^5.0.0", + "vitest": "^2.1.9" + }, + "engines": { + "node": ">=20.0.0 <25.0.0" } } } diff --git a/package.json b/package.json index cd06190..b7af89a 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,15 @@ "private": true, "version": "0.1.1", "type": "module", + "workspaces": [ + "packages/atlas" + ], "scripts": { "dev": "tauri dev", "dev:frontend": "vite", + "setup": "npm run build:atlas", "build": "tauri build && node scripts/collect-artifacts.mjs", + "build:atlas": "npm run build --workspace=@tempest/atlas && node scripts/bundle-atlas.mjs", "build:frontend": "tsc && vite build", "preview": "vite preview", "tauri": "tauri" diff --git a/packages/atlas/package-lock.json b/packages/atlas/package-lock.json new file mode 100644 index 0000000..274266e --- /dev/null +++ b/packages/atlas/package-lock.json @@ -0,0 +1,1534 @@ +{ + "name": "@tempest/atlas", + "version": "1.1.6", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@tempest/atlas", + "version": "1.1.6", + "license": "MIT", + "dependencies": { + "ignore": "^7.0.5", + "jsonc-parser": "^3.3.1", + "picomatch": "^4.0.3", + "tree-sitter-wasms": "^0.1.11", + "web-tree-sitter": "^0.25.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.0", + "@types/node": "^20.19.30", + "@types/picomatch": "^4.0.2", + "typescript": "^5.0.0", + "vitest": "^2.1.9" + }, + "engines": { + "node": ">=20.0.0 <25.0.0" + } + }, + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "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" + ], + "engines": { + "node": ">=12" + } + }, + "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" + ], + "engines": { + "node": ">=12" + } + }, + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "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==", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "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" + } + }, + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/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==", + "dev": true, + "license": "MIT" + }, + "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==", + "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" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "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/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "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": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "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==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-sitter-wasms": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz", + "integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==", + "license": "Unlicense", + "dependencies": { + "tree-sitter-wasms": "^0.1.11" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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==", + "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" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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==", + "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" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-tree-sitter": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", + "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", + "license": "MIT", + "peerDependencies": { + "@types/emscripten": "^1.40.0" + }, + "peerDependenciesMeta": { + "@types/emscripten": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/packages/atlas/package.json b/packages/atlas/package.json new file mode 100644 index 0000000..b237383 --- /dev/null +++ b/packages/atlas/package.json @@ -0,0 +1,33 @@ +{ + "name": "@tempest/atlas", + "version": "1.1.6", + "description": "Semantic code intelligence for Tempest agents — surgical context, fewer tool calls, faster answers. 100% local.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc && npm run copy-assets", + "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest", + "clean": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true})\"" + }, + "license": "MIT", + "dependencies": { + "ignore": "^7.0.5", + "jsonc-parser": "^3.3.1", + "picomatch": "^4.0.3", + "tree-sitter-wasms": "^0.1.11", + "web-tree-sitter": "^0.25.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.0", + "@types/node": "^20.19.30", + "@types/picomatch": "^4.0.2", + "typescript": "^5.0.0", + "vitest": "^2.1.9" + }, + "engines": { + "node": ">=20.0.0 <25.0.0" + } +} diff --git a/packages/atlas/src/context/formatter.ts b/packages/atlas/src/context/formatter.ts new file mode 100644 index 0000000..748d172 --- /dev/null +++ b/packages/atlas/src/context/formatter.ts @@ -0,0 +1,290 @@ +/** + * Context Formatter + * + * Formats TaskContext as markdown or JSON for consumption by Claude. + */ + +import { Node, Edge, TaskContext, Subgraph } from '../types'; +import { isGeneratedFile } from '../extraction/generated-detection'; + +/** + * Format context as markdown + * + * Creates a compact markdown document optimized for Claude with minimal context usage: + * - Brief summary + * - Entry points with locations + * - Code blocks only for key symbols + */ +export function formatContextAsMarkdown(context: TaskContext): string { + const lines: string[] = []; + + // Header with query + lines.push('## Code Context\n'); + lines.push(`**Query:** ${context.query}\n`); + + // Entry points - compact format. Re-sort so generated files (.pb.go, + // .pulsar.go, mocks, …) rank LAST — a flow query should lead with the + // hand-written implementation, not protobuf scaffolding. + const orderedEntries = [...context.entryPoints].sort((a, b) => { + const aGen = isGeneratedFile(a.filePath) ? 1 : 0; + const bGen = isGeneratedFile(b.filePath) ? 1 : 0; + return aGen - bGen; + }); + if (orderedEntries.length > 0) { + lines.push('### Entry Points\n'); + for (const node of orderedEntries) { + const location = node.startLine ? `:${node.startLine}` : ''; + lines.push(`- **${node.name}** (${node.kind}) - ${node.filePath}${location}`); + if (node.signature) { + lines.push(` \`${node.signature}\``); + } + } + lines.push(''); + } + + // Related symbols - compact list (skip verbose structure tree). Drop nodes + // in generated source files (`.pb.go` / `.pulsar.go` / mocks / …) — agents + // chasing a flow never want to land on protobuf scaffolding (cosmos-Q3 used + // to list `gov.pulsar.go::GetExpeditedThreshold` and `1.pulsar.go::Get` in + // Related Symbols, pure noise that displaced real-flow entries). + const otherSymbols = Array.from(context.subgraph.nodes.values()) + .filter(n => !context.entryPoints.some(e => e.id === n.id)) + .filter(n => !isGeneratedFile(n.filePath)) + .slice(0, 10); // Limit to 10 related symbols + + if (otherSymbols.length > 0) { + lines.push('### Related Symbols\n'); + const byFile = new Map(); + for (const node of otherSymbols) { + const existing = byFile.get(node.filePath) || []; + existing.push(node); + byFile.set(node.filePath, existing); + } + + for (const [file, nodes] of byFile) { + const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', '); + lines.push(`- ${file}: ${nodeList}`); + } + lines.push(''); + } + + // Code blocks - only for key entry points. Re-sort so non-generated blocks + // show first (consistent with Entry Points reordering above). + if (context.codeBlocks.length > 0) { + const orderedBlocks = [...context.codeBlocks].sort((a, b) => { + const aGen = isGeneratedFile(a.filePath) ? 1 : 0; + const bGen = isGeneratedFile(b.filePath) ? 1 : 0; + return aGen - bGen; + }); + lines.push('### Code\n'); + for (const block of orderedBlocks) { + const nodeName = block.node?.name ?? 'Unknown'; + lines.push(`#### ${nodeName} (${block.filePath}:${block.startLine})\n`); + lines.push('```' + block.language); + lines.push(block.content); + lines.push('```\n'); + } + } + + return lines.join('\n'); +} + +/** + * Format context as JSON + * + * Returns a structured JSON representation suitable for programmatic use. + */ +export function formatContextAsJson(context: TaskContext): string { + // Convert Map to array for JSON serialization + const serializable = { + query: context.query, + summary: context.summary, + entryPoints: context.entryPoints.map(serializeNode), + nodes: Array.from(context.subgraph.nodes.values()).map(serializeNode), + edges: context.subgraph.edges.map(serializeEdge), + codeBlocks: context.codeBlocks.map((block) => ({ + filePath: block.filePath, + startLine: block.startLine, + endLine: block.endLine, + language: block.language, + content: block.content, + nodeName: block.node?.name, + nodeKind: block.node?.kind, + })), + relatedFiles: context.relatedFiles, + stats: context.stats, + }; + + return JSON.stringify(serializable, null, 2); +} + +/** + * Format a subgraph as an ASCII tree structure + */ +export function formatSubgraphTree(subgraph: Subgraph, entryPoints: Node[]): string { + const lines: string[] = []; + const printed = new Set(); + + // Build adjacency list for outgoing edges + const outgoing = new Map(); + for (const edge of subgraph.edges) { + const existing = outgoing.get(edge.source) ?? []; + existing.push(edge); + outgoing.set(edge.source, existing); + } + + // Print each entry point as a tree root + for (const entry of entryPoints) { + formatNodeTree(entry, subgraph, outgoing, printed, lines, 0, ''); + lines.push(''); // Blank line between trees + } + + // Print any remaining nodes not reached from entry points + const remaining: Node[] = []; + for (const node of subgraph.nodes.values()) { + if (!printed.has(node.id)) { + remaining.push(node); + } + } + + if (remaining.length > 0 && remaining.length <= 10) { + lines.push('Other relevant symbols:'); + for (const node of remaining) { + const location = node.startLine ? `:${node.startLine}` : ''; + lines.push(` ${node.kind}: ${node.name} (${node.filePath}${location})`); + } + } else if (remaining.length > 10) { + lines.push(`... and ${remaining.length} more related symbols`); + } + + return lines.join('\n').trim(); +} + +/** + * Format a single node and its relationships + */ +function formatNodeTree( + node: Node, + subgraph: Subgraph, + outgoing: Map, + printed: Set, + lines: string[], + depth: number, + prefix: string +): void { + if (printed.has(node.id)) { + return; + } + printed.add(node.id); + + // Node header + const location = node.startLine ? `:${node.startLine}` : ''; + const signature = node.signature ? ` - ${truncate(node.signature, 50)}` : ''; + lines.push(`${prefix}${node.kind}: ${node.name} (${node.filePath}${location})${signature}`); + + // Outgoing edges + const edges = outgoing.get(node.id) ?? []; + const significantEdges = edges.filter((e) => + ['calls', 'extends', 'implements', 'imports', 'references'].includes(e.kind) + ); + + // Group by kind + const edgesByKind = new Map(); + for (const edge of significantEdges) { + const existing = edgesByKind.get(edge.kind) ?? []; + existing.push(edge); + edgesByKind.set(edge.kind, existing); + } + + // Print edges grouped by kind + const newPrefix = prefix + ' '; + for (const [kind, kindEdges] of edgesByKind) { + if (kindEdges.length > 3) { + // Summarize if too many + const names = kindEdges + .slice(0, 3) + .map((e) => { + const target = subgraph.nodes.get(e.target); + return target?.name ?? 'unknown'; + }) + .join(', '); + lines.push(`${newPrefix}├── ${kind}: ${names} and ${kindEdges.length - 3} more`); + } else { + for (let i = 0; i < kindEdges.length; i++) { + const edge = kindEdges[i]!; + const target = subgraph.nodes.get(edge.target); + const targetName = target?.name ?? 'unknown'; + const connector = i === kindEdges.length - 1 ? '└──' : '├──'; + lines.push(`${newPrefix}${connector} ${kind} → ${targetName}`); + } + } + } + + // Recurse for directly connected nodes (limited depth) + if (depth < 1) { + for (const edge of significantEdges.slice(0, 3)) { + const target = subgraph.nodes.get(edge.target); + if (target && !printed.has(target.id)) { + formatNodeTree(target, subgraph, outgoing, printed, lines, depth + 1, newPrefix); + } + } + } +} + +/** + * Serialize a node for JSON output + */ +function serializeNode(node: Node): Record { + return { + id: node.id, + kind: node.kind, + name: node.name, + qualifiedName: node.qualifiedName, + filePath: node.filePath, + language: node.language, + startLine: node.startLine, + endLine: node.endLine, + signature: node.signature, + docstring: node.docstring, + visibility: node.visibility, + isExported: node.isExported, + isAsync: node.isAsync, + isStatic: node.isStatic, + }; +} + +/** + * Serialize an edge for JSON output + */ +function serializeEdge(edge: Edge): Record { + return { + source: edge.source, + target: edge.target, + kind: edge.kind, + line: edge.line, + column: edge.column, + }; +} + +/** + * Truncate a string with ellipsis + */ +function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) { + return str; + } + return str.slice(0, maxLength - 3) + '...'; +} + +/** + * Format bytes as human-readable string + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} bytes`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } else { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } +} diff --git a/packages/atlas/src/context/index.ts b/packages/atlas/src/context/index.ts new file mode 100644 index 0000000..6935337 --- /dev/null +++ b/packages/atlas/src/context/index.ts @@ -0,0 +1,1372 @@ +/** + * Context Builder + * + * Builds rich context for tasks by combining FTS search with graph traversal. + * Outputs structured context ready to inject into Claude. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { + Node, + Edge, + NodeKind, + EdgeKind, + Subgraph, + CodeBlock, + TaskContext, + TaskInput, + BuildContextOptions, + FindRelevantContextOptions, + SearchResult, +} from '../types'; +import { QueryBuilder } from '../db/queries'; +import { GraphTraverser } from '../graph'; +import { formatContextAsMarkdown, formatContextAsJson } from './formatter'; +import { logDebug } from '../errors'; +import { validatePathWithinRoot, isConfigLeafNode } from '../utils'; +import { isTestFile, extractSearchTerms, scorePathRelevance, getStemVariants, isDistinctiveIdentifier } from '../search/query-utils'; +import { LOW_CONFIDENCE_MARKER } from './markers'; + +/** + * Extract likely symbol names from a natural language query + * + * Identifies potential code symbols using patterns: + * - CamelCase: UserService, signInWithGoogle + * - snake_case: user_service, sign_in + * - SCREAMING_SNAKE: MAX_RETRIES + * - dot.notation: app.isPackaged (extracts both sides) + * - Single words that look like identifiers (no spaces, not common English words) + * + * @param query - Natural language query + * @returns Array of potential symbol names + */ +function extractSymbolsFromQuery(query: string): string[] { + const symbols = new Set(); + + // Extract CamelCase identifiers (2+ chars, starts with letter) + const camelCasePattern = /\b([A-Z][a-z]+(?:[A-Z][a-z]*)*|[a-z]+(?:[A-Z][a-z]*)+)\b/g; + let match; + while ((match = camelCasePattern.exec(query)) !== null) { + if (match[1] && match[1].length >= 2) { + symbols.add(match[1]); + } + } + + // Extract snake_case identifiers + const snakeCasePattern = /\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b/gi; + while ((match = snakeCasePattern.exec(query)) !== null) { + if (match[1] && match[1].length >= 3) { + symbols.add(match[1]); + } + } + + // Extract SCREAMING_SNAKE_CASE + const screamingPattern = /\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g; + while ((match = screamingPattern.exec(query)) !== null) { + if (match[1]) { + symbols.add(match[1]); + } + } + + // Extract ALL_CAPS acronyms (2+ chars, e.g., REST, HTTP, LRU, API) + const acronymPattern = /\b([A-Z]{2,})\b/g; + while ((match = acronymPattern.exec(query)) !== null) { + if (match[1]) { + symbols.add(match[1]); + } + } + + // Extract dot.notation and split into parts (e.g., "app.isPackaged" -> ["app", "isPackaged"]) + const dotPattern = /\b([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)+)\b/g; + while ((match = dotPattern.exec(query)) !== null) { + if (match[1]) { + // Add both the full path and individual parts + symbols.add(match[1]); + const parts = match[1].split('.'); + for (const part of parts) { + if (part.length >= 2) { + symbols.add(part); + } + } + } + } + + // Extract plain lowercase identifiers (3+ chars, not already matched) + // Catches symbol names like "undo", "redo", "history", "render", "parse" + const lowercasePattern = /\b([a-z][a-z0-9]{2,})\b/g; + while ((match = lowercasePattern.exec(query)) !== null) { + if (match[1]) { + symbols.add(match[1]); + } + } + + // Filter out common English words that aren't likely symbol names + const commonWords = new Set([ + 'the', 'and', 'for', 'with', 'from', 'this', 'that', 'have', 'been', + 'will', 'would', 'could', 'should', 'does', 'done', 'make', 'made', + 'use', 'used', 'using', 'work', 'works', 'find', 'found', 'show', + 'call', 'called', 'calling', 'get', 'set', 'add', 'all', 'any', + 'how', 'what', 'when', 'where', 'which', 'who', 'why', + 'not', 'but', 'are', 'was', 'were', 'has', 'had', 'its', + 'can', 'did', 'may', 'also', 'into', 'than', 'then', 'them', + 'each', 'other', 'some', 'such', 'only', 'same', 'about', + 'after', 'before', 'between', 'through', 'during', 'without', + 'again', 'further', 'once', 'here', 'there', 'both', 'just', + 'more', 'most', 'very', 'being', 'having', 'doing', + 'system', 'need', 'needs', 'want', 'wants', 'like', 'look', + 'change', 'changes', 'changed', 'changing', + // Common English nouns/verbs that match thousands of unrelated code symbols + 'layer', 'handle', 'handles', 'handling', 'incoming', 'outgoing', + 'data', 'flow', 'flows', 'level', 'levels', 'request', 'requests', + 'response', 'responses', 'implement', 'implements', 'implementation', + 'interface', 'interfaces', 'class', 'classes', 'method', 'methods', + 'trigger', 'triggers', 'affected', 'affect', 'affects', + 'else', 'code', 'failing', 'failed', 'silently', 'decide', 'decides', + 'return', 'returns', 'returned', 'take', 'takes', 'taken', + 'check', 'checks', 'checked', 'create', 'creates', 'created', + 'read', 'reads', 'write', 'writes', 'written', + 'start', 'starts', 'stop', 'stops', 'run', 'runs', 'running', + ]); + + return Array.from(symbols).filter(s => !commonWords.has(s.toLowerCase())); +} + +/** + * Default options for context building + * + * Tuned for minimal context usage while still providing useful results: + * - Fewer nodes and code blocks by default + * - Smaller code block size limit + * - Shallower traversal + */ +const DEFAULT_BUILD_OPTIONS: Required = { + maxNodes: 20, // Reduced from 50 - most tasks don't need 50 symbols + maxCodeBlocks: 5, // Reduced from 10 - only show most relevant code + maxCodeBlockSize: 1500, // Reduced from 2000 + includeCode: true, + format: 'markdown', + searchLimit: 3, // Reduced from 5 - fewer entry points + traversalDepth: 1, // Reduced from 2 - shallower graph expansion + minScore: 0.3, +}; + +/** + * Node kinds that provide high information value in context results. + * Imports/exports are excluded because they have near-zero information density - + * they tell you something exists, not how it works. + */ +const HIGH_VALUE_NODE_KINDS: NodeKind[] = [ + 'function', 'method', 'class', 'interface', 'type_alias', 'struct', 'trait', + 'component', 'route', 'variable', 'constant', 'enum', 'module', 'namespace', +]; + +/** + * Default options for finding relevant context + */ +const DEFAULT_FIND_OPTIONS: Required = { + searchLimit: 3, // Reduced from 5 + traversalDepth: 1, // Reduced from 2 + maxNodes: 20, // Reduced from 50 + minScore: 0.3, + edgeKinds: [], + nodeKinds: HIGH_VALUE_NODE_KINDS, // Filter out imports/exports by default +}; + +// Re-export the low-confidence sentinel (defined in a dependency-free leaf so +// the MCP layer can import it without pulling this module's deps onto the +// cold-start path). Builder code below uses the imported binding directly. +export { LOW_CONFIDENCE_MARKER } from './markers'; + +/** + * Context Builder + * + * Coordinates semantic search and graph traversal to build + * comprehensive context for tasks. + */ +export class ContextBuilder { + private projectRoot: string; + private queries: QueryBuilder; + private traverser: GraphTraverser; + + constructor( + projectRoot: string, + queries: QueryBuilder, + traverser: GraphTraverser + ) { + this.projectRoot = projectRoot; + this.queries = queries; + this.traverser = traverser; + } + + /** + * Build context for a task + * + * Pipeline: + * 1. Parse task input (string or {title, description}) + * 2. Run semantic search to find entry points + * 3. Expand graph around entry points + * 4. Extract code blocks for key nodes + * 5. Format output for Claude + * + * @param input - Task description or object with title/description + * @param options - Build options + * @returns TaskContext (structured) or formatted string + */ + async buildContext( + input: TaskInput, + options: BuildContextOptions = {} + ): Promise { + const opts = { ...DEFAULT_BUILD_OPTIONS, ...options }; + + // Parse input + const query = typeof input === 'string' ? input : `${input.title}${input.description ? `: ${input.description}` : ''}`; + + // Find relevant context (semantic search + graph expansion) + const subgraph = await this.findRelevantContext(query, { + searchLimit: opts.searchLimit, + traversalDepth: opts.traversalDepth, + maxNodes: opts.maxNodes, + minScore: opts.minScore, + }); + + // Get entry points (nodes from semantic search) + const entryPoints = this.getEntryPoints(subgraph); + + // Extract code blocks for key nodes + const codeBlocks = opts.includeCode + ? await this.extractCodeBlocks(subgraph, opts.maxCodeBlocks, opts.maxCodeBlockSize) + : []; + + // Get related files + const relatedFiles = this.getRelatedFiles(subgraph); + + // Generate summary + const summary = this.generateSummary(query, subgraph, entryPoints); + + // Calculate stats + const stats = { + nodeCount: subgraph.nodes.size, + edgeCount: subgraph.edges.length, + fileCount: relatedFiles.length, + codeBlockCount: codeBlocks.length, + totalCodeSize: codeBlocks.reduce((sum, block) => sum + block.content.length, 0), + }; + + const context: TaskContext = { + query, + subgraph, + entryPoints, + codeBlocks, + relatedFiles, + summary, + stats, + }; + + // Return formatted output or raw context + if (opts.format === 'markdown') { + return formatContextAsMarkdown(context) + + this.buildCallPathsSection(subgraph) + + (subgraph.confidence === 'low' ? this.buildLowConfidenceNote(entryPoints) : ''); + } else if (opts.format === 'json') { + return formatContextAsJson(context); + } + + return context; + } + + /** + * Honest handoff appended when retrieval confidence is low (the query matched + * mostly common words). Instead of the usual "this covers the surface" framing + * — which, when wrong, sends the agent off to Read/Grep — it admits the + * uncertainty and routes the agent to the precise tools (explore with real + * symbol names, search, or files to browse the closest areas we *did* surface). + */ + private buildLowConfidenceNote(entryPoints: Node[]): string { + const dirs: string[] = []; + const seen = new Set(); + for (const n of entryPoints) { + const slash = n.filePath.lastIndexOf('/'); + const dir = slash > 0 ? n.filePath.slice(0, slash) : n.filePath; + if (!seen.has(dir)) { seen.add(dir); dirs.push(dir); } + if (dirs.length >= 4) break; + } + const dirLine = dirs.length + ? `\n- \`atlas_files\` a likely area: ${dirs.map(d => `\`${d}\``).join(', ')}` + : ''; + return `\n\n${LOW_CONFIDENCE_MARKER}\n\n` + + 'This query matched mostly on common words, so the entry points above may ' + + 'be off-target — treat them as a starting point, not a complete answer. ' + + 'For a reliable result:\n' + + '- `atlas_explore` with the **exact symbol names** you are after ' + + '(class / function / method names), or\n' + + '- `atlas_search ` for one specific symbol' + + dirLine + + '\n\nDo not assume the list above is comprehensive.'; + } + + /** + * Surface short call-paths among the symbols this context already found, + * derived in-memory from the subgraph's `calls` edges (no extra queries). + * + * This bakes the value of path-finding INTO the always-loaded `context` tool. + * Agents reliably read context's output but do NOT discover/adopt a standalone + * trace tool (in deferred-MCP harnesses they only ToolSearch-select tools they + * already know). Delivering the flow here means "how does X reach Y" is + * answered without the agent needing to find, load, or choose a new tool. + * Chains stop where the static call graph ends (e.g. dynamic dispatch) — that + * truncation is honest, and the agent can atlas_node the last hop to bridge. + */ + private buildCallPathsSection(subgraph: Subgraph): string { + const adj = new Map(); + for (const e of subgraph.edges) { + if (e.kind !== 'calls') continue; + if (!subgraph.nodes.has(e.source) || !subgraph.nodes.has(e.target)) continue; + const list = adj.get(e.source); + if (list) list.push(e.target); + else adj.set(e.source, [e.target]); + } + if (adj.size === 0) return ''; + + const MAX_HOPS = 6; + const chains: string[][] = []; + let budget = 2000; // bound DFS work on dense subgraphs + const dfs = (id: string, path: string[], seen: Set): void => { + if (budget-- <= 0) return; + const next = (adj.get(id) ?? []).filter((t) => !seen.has(t)); + if (next.length === 0 || path.length >= MAX_HOPS) { + if (path.length >= 3) chains.push([...path]); // >=3 nodes = a real flow, not a single call + return; + } + for (const t of next) { + seen.add(t); + dfs(t, [...path, t], seen); + seen.delete(t); + } + }; + const starts = (subgraph.roots.length > 0 + ? subgraph.roots.filter((id) => adj.has(id)) + : [...adj.keys()] + ).slice(0, 5); + for (const s of starts) dfs(s, [s], new Set([s])); + if (chains.length === 0) return ''; + + // Keep only chains that connect TWO OR MORE query-relevant symbols (roots). + // A chain from a root into an arbitrary callee (render → onMagicFrameGenerate) + // is structurally valid but tangential to the question; requiring ≥2 roots + // keeps the chain anchored to what the user actually asked about. Rank by + // #roots then length, and drop any that are a sub-path of a longer kept chain. + const rootSet = new Set(subgraph.roots); + const rootCount = (c: string[]): number => c.reduce((n, id) => n + (rootSet.has(id) ? 1 : 0), 0); + const relevant = chains.filter((c) => rootCount(c) >= 2); + relevant.sort((a, b) => rootCount(b) - rootCount(a) || b.length - a.length); + const kept: string[][] = []; + for (const c of relevant) { + const key = c.join('>'); + if (kept.some((k) => k.join('>').includes(key))) continue; + kept.push(c); + if (kept.length >= 3) break; + } + if (kept.length === 0) return ''; + const name = (id: string): string => subgraph.nodes.get(id)?.name ?? id; + + // Synthesized (dynamic-dispatch) hops are real `calls` edges but invisible to + // static parsing — mark them inline so the agent sees WHERE the callback was + // wired up (`registered @file:line`) instead of grepping for it. Keyed by + // "source>target". + const synthByPair = new Map(); + for (const e of subgraph.edges) { + if (e.kind !== 'calls' || e.provenance !== 'heuristic') continue; + const m = e.metadata as Record | undefined; + if (!m?.synthesizedBy) continue; + const at = typeof m.registeredAt === 'string' ? ` @${m.registeredAt}` : ''; + const label = m.synthesizedBy === 'callback' + ? `callback via ${m.via ? `\`${String(m.via)}\`` : 'registrar'}${at}` + : m.synthesizedBy === 'react-render' + ? `React re-render via setState${at}` + : m.synthesizedBy === 'jsx-render' + ? `renders <${String(m.via || 'child')}>` + : m.synthesizedBy === 'vue-handler' + ? `Vue @${String(m.event || 'event')} handler` + : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`; + synthByPair.set(`${e.source}>${e.target}`, label); + } + const renderChain = (c: string[]): string => { + let s = name(c[0]!); + for (let i = 1; i < c.length; i++) { + const synth = synthByPair.get(`${c[i - 1]}>${c[i]}`); + s += synth ? ` →[${synth}] ${name(c[i]!)}` : ` → ${name(c[i]!)}`; + } + return s; + }; + const hasSynth = kept.some((c) => c.some((_, i) => i > 0 && synthByPair.has(`${c[i - 1]}>${c[i]}`))); + const lines = [ + '', + '## Call paths', + '', + 'Execution flow among the key symbols (traced through the call graph):', + '', + ...kept.map((c) => `- ${renderChain(c)}`), + '', + hasSynth + ? '_Hops marked `[callback/event …]` are dynamic dispatch bridged by atlas (with the registration site); the rest are direct calls. atlas_node any symbol for its body._' + : '_atlas_node any symbol above for its source + its own callers/callees._', + ]; + return '\n' + lines.join('\n') + '\n'; + } + + /** + * Find relevant subgraph for a query + * + * Uses hybrid search combining exact symbol lookup with semantic search: + * 1. Extract potential symbol names from query + * 2. Look up exact matches for those symbols (high confidence) + * 3. Use semantic search for concept matching + * 4. Merge results, prioritizing exact matches + * 5. Traverse graph from entry points + * + * @param query - Natural language query + * @param options - Search and traversal options + * @returns Subgraph of relevant nodes and edges + */ + async findRelevantContext( + query: string, + options: FindRelevantContextOptions = {} + ): Promise { + const opts = { ...DEFAULT_FIND_OPTIONS, ...options }; + + // Start with empty subgraph + const nodes = new Map(); + const edges: Edge[] = []; + const roots: string[] = []; + + // Handle empty query - return empty subgraph + if (!query || query.trim().length === 0) { + return { nodes, edges, roots }; + } + + // === HYBRID SEARCH === + + // Step 1: Extract potential symbol names from query + const symbolsFromQuery = extractSymbolsFromQuery(query); + logDebug('Extracted symbols from query', { query, symbols: symbolsFromQuery }); + + // Step 2: Look up exact matches for extracted symbols + let exactMatches: SearchResult[] = []; + if (symbolsFromQuery.length > 0) { + try { + // Get more results so we can apply co-location boosting before trimming + exactMatches = this.queries.findNodesByExactName(symbolsFromQuery, { + limit: Math.ceil(opts.searchLimit * 5), + kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined, + }); + + // Co-location boost: when multiple extracted symbols appear in the same file, + // those results are much more likely to be what the user is looking for. + // E.g., "scrapeLoop" + "run" both in scrape/scrape.go → boost both. + if (exactMatches.length > 1) { + // Build a map of files → how many distinct symbol names matched in that file + const fileSymbolCounts = new Map>(); + for (const r of exactMatches) { + const names = fileSymbolCounts.get(r.node.filePath) || new Set(); + names.add(r.node.name.toLowerCase()); + fileSymbolCounts.set(r.node.filePath, names); + } + // Boost results in files where multiple query symbols co-occur + exactMatches = exactMatches.map(r => { + const symbolCount = fileSymbolCounts.get(r.node.filePath)?.size || 1; + return { + ...r, + score: symbolCount > 1 ? r.score + (symbolCount - 1) * 20 : r.score, + }; + }); + exactMatches.sort((a, b) => b.score - a.score); + } + + // Trim back to reasonable size + exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 2)); + logDebug('Exact symbol matches', { count: exactMatches.length }); + } catch (error) { + logDebug('Exact symbol lookup failed', { error: String(error) }); + } + } + + // Step 2b: Search for extracted symbols as definition (class/interface) prefixes. + // When the user writes "REST", "bulk", or "allocation", they usually mean classes + // like RestController, BulkRequest, AllocationService — not nodes named exactly that. + // Also tries stem variants: "caching" → "cache" finds Cache, CacheBuilder. + if (symbolsFromQuery.length > 0) { + const definitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'trait', + 'protocol', 'enum', 'type_alias']; + // Expand symbols with stem variants for broader definition matching + const expandedSymbols = new Set(symbolsFromQuery); + for (const sym of symbolsFromQuery) { + for (const variant of getStemVariants(sym)) { + expandedSymbols.add(variant); + } + } + for (const sym of expandedSymbols) { + // Title-case the symbol: "REST" → "Rest", "bulk" → "Bulk", "allocation" → "Allocation" + const titleCased = sym.charAt(0).toUpperCase() + sym.slice(1).toLowerCase(); + if (titleCased === sym) continue; // already title-case (e.g., "Engine") — handled by exact match + // Fetch more results since popular prefixes have many matches + const prefixResults = this.queries.searchNodes(titleCased, { + limit: 30, + kinds: definitionKinds, + }); + const matched: SearchResult[] = []; + for (const r of prefixResults) { + if (r.node.name.toLowerCase().startsWith(titleCased.toLowerCase())) { + // Favor shorter names: "AllocationService" (18 chars) over + // "AllocationBalancingRoundMetrics" (31 chars). Core classes tend + // to have concise names; test/helper classes are verbose. + const brevityBonus = Math.max(0, 10 - (r.node.name.length - titleCased.length) / 3); + matched.push({ ...r, score: r.score + 15 + brevityBonus }); + } + } + matched.sort((a, b) => b.score - a.score); + for (const r of matched.slice(0, Math.ceil(opts.searchLimit))) { + const existing = exactMatches.find(e => e.node.id === r.node.id); + if (!existing) { + exactMatches.push(r); + } + } + } + exactMatches.sort((a, b) => b.score - a.score); + exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 3)); + } + + // Step 3: Run text search for natural language term matching + // This catches file-name and node-name matches that semantic search may miss, + // which is critical for template-heavy codebases (e.g., Liquid/Shopify themes) + // where file names are the primary identifiers. + let textResults: SearchResult[] = []; + try { + const searchTerms = extractSearchTerms(query); + if (searchTerms.length > 0) { + // Search each term individually to get broader coverage, + // then boost results that match multiple terms + const termResultsMap = new Map(); + // When no explicit kind filter is set, exclude imports — they flood FTS + // results with qualified name matches (e.g., "REST" matches 445K import paths) + // but are almost never what exploration queries want. + const searchKinds = opts.nodeKinds && opts.nodeKinds.length > 0 + ? opts.nodeKinds + : ['file', 'module', 'class', 'struct', 'interface', 'trait', 'protocol', + 'function', 'method', 'property', 'field', 'variable', 'constant', + 'enum', 'enum_member', 'type_alias', 'namespace', 'export', + 'route', 'component'] as NodeKind[]; + for (const term of searchTerms) { + const termResults = this.queries.searchNodes(term, { + limit: opts.searchLimit * 2, + kinds: searchKinds, + }); + for (const r of termResults) { + const existing = termResultsMap.get(r.node.id); + if (existing) { + existing.termHits++; + existing.result.score = Math.max(existing.result.score, r.score); + } else { + termResultsMap.set(r.node.id, { result: r, termHits: 1 }); + } + } + } + // Boost results matching multiple terms and sort + textResults = Array.from(termResultsMap.values()) + .map(({ result, termHits }) => ({ + ...result, + score: result.score + (termHits - 1) * 5, + })) + .sort((a, b) => b.score - a.score) + .slice(0, opts.searchLimit * 2); + } + logDebug('Text search results', { count: textResults.length }); + } catch (error) { + logDebug('Text search failed', { query, error: String(error) }); + } + + // Step 4: Merge results, taking the max score when duplicates appear + // across search channels. Exact matches may have lower scores than FTS + // results for the same node — use the best score from any channel. + const resultById = new Map(); + let searchResults: SearchResult[] = []; + + // Add exact matches first + for (const result of exactMatches) { + const existing = resultById.get(result.node.id); + if (existing) { + existing.score = Math.max(existing.score, result.score); + } else { + resultById.set(result.node.id, result); + searchResults.push(result); + } + } + + // Add text search results, upgrading scores for duplicates + for (const result of textResults) { + const existing = resultById.get(result.node.id); + if (existing) { + existing.score = Math.max(existing.score, result.score); + } else { + resultById.set(result.node.id, result); + searchResults.push(result); + } + } + + const queryLower = query.toLowerCase(); + const isTestQuery = queryLower.includes('test') || queryLower.includes('spec'); + + // Deprioritize test files early so they don't take multi-term boost slots + if (!isTestQuery) { + for (const result of searchResults) { + if (isTestFile(result.node.filePath)) { + result.score *= 0.3; + } + } + } + + // Iter7 — Core-directory boost. On projects with one file that holds + // the dense majority of internal call edges (e.g. sinatra's + // `lib/sinatra/base.rb` at 85% of all in-file edges), the agent's + // task usually asks about the framework's core. Without this boost, + // ranking favors small focused extension files (e.g. text search + // picks `sinatra-contrib/lib/sinatra/multi_route.rb`'s 10-line + // `route` method over `base.rb`'s `route!` because the extension + // file's `route` matches the query verbatim AND the file is small, + // dwarfing the longer name `route!` in a 1500-line file). Boost + // results that share a directory prefix with the dominant file's + // directory so the core file's siblings outrank sibling-package + // extensions. + try { + const dominant = this.queries.getDominantFile?.(); + if (dominant && dominant.edgeCount >= 3 * dominant.nextEdgeCount) { + // Take the directory of the dominant file (everything up to the + // last slash). For `lib/sinatra/base.rb` → `lib/sinatra/`. + const slash = dominant.filePath.lastIndexOf('/'); + if (slash > 0) { + const coreDir = dominant.filePath.slice(0, slash + 1); + for (const result of searchResults) { + if (result.node.filePath.startsWith(coreDir)) { + result.score += 25; + } + } + } + } + } catch { + // SQL failure — fall through, scoring works without the boost + } + + // Step 5a: Multi-term co-occurrence re-ranking (applied BEFORE truncation). + // For multi-word queries like "search execution from request to shard", + // nodes matching 2+ query terms in their name or path are far more relevant + // than nodes matching just one generic term. Without this, "ExecutionUtils" + // (matches only "execution") fills budget slots meant for "ShardSearchRequest" + // (matches "shard" + "search" + "request"). + const queryTermsForBoost = extractSearchTerms(query); + if (queryTermsForBoost.length >= 2) { + // Group terms that are substrings of each other (stem variants of the same + // root word). "indexed", "indexe", "index" should count as ONE concept match, + // not three. Without this, stem variants inflate matchCount and give false + // multi-term boosts to symbols matching one root word multiple times. + const termGroups: string[][] = []; + const sorted = [...queryTermsForBoost].sort((a, b) => b.length - a.length); + const assigned = new Set(); + for (const term of sorted) { + if (assigned.has(term)) continue; + const group = [term]; + assigned.add(term); + for (const other of sorted) { + if (assigned.has(other)) continue; + if (term.includes(other) || other.includes(term)) { + group.push(other); + assigned.add(other); + } + } + termGroups.push(group); + } + + // Build a set of exact-match node IDs so we can exempt them from dampening. + // When the query is "LiveEditMode DevServerPreview", these are specific + // symbols the user asked for — dampening them because they only match 1 + // term group is counter-productive. + const exactMatchIds = new Set(exactMatches.map(r => r.node.id)); + + // ...but only exempt exact matches the user *named as an identifier* + // (camelCase/snake_case/acronym). A plain dictionary word that happens to + // exact-match an unrelated symbol — query "flat object" → a constant named + // FLAT — must NOT be exempt, or the +exact-name bonus floats it to the top + // of a prose query with zero corroboration from any other term. Classify by + // the QUERY token (what the user typed), not the matched symbol's name. + const distinctiveTokens = new Set( + symbolsFromQuery.filter(isDistinctiveIdentifier).map(s => s.toLowerCase()) + ); + const distinctiveExactMatchIds = new Set( + exactMatches + .filter(r => distinctiveTokens.has(r.node.name.toLowerCase())) + .map(r => r.node.id) + ); + + for (const result of searchResults) { + // Check term matches in name (substring) and path DIRECTORIES (exact). + // Directory segments must match exactly — "search" matches directory + // "search/" but NOT "elasticsearch/". The class name is checked + // separately via substring match on the node name. + const nameLower = result.node.name.toLowerCase(); + const dirSegments = path.dirname(result.node.filePath).toLowerCase().split('/'); + let matchCount = 0; + for (const group of termGroups) { + const groupMatches = group.some(term => { + const inName = nameLower.includes(term); + const inDir = dirSegments.some(seg => seg === term); + return inName || inDir; + }); + if (groupMatches) matchCount++; + } + if (matchCount >= 2) { + // Multiplicative boost — 2 terms → 2x, 3 terms → 2.5x + result.score *= 1 + matchCount * 0.5; + } else if (distinctiveExactMatchIds.has(result.node.id)) { + // Exact match on a distinctive identifier the user explicitly named — + // keep full score (e.g. "LiveEditMode DevServerPreview"). + } else if (exactMatchIds.has(result.node.id)) { + // Exact match on a COMMON word (e.g. "flat" → FLAT): high-scoring noise + // inflated by the +exact-name bonus, corroborated by no other query + // term. Demote hard so corroborated matches win. + result.score *= 0.3; + } else { + // Mild dampen for generic single-term matches — they might be generic + // but could also be the right result (e.g., "Protocol" class for an IPC query). + result.score *= 0.6; + } + } + searchResults.sort((a, b) => b.score - a.score); + } + + // Step 5b: CamelCase-boundary matching via LIKE query. + // FTS can't find "Search" inside "TransportSearchAction" (one FTS token). + // LIKE reliably finds these substring matches. Results are appended with + // guaranteed slots so they don't compete with higher-scoring prefix matches. + if (symbolsFromQuery.length > 0) { + const camelDefinitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'trait', + 'protocol', 'enum', 'type_alias']; + const camelSearchedTerms = new Set(); + const searchIdSet = new Set(searchResults.map(r => r.node.id)); + // Track per-node term hits for multi-term boosting + const camelNodeTerms = new Map(); + const maxCamelPerTerm = Math.ceil(opts.searchLimit / 2); + + for (const sym of symbolsFromQuery) { + const titleCased = sym.charAt(0).toUpperCase() + sym.slice(1).toLowerCase(); + if (titleCased.length < 3) continue; + const termKey = titleCased.toLowerCase(); + if (camelSearchedTerms.has(termKey)) continue; + camelSearchedTerms.add(termKey); + + // Fetch a large batch — popular terms like "Search" in Elasticsearch + // have hundreds of substring matches. The LIKE scan cost is the same + // regardless of LIMIT (SQLite scans all matches to sort), so we fetch + // generously and let path-relevance scoring pick the best ones. + const likeResults = this.queries.findNodesByNameSubstring(titleCased, { + limit: 200, + kinds: camelDefinitionKinds, + excludePrefix: true, + }); + + // Filter to CamelCase boundaries, score by path relevance, and take top N + const termCandidates: SearchResult[] = []; + for (const r of likeResults) { + const name = r.node.name; + const idx = name.indexOf(titleCased); + if (idx <= 0) continue; + // Accept CamelCase boundary (lowercase before match) OR + // acronym boundary (uppercase before match, e.g., RPCProtocol) + if (!/[a-zA-Z]/.test(name.charAt(idx - 1))) continue; + if (searchIdSet.has(r.node.id)) continue; + if (isTestFile(r.node.filePath) && !isTestQuery) continue; + + const pathScore = scorePathRelevance(r.node.filePath, query); + const brevityBonus = Math.max(0, 6 - (name.length - titleCased.length) / 4); + termCandidates.push({ node: r.node, score: 8 + brevityBonus + pathScore }); + } + termCandidates.sort((a, b) => b.score - a.score); + + // Widen the per-term pool for accumulation so multi-term co-occurrences + // can be discovered. A class matching 3 query terms at CamelCase boundaries + // is far more relevant than one matching just 1, but it needs to survive + // the per-term cut for EACH term to accumulate its count. + const accumPerTerm = maxCamelPerTerm * 4; + for (const r of termCandidates.slice(0, accumPerTerm)) { + const existing = camelNodeTerms.get(r.node.id); + if (existing) { + existing.termCount++; + } else { + camelNodeTerms.set(r.node.id, { + result: r, + termCount: 1, + }); + } + } + } + + // Append CamelCase matches with multi-term boost. + // These are structurally important (class names containing query terms at + // CamelCase boundaries) but score much lower than FTS results. Scale their + // scores up so multi-term CamelCase matches can compete with FTS results. + const camelResults: SearchResult[] = []; + for (const [, info] of camelNodeTerms) { + // Multi-term CamelCase matches are extremely relevant — a class matching + // 3+ query terms in its name (e.g., ExtensionHostProcess) is almost + // certainly what the user wants. Scale aggressively. + info.result.score = info.result.score * (1 + info.termCount) + (info.termCount - 1) * 30; + camelResults.push(info.result); + } + camelResults.sort((a, b) => b.score - a.score); + const maxCamelTotal = opts.searchLimit; + for (const r of camelResults.slice(0, maxCamelTotal)) { + searchResults.push(r); + searchIdSet.add(r.node.id); + } + + // Step 5c: Compound term matching — find classes whose name contains 2+ + // query terms at ANY position (not just CamelCase boundaries). + // The CamelCase step above requires idx > 0, which misses classes that + // START with a query term (e.g., "SearchShardsRequest" starts with "Search"). + // For multi-word queries, a class matching multiple query terms in its name + // is almost certainly relevant regardless of position. + if (symbolsFromQuery.length >= 2) { + // Collect ALL LIKE results per term (reusing findNodesByNameSubstring) + // but without the CamelCase boundary or prefix exclusion filters. + const compoundTermMap = new Map }>(); + for (const sym of symbolsFromQuery) { + const titleCased = sym.charAt(0).toUpperCase() + sym.slice(1).toLowerCase(); + if (titleCased.length < 3) continue; + + const likeResults = this.queries.findNodesByNameSubstring(titleCased, { + limit: 200, + kinds: camelDefinitionKinds, + excludePrefix: false, + }); + + for (const r of likeResults) { + if (searchIdSet.has(r.node.id)) continue; + if (isTestFile(r.node.filePath) && !isTestQuery) continue; + const entry = compoundTermMap.get(r.node.id); + if (entry) { + entry.terms.add(titleCased); + } else { + compoundTermMap.set(r.node.id, { node: r.node, terms: new Set([titleCased]) }); + } + } + } + + // Keep only nodes matching 2+ distinct terms + const compoundResults: SearchResult[] = []; + for (const [, entry] of compoundTermMap) { + if (entry.terms.size >= 2) { + const pathScore = scorePathRelevance(entry.node.filePath, query); + const brevityBonus = Math.max(0, 6 - entry.node.name.length / 8); + compoundResults.push({ + node: entry.node, + score: 10 + (entry.terms.size - 1) * 20 + pathScore + brevityBonus, + }); + } + } + compoundResults.sort((a, b) => b.score - a.score); + const maxCompound = Math.ceil(opts.searchLimit / 2); + for (const r of compoundResults.slice(0, maxCompound)) { + searchResults.push(r); + searchIdSet.add(r.node.id); + } + } + } + + // Final sort and truncation — all search channels (exact, text, CamelCase, + // compound) have now contributed. Sort by score so multi-term matches from + // later steps can outrank dampened single-term matches from earlier steps. + searchResults.sort((a, b) => b.score - a.score); + searchResults = searchResults.slice(0, opts.searchLimit * 3); + + // Filter by minimum score + let filteredResults = searchResults.filter((r) => r.score >= opts.minScore); + + // Resolve imports/exports to their actual definitions + // If someone searches "terminal" and finds `import { TerminalPanel }`, + // they want the TerminalPanel class, not the import statement + filteredResults = this.resolveImportsToDefinitions(filteredResults); + + // Cap entry points so traversal budget isn't spread too thin. + // With 36 entry points and maxNodes=120, each gets only 3 nodes — useless. + // Cap to searchLimit so each entry point gets a meaningful traversal budget. + if (filteredResults.length > opts.searchLimit) { + filteredResults = filteredResults.slice(0, opts.searchLimit); + } + + // Confidence signal for the honest-handoff footer (consumed in buildContext). + // A multi-term prose query that resolves only to isolated common-word matches + // — no entry point corroborated by 2+ distinct query terms, and none a + // distinctive identifier the user explicitly named — is LOW confidence: the + // results are best-effort, not a located answer, so the agent should be told + // to drill in with explore/trace rather than trust the list as comprehensive. + // Single-keyword and symbol-name queries are exempt (their single match IS the + // answer), so the handoff never fires on them. + let confidence: 'high' | 'low' = 'high'; + const confTerms = extractSearchTerms(query, { stems: false }).filter(t => t.length >= 3); + if (confTerms.length >= 2 && filteredResults.length > 0) { + const distinctive = new Set( + symbolsFromQuery.filter(isDistinctiveIdentifier).map(s => s.toLowerCase()) + ); + const anyStrong = filteredResults.some(r => { + if (distinctive.has(r.node.name.toLowerCase())) return true; + const nameLower = r.node.name.toLowerCase(); + const dirSegs = path.dirname(r.node.filePath).toLowerCase().split('/'); + let hits = 0; + for (const t of confTerms) { + if (nameLower.includes(t) || dirSegs.includes(t)) { + if (++hits >= 2) return true; + } + } + return false; + }); + if (!anyStrong) confidence = 'low'; + } + + // Add entry points to subgraph + for (const result of filteredResults) { + nodes.set(result.node.id, result.node); + roots.push(result.node.id); + } + + // Expand type hierarchy for class/interface entry points. + // BFS often exhausts its per-entry-point budget on contained methods + // before reaching extends/implements neighbors. This dedicated step + // ensures subclasses and superclasses always appear in results. + // Budget: up to maxNodes/4 hierarchy nodes to avoid flooding. + const typeHierarchyKinds = new Set(['class', 'interface', 'struct', 'trait', 'protocol']); + const maxHierarchyNodes = Math.ceil(opts.maxNodes / 4); + let hierarchyNodesAdded = 0; + for (const result of filteredResults) { + if (hierarchyNodesAdded >= maxHierarchyNodes) break; + if (typeHierarchyKinds.has(result.node.kind)) { + const hierarchy = this.traverser.getTypeHierarchy(result.node.id); + for (const [id, node] of hierarchy.nodes) { + if (!nodes.has(id)) { + nodes.set(id, node); + hierarchyNodesAdded++; + } + } + for (const edge of hierarchy.edges) { + const exists = edges.some( + (e) => e.source === edge.source && e.target === edge.target && e.kind === edge.kind + ); + if (!exists) { + edges.push(edge); + } + } + } + } + + // Pass 2: expand hierarchy of newly-discovered parent types to find siblings. + // E.g., InternalEngine → Engine (parent, from pass 1) → ReadOnlyEngine (sibling). + if (hierarchyNodesAdded > 0) { + const pass2Candidates = [...nodes.values()].filter( + n => typeHierarchyKinds.has(n.kind) && !roots.includes(n.id) + ); + for (const candidate of pass2Candidates) { + if (hierarchyNodesAdded >= maxHierarchyNodes) break; + const siblingHierarchy = this.traverser.getTypeHierarchy(candidate.id); + for (const [id, node] of siblingHierarchy.nodes) { + if (!nodes.has(id) && hierarchyNodesAdded < maxHierarchyNodes) { + nodes.set(id, node); + hierarchyNodesAdded++; + } + } + for (const edge of siblingHierarchy.edges) { + if (nodes.has(edge.source) && nodes.has(edge.target)) { + const exists = edges.some( + (e) => e.source === edge.source && e.target === edge.target && e.kind === edge.kind + ); + if (!exists) { + edges.push(edge); + } + } + } + } + } + + // Traverse from each entry point + for (const result of filteredResults) { + const traversalResult = this.traverser.traverseBFS(result.node.id, { + maxDepth: opts.traversalDepth, + edgeKinds: opts.edgeKinds && opts.edgeKinds.length > 0 ? opts.edgeKinds : undefined, + nodeKinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined, + direction: 'both', + limit: Math.ceil(opts.maxNodes / Math.max(1, filteredResults.length)), + }); + + // Merge nodes + for (const [id, node] of traversalResult.nodes) { + if (!nodes.has(id)) { + nodes.set(id, node); + } + } + + // Merge edges (avoid duplicates) + for (const edge of traversalResult.edges) { + const exists = edges.some( + (e) => e.source === edge.source && e.target === edge.target && e.kind === edge.kind + ); + if (!exists) { + edges.push(edge); + } + } + } + + // Trim to max nodes if needed + let finalNodes = nodes; + let finalEdges = edges; + if (nodes.size > opts.maxNodes) { + // Prioritize entry points and their direct neighbors + const priorityIds = new Set(roots); + for (const edge of edges) { + if (priorityIds.has(edge.source)) { + priorityIds.add(edge.target); + } + if (priorityIds.has(edge.target)) { + priorityIds.add(edge.source); + } + } + + // Keep priority nodes, then fill remaining slots + finalNodes = new Map(); + for (const id of priorityIds) { + const node = nodes.get(id); + if (node && finalNodes.size < opts.maxNodes) { + finalNodes.set(id, node); + } + } + + // Fill remaining from other nodes + for (const [id, node] of nodes) { + if (finalNodes.size >= opts.maxNodes) break; + if (!finalNodes.has(id)) { + finalNodes.set(id, node); + } + } + + // Filter edges to only include kept nodes + finalEdges = edges.filter( + (e) => finalNodes.has(e.source) && finalNodes.has(e.target) + ); + } + + // Per-file diversity cap: prevent any single file from monopolizing the + // node budget. When BFS traverses from a method, it follows `contains` + // to the parent class, then back down to all sibling methods. With + // multiple entry points in the same class, one file can consume 30-40% + // of maxNodes. Cap each file to ~20% to ensure cross-file diversity. + const maxPerFile = Math.max(5, Math.ceil(opts.maxNodes * 0.2)); + const fileCounts = new Map(); + for (const [id, node] of finalNodes) { + const ids = fileCounts.get(node.filePath) || []; + ids.push(id); + fileCounts.set(node.filePath, ids); + } + const rootSet = new Set(roots); + for (const [, nodeIds] of fileCounts) { + if (nodeIds.length <= maxPerFile) continue; + // Sort: entry points first, then classes/interfaces, then others + const kindPriority: Record = { + class: 3, interface: 3, struct: 3, trait: 3, protocol: 3, enum: 3, + method: 1, function: 1, property: 0, field: 0, variable: 0, + }; + nodeIds.sort((a, b) => { + const aRoot = rootSet.has(a) ? 10 : 0; + const bRoot = rootSet.has(b) ? 10 : 0; + const aKind = kindPriority[finalNodes.get(a)!.kind] ?? 0; + const bKind = kindPriority[finalNodes.get(b)!.kind] ?? 0; + return (bRoot + bKind) - (aRoot + aKind); + }); + // Remove excess nodes (keep the highest-priority ones) + for (const id of nodeIds.slice(maxPerFile)) { + finalNodes.delete(id); + } + } + // Non-production node cap: limit test/sample/integration/example files to + // at most 15% of the budget. Many codebases have dozens of near-identical + // test implementations (e.g., 6 Guard classes in integration tests) that + // individually survive score dampening but collectively flood the result. + // Test entry points are NOT exempt — they should be evicted too. + if (!isTestQuery) { + const maxNonProd = Math.max(3, Math.ceil(opts.maxNodes * 0.15)); + const nonProdIds: string[] = []; + for (const [id, node] of finalNodes) { + if (isTestFile(node.filePath)) { + nonProdIds.push(id); + } + } + if (nonProdIds.length > maxNonProd) { + for (const id of nonProdIds.slice(maxNonProd)) { + finalNodes.delete(id); + // Also remove from roots — test file entry points shouldn't anchor results + const rootIdx = roots.indexOf(id); + if (rootIdx !== -1) roots.splice(rootIdx, 1); + } + } + } + + // Re-filter edges after per-file and non-production caps + finalEdges = finalEdges.filter( + (e) => finalNodes.has(e.source) && finalNodes.has(e.target) + ); + + // Edge recovery: BFS with many entry points leaves most nodes disconnected. + // Discover edges between already-selected nodes to recover connectivity. + const recoveryKinds: EdgeKind[] = ['calls', 'extends', 'implements', 'references', 'overrides']; + const recoveredEdges = this.queries.findEdgesBetweenNodes( + [...finalNodes.keys()], + recoveryKinds, + ); + const existingEdgeKeys = new Set( + finalEdges.map((e) => `${e.source}:${e.target}:${e.kind}`) + ); + for (const edge of recoveredEdges) { + const key = `${edge.source}:${edge.target}:${edge.kind}`; + if (!existingEdgeKeys.has(key)) { + finalEdges.push(edge); + existingEdgeKeys.add(key); + } + } + + return { nodes: finalNodes, edges: finalEdges, roots, confidence }; + } + + /** + * Get the source code for a node + * + * Reads the file and extracts the code between startLine and endLine. + * + * @param nodeId - ID of the node + * @returns Code string or null if not found + */ + async getCode(nodeId: string): Promise { + const node = this.queries.getNodeById(nodeId); + if (!node) { + return null; + } + + return this.extractNodeCode(node); + } + + /** + * Extract code from a node's source file + */ + private async extractNodeCode(node: Node): Promise { + // SECURITY (#383): a config-leaf node's on-disk line is `key = `. + // Return the KEY only — never read the value off disk. This closes the + // includeCode / buildContext code-block path, mirroring the explore source + // renderer; an agent that genuinely needs a value can read the file itself. + if (isConfigLeafNode(node)) { + return node.signature || node.qualifiedName || node.name; + } + + const filePath = validatePathWithinRoot(this.projectRoot, node.filePath); + + if (!filePath || !fs.existsSync(filePath)) { + return null; + } + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + // Extract lines (1-indexed to 0-indexed) + const startIdx = Math.max(0, node.startLine - 1); + const endIdx = Math.min(lines.length, node.endLine); + + return lines.slice(startIdx, endIdx).join('\n'); + } catch (error) { + logDebug('Failed to extract code from node', { nodeId: node.id, filePath: node.filePath, error: String(error) }); + return null; + } + } + + /** + * Get entry points from a subgraph (the root nodes) + */ + private getEntryPoints(subgraph: Subgraph): Node[] { + return subgraph.roots + .map((id) => subgraph.nodes.get(id)) + .filter((n): n is Node => n !== undefined); + } + + /** + * Extract code blocks for key nodes in the subgraph + */ + private async extractCodeBlocks( + subgraph: Subgraph, + maxBlocks: number, + maxBlockSize: number + ): Promise { + const blocks: CodeBlock[] = []; + + // Prioritize entry points, then functions/methods + const priorityNodes: Node[] = []; + + // First: entry points + for (const id of subgraph.roots) { + const node = subgraph.nodes.get(id); + if (node) { + priorityNodes.push(node); + } + } + + // Then: functions and methods + for (const node of subgraph.nodes.values()) { + if (!subgraph.roots.includes(node.id)) { + if (node.kind === 'function' || node.kind === 'method') { + priorityNodes.push(node); + } + } + } + + // Then: classes + for (const node of subgraph.nodes.values()) { + if (!subgraph.roots.includes(node.id)) { + if (node.kind === 'class') { + priorityNodes.push(node); + } + } + } + + // Extract code for priority nodes + for (const node of priorityNodes) { + if (blocks.length >= maxBlocks) break; + + const code = await this.extractNodeCode(node); + if (code) { + // Truncate if too long. Language-neutral marker (no `//` — not a + // comment in Python, Ruby, etc.); this renders inside a fenced + // source block whose language varies. + const truncated = code.length > maxBlockSize + ? code.slice(0, maxBlockSize) + '\n... (truncated) ...' + : code; + + blocks.push({ + content: truncated, + filePath: node.filePath, + startLine: node.startLine, + endLine: node.endLine, + language: node.language, + node, + }); + } + } + + return blocks; + } + + /** + * Get unique files from a subgraph + */ + private getRelatedFiles(subgraph: Subgraph): string[] { + const files = new Set(); + for (const node of subgraph.nodes.values()) { + files.add(node.filePath); + } + return Array.from(files).sort(); + } + + /** + * Generate a summary of the context + */ + private generateSummary(_query: string, subgraph: Subgraph, entryPoints: Node[]): string { + const nodeCount = subgraph.nodes.size; + const edgeCount = subgraph.edges.length; + const files = this.getRelatedFiles(subgraph); + + const entryPointNames = entryPoints + .slice(0, 3) + .map((n) => n.name) + .join(', '); + + const remaining = entryPoints.length > 3 ? ` and ${entryPoints.length - 3} more` : ''; + + return `Found ${nodeCount} relevant code symbols across ${files.length} files. ` + + `Key entry points: ${entryPointNames}${remaining}. ` + + `${edgeCount} relationships identified.`; + } + + /** + * Resolve import/export nodes to their actual definitions + * + * When search returns `import { TerminalPanel }`, users want the TerminalPanel + * class definition, not the import statement. This follows the `imports` edge + * to find and return the actual definition instead. + * + * @param results - Search results that may include import/export nodes + * @returns Results with imports resolved to definitions where possible + */ + private resolveImportsToDefinitions(results: SearchResult[]): SearchResult[] { + const resolved: SearchResult[] = []; + const seenIds = new Set(); + + for (const result of results) { + const { node, score } = result; + + // If it's not an import/export, keep it as-is + if (node.kind !== 'import' && node.kind !== 'export') { + if (!seenIds.has(node.id)) { + seenIds.add(node.id); + resolved.push(result); + } + continue; + } + + // For imports/exports, try to find what they reference + // Imports have outgoing 'imports' edges to the definition + // Exports have outgoing 'exports' edges to the definition + const edgeKind = node.kind === 'import' ? 'imports' : 'exports'; + const outgoingEdges = this.queries.getOutgoingEdges(node.id, [edgeKind as EdgeKind]); + + let foundDefinition = false; + for (const edge of outgoingEdges) { + const targetNode = this.queries.getNodeById(edge.target); + if (targetNode && !seenIds.has(targetNode.id)) { + // Found the definition - use it instead of the import + seenIds.add(targetNode.id); + resolved.push({ + node: targetNode, + score: score, // Preserve the original score + }); + foundDefinition = true; + logDebug('Resolved import to definition', { + import: node.name, + definition: targetNode.name, + kind: targetNode.kind, + }); + } + } + + // If we couldn't resolve the import, skip it (it's low-value on its own) + if (!foundDefinition) { + logDebug('Skipping unresolved import', { name: node.name, file: node.filePath }); + } + } + + return resolved; + } +} + +/** + * Create a context builder + */ +export function createContextBuilder( + projectRoot: string, + queries: QueryBuilder, + traverser: GraphTraverser +): ContextBuilder { + return new ContextBuilder(projectRoot, queries, traverser); +} + +// Re-export formatter +export { formatContextAsMarkdown, formatContextAsJson } from './formatter'; diff --git a/packages/atlas/src/context/markers.ts b/packages/atlas/src/context/markers.ts new file mode 100644 index 0000000..13f1968 --- /dev/null +++ b/packages/atlas/src/context/markers.ts @@ -0,0 +1,19 @@ +/** + * Stable sentinel strings shared between the context builder (which emits them + * into its markdown) and the MCP layer (which detects them to adjust framing). + * + * Intentionally a dependency-free leaf module: the MCP tool layer imports this + * to recognise a low-confidence response, and routing that recognition through + * the full context module would drag its dependencies onto the cold-start path. + * Keep this file import-free. + */ + +/** + * Heading that leads the honest low-confidence handoff appended to a context + * response when the query resolved only to weak/isolated matches. The MCP layer + * checks for it to suppress the contradictory "this is comprehensive, don't call + * explore" small-repo footer. Changing the text is a breaking sentinel change — + * both the emitter (`ContextBuilder`) and the detector (`src/mcp/tools.ts`) + * import this constant, so they stay in sync automatically. + */ +export const LOW_CONFIDENCE_MARKER = '### ⚠️ Low-confidence match'; diff --git a/packages/atlas/src/db/index.ts b/packages/atlas/src/db/index.ts new file mode 100644 index 0000000..5f7fd1a --- /dev/null +++ b/packages/atlas/src/db/index.ts @@ -0,0 +1,328 @@ +/** + * Database Layer + * + * Handles SQLite database initialization and connection management. + */ + +import { SqliteDatabase, SqliteBackend, createDatabase } from './sqlite-adapter'; +import * as fs from 'fs'; +import * as path from 'path'; +import { SchemaVersion } from '../types'; +import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations'; +import { getAtlasDir } from '../directory'; + +export { SqliteDatabase, SqliteBackend } from './sqlite-adapter'; + +/** + * Apply connection-level PRAGMAs. Shared by `initialize` and `open` so the two + * paths can't drift. + * + * `busy_timeout` is set FIRST, before any pragma that might touch the database + * file (notably `journal_mode`). If another process holds a write lock at open + * time, the later pragmas — and the connection's first query — then wait out + * the lock instead of throwing "database is locked" immediately. See issue #238. + * + * The 5s window (was 120s) rides out a normal incremental sync; the old + * 2-minute wait presented as a frozen, hung agent. With WAL, reads never block + * on a writer, so this timeout only governs cross-process write contention + * (e.g. the git-hook `atlas sync` running while the MCP server writes). + */ +function configureConnection(db: SqliteDatabase): void { + db.pragma('busy_timeout = 5000'); // MUST be first — see above + db.pragma('foreign_keys = ON'); + db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform + db.pragma('synchronous = NORMAL'); // safe with WAL mode + db.pragma('cache_size = -64000'); // 64 MB page cache + db.pragma('temp_store = MEMORY'); // temp tables in memory + db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O +} + +/** + * Database connection wrapper with lifecycle management + */ +export class DatabaseConnection { + private db: SqliteDatabase; + private dbPath: string; + private backend: SqliteBackend; + /** + * `dev:ino` of the DB file at the moment we opened it (or null when the + * platform/filesystem reports no usable inode). Lets us notice when the file + * we hold open has been unlinked and REPLACED by a new file at the same path + * — a git worktree removed and re-added, or `.atlas/` deleted and + * re-`init`ed under a long-lived server — at which point our fd reads a now + * dead inode forever (#925). See `isReplacedOnDisk`. + */ + private openedInode: string | null; + + private constructor(db: SqliteDatabase, dbPath: string, backend: SqliteBackend) { + this.db = db; + this.dbPath = dbPath; + this.backend = backend; + this.openedInode = statInode(dbPath); + } + + /** + * Initialize a new database at the given path + */ + static initialize(dbPath: string): DatabaseConnection { + // Ensure parent directory exists + const dir = path.dirname(dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Create and configure database + const { db, backend } = createDatabase(dbPath); + + configureConnection(db); + + // Run schema initialization + const schemaPath = path.join(__dirname, 'schema.sql'); + const schema = fs.readFileSync(schemaPath, 'utf-8'); + db.exec(schema); + + // Record current schema version so migrations aren't re-applied on open + const currentVersion = getCurrentVersion(db); + if (currentVersion < CURRENT_SCHEMA_VERSION) { + db.prepare( + 'INSERT OR IGNORE INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)' + ).run(CURRENT_SCHEMA_VERSION, Date.now(), 'Initial schema includes all migrations'); + } + + return new DatabaseConnection(db, dbPath, backend); + } + + /** + * Open an existing database + */ + static open(dbPath: string): DatabaseConnection { + if (!fs.existsSync(dbPath)) { + throw new Error(`Database not found: ${dbPath}`); + } + + const { db, backend } = createDatabase(dbPath); + + configureConnection(db); + + // Check and run migrations if needed + const conn = new DatabaseConnection(db, dbPath, backend); + const currentVersion = getCurrentVersion(db); + + if (currentVersion < CURRENT_SCHEMA_VERSION) { + runMigrations(db, currentVersion); + } + + return conn; + } + + /** + * Get the underlying database instance + */ + getDb(): SqliteDatabase { + return this.db; + } + + /** + * Get the SQLite backend serving this connection. Per-instance so + * MCP cross-project queries report the right backend even when + * multiple project DBs are open in the same process. + */ + getBackend(): SqliteBackend { + return this.backend; + } + + /** + * Get database file path + */ + getPath(): string { + return this.dbPath; + } + + /** + * The journal mode actually in effect (e.g. 'wal', 'delete'). + * + * SQLite silently keeps the prior mode if WAL can't be enabled — e.g. on + * filesystems without shared-memory support (some network/virtualized mounts, + * WSL2 /mnt). So the effective mode can differ + * from what `configureConnection` requested. Surfaced in `atlas status` so + * a "database is locked" report is triageable: 'wal' ⇒ readers never block on a + * writer; anything else ⇒ they can. See issue #238. + */ + getJournalMode(): string { + const raw = this.db.pragma('journal_mode'); + const row = Array.isArray(raw) ? raw[0] : raw; + const mode = row && typeof row === 'object' + ? (row as Record).journal_mode + : row; + return String(mode ?? '').toLowerCase(); + } + + /** + * Get current schema version + */ + getSchemaVersion(): SchemaVersion | null { + const row = this.db + .prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version DESC LIMIT 1') + .get() as { version: number; applied_at: number; description: string | null } | undefined; + + if (!row) return null; + + return { + version: row.version, + appliedAt: row.applied_at, + description: row.description ?? undefined, + }; + } + + /** + * Execute a function within a transaction + */ + transaction(fn: () => T): T { + return this.db.transaction(fn)(); + } + + /** + * Get database file size in bytes + */ + getSize(): number { + const stats = fs.statSync(this.dbPath); + return stats.size; + } + + /** + * Optimize database (vacuum and analyze) + */ + optimize(): void { + this.db.exec('VACUUM'); + this.db.exec('ANALYZE'); + } + + /** + * Lightweight, non-blocking maintenance to run after bulk writes + * (indexAll, sync). Two operations: + * + * - `PRAGMA optimize` — incremental ANALYZE; SQLite only re-analyzes + * tables whose row counts changed materially since the last + * ANALYZE. Without it, the query planner has no statistics on the + * freshly-bulk-loaded tables and can pick suboptimal indexes. + * + * - `PRAGMA wal_checkpoint(PASSIVE)` — fold pending WAL pages back + * into the main database file so the WAL file doesn't grow + * unboundedly between automatic checkpoints (auto-fires at 1000 + * pages by default; large indexAll runs blow past that). + * + * Both operations are silently swallowed on failure — they're a + * best-effort optimization, never load-bearing for correctness. + */ + runMaintenance(): void { + try { + this.db.exec('PRAGMA optimize'); + } catch { + // ignore + } + try { + this.db.exec('PRAGMA wal_checkpoint(PASSIVE)'); + } catch { + // ignore (e.g., not in WAL mode) + } + } + + /** + * Close the database connection + */ + close(): void { + this.db.close(); + } + + /** + * Check if the database connection is open + */ + isOpen(): boolean { + return this.db.open; + } + + /** + * True when the DB file at our path has been REPLACED on disk since we opened + * it — a different inode now lives at the same path, so the fd we still hold + * points at a now-unlinked inode that can never receive new writes (#925). + * The trigger is removing and recreating `.atlas/` at the same path under + * a long-lived process (`git worktree remove` + re-add, or `rm -rf + * .atlas` + `atlas init`). Returns false when the inode is unchanged, + * when the file is momentarily absent (mid-recreate — nothing to reopen onto + * yet), or when the platform doesn't report a usable inode (Windows can't + * unlink an open file and its st_ino is unreliable, so this never fires there). + */ + isReplacedOnDisk(): boolean { + if (this.openedInode === null) return false; + const current = statInode(this.dbPath); + return current !== null && current !== this.openedInode; + } +} + +/** + * `dev:ino` for a path, or null if it can't be stat'd or the platform doesn't + * report a usable inode. Windows st_ino is unreliable across handle reopens, so + * we deliberately return null there — the deleted-but-open-inode hazard this + * guards (#925) is a POSIX file-semantics issue that doesn't arise on Windows + * (an open file can't be unlinked). + */ +function statInode(p: string): string | null { + if (process.platform === 'win32') return null; + try { + const s = fs.statSync(p); + return `${s.dev}:${s.ino}`; + } catch { + return null; + } +} + +/** + * Default database filename + */ +export const DATABASE_FILENAME = 'atlas.db'; + +/** + * SQLite's sidecar files in WAL mode — the write-ahead log and its shared-memory + * index. They sit beside the main DB file and are removed alongside it when the + * database is discarded (see `removeDatabaseFiles`). + */ +const WAL_SIDECAR_SUFFIXES = ['-wal', '-shm'] as const; + +/** + * Get the default database path for a project + */ +export function getDatabasePath(projectRoot: string): string { + return path.join(getAtlasDir(projectRoot), DATABASE_FILENAME); +} + +/** + * Delete a database file and its WAL sidecars (`-wal`/`-shm`). + * + * This is how a FULL re-index discards an existing database — rather than + * opening the old graph and DELETE-ing every row. On a large or pre-fix + * poisoned index (e.g. an old graph that scanned an ignored gitlink corpus into + * ~1.6M nodes with a multi-GB WAL, #1065) the per-row `nodes_fts` delete-trigger + * churn blocks the main thread long enough to trip the #850 liveness watchdog + * before indexing even starts, so the rebuild could never recover the bad state + * (#1067). Unlinking is O(1) regardless of DB size and also reclaims the disk + * the bloated WAL would otherwise keep. + * + * POSIX removes the directory entry even while another process (a daemon/MCP + * server) still holds the file open; that holder heals via `reopenIfReplaced` + * (#925). On Windows a live holder can make the unlink fail with EBUSY/EPERM — + * that is thrown for the caller to surface ("stop the other process and retry"). + * The `-wal`/`-shm` sidecars are best-effort: SQLite recreates them on the next + * open, so a leftover sidecar is harmless. + */ +export function removeDatabaseFiles(dbPath: string): void { + // The main DB file first — its removal is the operation that must succeed (or + // report why it couldn't). force:true treats an already-missing file as done. + fs.rmSync(dbPath, { force: true }); + for (const suffix of WAL_SIDECAR_SUFFIXES) { + try { + fs.rmSync(dbPath + suffix, { force: true }); + } catch { + // A sidecar still held/locked is harmless — SQLite rebuilds it on open. + } + } +} diff --git a/packages/atlas/src/db/migrations.ts b/packages/atlas/src/db/migrations.ts new file mode 100644 index 0000000..4f38b56 --- /dev/null +++ b/packages/atlas/src/db/migrations.ts @@ -0,0 +1,184 @@ +/** + * Database Migrations + * + * Schema versioning and migration support. + */ + +import { SqliteDatabase } from './sqlite-adapter'; + +/** + * Current schema version + */ +export const CURRENT_SCHEMA_VERSION = 6; + +/** + * Migration definition + */ +interface Migration { + version: number; + description: string; + up: (db: SqliteDatabase) => void; +} + +/** + * All migrations in order + * + * Note: Version 1 is the initial schema, handled by schema.sql + * Future migrations go here. + */ +const migrations: Migration[] = [ + { + version: 2, + description: 'Add project metadata, provenance tracking, and unresolved ref context', + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS project_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + ALTER TABLE unresolved_refs ADD COLUMN file_path TEXT NOT NULL DEFAULT ''; + ALTER TABLE unresolved_refs ADD COLUMN language TEXT NOT NULL DEFAULT 'unknown'; + ALTER TABLE edges ADD COLUMN provenance TEXT DEFAULT NULL; + CREATE INDEX IF NOT EXISTS idx_unresolved_file_path ON unresolved_refs(file_path); + CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance); + `); + }, + }, + { + version: 3, + description: 'Add lower(name) expression index for memory-efficient case-insensitive lookups', + up: (db) => { + db.exec(` + CREATE INDEX IF NOT EXISTS idx_nodes_lower_name ON nodes(lower(name)); + `); + }, + }, + { + version: 4, + description: + 'Drop redundant idx_edges_source / idx_edges_target (covered by source_kind / target_kind composites)', + up: (db) => { + db.exec(` + DROP INDEX IF EXISTS idx_edges_source; + DROP INDEX IF EXISTS idx_edges_target; + `); + }, + }, + { + version: 5, + description: + 'Add nodes.return_type — normalized return/result type for receiver-type inference (C++ singletons/factories, #645)', + up: (db) => { + db.exec(` + ALTER TABLE nodes ADD COLUMN return_type TEXT; + `); + }, + }, + { + version: 6, + description: + 'Dedup duplicate edge rows and add a UNIQUE identity index so INSERT OR IGNORE actually dedups (#1034)', + up: (db) => { + // `insertEdge` has always used `INSERT OR IGNORE`, but the edges table had + // no UNIQUE constraint, so nothing conflicted and byte-identical rows + // accumulated whenever two passes emitted the same edge. Collapse each + // identity group to its lowest id, then add the constraint that makes + // `OR IGNORE` keep its promise. IFNULL folds nullable line/col so + // coordinate-less edges dedup too (SQLite treats each NULL as distinct) — + // and it MUST match the GROUP BY exactly, or the index creation would + // fail on a pair the DELETE left behind. Idempotent: the index is + // `IF NOT EXISTS` and the DELETE is a no-op once the table is unique. + db.exec(` + DELETE FROM edges + WHERE id NOT IN ( + SELECT MIN(id) FROM edges + GROUP BY source, target, kind, IFNULL(line, -1), IFNULL(col, -1) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_edges_identity + ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1)); + `); + }, + }, +]; + +/** + * Get the current schema version from the database + */ +export function getCurrentVersion(db: SqliteDatabase): number { + try { + const row = db + .prepare('SELECT MAX(version) as version FROM schema_versions') + .get() as { version: number | null } | undefined; + return row?.version ?? 0; + } catch { + // Table doesn't exist yet + return 0; + } +} + +/** + * Record a migration as applied + */ +function recordMigration(db: SqliteDatabase, version: number, description: string): void { + db.prepare( + 'INSERT INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)' + ).run(version, Date.now(), description); +} + +/** + * Run all pending migrations + */ +export function runMigrations(db: SqliteDatabase, fromVersion: number): void { + const pending = migrations.filter((m) => m.version > fromVersion); + + if (pending.length === 0) { + return; + } + + // Sort by version + pending.sort((a, b) => a.version - b.version); + + // Run each migration in a transaction + for (const migration of pending) { + db.transaction(() => { + migration.up(db); + recordMigration(db, migration.version, migration.description); + })(); + } +} + +/** + * Check if the database needs migration + */ +export function needsMigration(db: SqliteDatabase): boolean { + const current = getCurrentVersion(db); + return current < CURRENT_SCHEMA_VERSION; +} + +/** + * Get list of pending migrations + */ +export function getPendingMigrations(db: SqliteDatabase): Migration[] { + const current = getCurrentVersion(db); + return migrations + .filter((m) => m.version > current) + .sort((a, b) => a.version - b.version); +} + +/** + * Get migration history from database + */ +export function getMigrationHistory( + db: SqliteDatabase +): Array<{ version: number; appliedAt: number; description: string | null }> { + const rows = db + .prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version') + .all() as Array<{ version: number; applied_at: number; description: string | null }>; + + return rows.map((row) => ({ + version: row.version, + appliedAt: row.applied_at, + description: row.description, + })); +} diff --git a/packages/atlas/src/db/queries.ts b/packages/atlas/src/db/queries.ts new file mode 100644 index 0000000..9053532 --- /dev/null +++ b/packages/atlas/src/db/queries.ts @@ -0,0 +1,1873 @@ +/** + * Database Queries + * + * Prepared statements for CRUD operations on the knowledge graph. + */ + +import { SqliteDatabase, SqliteStatement } from './sqlite-adapter'; +import { + Node, + Edge, + FileRecord, + UnresolvedReference, + NodeKind, + EdgeKind, + Language, + GraphStats, + SearchOptions, + SearchResult, +} from '../types'; +import { safeJsonParse } from '../utils'; +import { kindBonus, nameMatchBonus, scorePathRelevance } from '../search/query-utils'; +import { parseQuery, boundedEditDistance } from '../search/query-parser'; +import { isGeneratedFile } from '../extraction/generated-detection'; + +/** + * Path-only heuristic for files that should not be candidates for + * "dominant file" detection: test/spec files and tool-generated files. + * Generated files (`*.pb.go`, `*.pulsar.go`, mock outputs, …) often + * have huge in-file edge counts that dwarf the real source — etcd's + * `rpc.pb.go` has 4× the in-file edges of `server.go`. + */ +function isLowValueFile(filePath: string): boolean { + const lp = filePath.toLowerCase(); + return ( + /(?:^|\/)(tests?|__tests?__|spec)\//.test(lp) || + /_test\.go$/.test(lp) || + /(?:^|\/)test_[^/]+\.py$/.test(lp) || + /_test\.py$/.test(lp) || + /_spec\.rb$/.test(lp) || + /_test\.rb$/.test(lp) || + /\.(test|spec)\.[jt]sx?$/.test(lp) || + /(test|spec|tests)\.(java|kt|scala)$/.test(lp) || + /(tests?|spec)\.cs$/.test(lp) || + /tests?\.swift$/.test(lp) || + /_test\.dart$/.test(lp) || + isGeneratedFile(filePath) + ); +} + +const SQLITE_PARAM_CHUNK_SIZE = 500; + +/** + * Database row types (snake_case from SQLite) + */ +interface NodeRow { + id: string; + kind: string; + name: string; + qualified_name: string; + file_path: string; + language: string; + start_line: number; + end_line: number; + start_column: number; + end_column: number; + docstring: string | null; + signature: string | null; + visibility: string | null; + is_exported: number; + is_async: number; + is_static: number; + is_abstract: number; + decorators: string | null; + type_parameters: string | null; + return_type: string | null; + updated_at: number; +} + +interface EdgeRow { + id: number; + source: string; + target: string; + kind: string; + metadata: string | null; + line: number | null; + col: number | null; + provenance: string | null; +} + +interface FileRow { + path: string; + content_hash: string; + language: string; + size: number; + modified_at: number; + indexed_at: number; + node_count: number; + errors: string | null; +} + +interface UnresolvedRefRow { + id: number; + from_node_id: string; + reference_name: string; + reference_kind: string; + line: number; + col: number; + candidates: string | null; + file_path: string; + language: string; +} + +/** + * Convert database row to Node object + */ +function rowToNode(row: NodeRow): Node { + return { + id: row.id, + kind: row.kind as NodeKind, + name: row.name, + qualifiedName: row.qualified_name, + filePath: row.file_path, + language: row.language as Language, + startLine: row.start_line, + endLine: row.end_line, + startColumn: row.start_column, + endColumn: row.end_column, + docstring: row.docstring ?? undefined, + signature: row.signature ?? undefined, + visibility: row.visibility as Node['visibility'], + isExported: row.is_exported === 1, + isAsync: row.is_async === 1, + isStatic: row.is_static === 1, + isAbstract: row.is_abstract === 1, + decorators: row.decorators ? safeJsonParse(row.decorators, undefined) : undefined, + typeParameters: row.type_parameters ? safeJsonParse(row.type_parameters, undefined) : undefined, + returnType: row.return_type ?? undefined, + updatedAt: row.updated_at, + }; +} + +/** + * Convert database row to Edge object + */ +function rowToEdge(row: EdgeRow): Edge { + return { + source: row.source, + target: row.target, + kind: row.kind as EdgeKind, + metadata: row.metadata ? safeJsonParse(row.metadata, undefined) : undefined, + line: row.line ?? undefined, + column: row.col ?? undefined, + provenance: row.provenance as Edge['provenance'], + }; +} + +/** + * Convert database row to FileRecord object + */ +function rowToFileRecord(row: FileRow): FileRecord { + return { + path: row.path, + contentHash: row.content_hash, + language: row.language as Language, + size: row.size, + modifiedAt: row.modified_at, + indexedAt: row.indexed_at, + nodeCount: row.node_count, + errors: row.errors ? safeJsonParse(row.errors, undefined) : undefined, + }; +} + +/** + * Query builder for the knowledge graph database + */ +export class QueryBuilder { + private db: SqliteDatabase; + + // Project-name tokens (go.mod / package.json / repo dir), normalized. A query + // word matching one is dropped from path-relevance scoring — it names the + // whole project, not a symbol, so it carries no discriminative signal (#720). + // Set once by the Atlas instance; empty by default (no down-weighting). + private projectNameTokens: Set = new Set(); + + // Node cache for frequently accessed nodes (LRU-style, max 1000 entries) + private nodeCache: Map = new Map(); + private readonly maxCacheSize = 1000; + + // Prepared statements (lazily initialized) + private stmts: { + insertNode?: SqliteStatement; + updateNode?: SqliteStatement; + deleteNode?: SqliteStatement; + deleteNodesByFile?: SqliteStatement; + getNodeById?: SqliteStatement; + getNodesByFile?: SqliteStatement; + getNodesByKind?: SqliteStatement; + insertEdge?: SqliteStatement; + upsertFile?: SqliteStatement; + deleteEdgesBySource?: SqliteStatement; + deleteEdgesByTarget?: SqliteStatement; + getEdgesBySource?: SqliteStatement; + getEdgesByTarget?: SqliteStatement; + insertFile?: SqliteStatement; + updateFile?: SqliteStatement; + deleteFile?: SqliteStatement; + getFileByPath?: SqliteStatement; + getAllFiles?: SqliteStatement; + insertUnresolved?: SqliteStatement; + deleteUnresolvedByNode?: SqliteStatement; + getUnresolvedByName?: SqliteStatement; + getNodesByName?: SqliteStatement; + getNodesByQualifiedNameExact?: SqliteStatement; + getNodesByLowerName?: SqliteStatement; + getUnresolvedCount?: SqliteStatement; + getUnresolvedBatch?: SqliteStatement; + getAllFilePaths?: SqliteStatement; + getAllNodeNames?: SqliteStatement; + getDominantFile?: SqliteStatement; + getTopRouteFile?: SqliteStatement; + getRoutingManifest?: SqliteStatement; + } = {}; + + constructor(db: SqliteDatabase) { + this.db = db; + } + + /** Set the normalized project-name tokens used to down-weight non-discriminative + * query words in path scoring (#720). Called once when the project opens. */ + setProjectNameTokens(tokens: Set): void { + this.projectNameTokens = tokens; + } + + /** The normalized project-name tokens (#720); empty if none were derived. */ + getProjectNameTokens(): Set { + return this.projectNameTokens; + } + + // =========================================================================== + // Node Operations + // =========================================================================== + + /** + * Insert a new node + */ + insertNode(node: Node): void { + if (!this.stmts.insertNode) { + this.stmts.insertNode = this.db.prepare(` + INSERT OR REPLACE INTO nodes ( + id, kind, name, qualified_name, file_path, language, + start_line, end_line, start_column, end_column, + docstring, signature, visibility, + is_exported, is_async, is_static, is_abstract, + decorators, type_parameters, return_type, updated_at + ) VALUES ( + @id, @kind, @name, @qualifiedName, @filePath, @language, + @startLine, @endLine, @startColumn, @endColumn, + @docstring, @signature, @visibility, + @isExported, @isAsync, @isStatic, @isAbstract, + @decorators, @typeParameters, @returnType, @updatedAt + ) + `); + } + + // Validate required fields to prevent SQLite bind errors + if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) { + console.error('[Atlas] Skipping node with missing required fields:', { + id: node.id, + kind: node.kind, + name: node.name, + filePath: node.filePath, + language: node.language, + }); + return; + } + + // INSERT OR REPLACE may overwrite a node we have cached. Drop the + // stale entry so the next getNodeById sees the new row, not the old + // one (matches the cache-invalidation pattern used by updateNode and + // deleteNode below). + this.nodeCache.delete(node.id); + + this.stmts.insertNode.run({ + id: node.id, + kind: node.kind, + name: node.name, + qualifiedName: node.qualifiedName ?? node.name, + filePath: node.filePath, + language: node.language, + startLine: node.startLine ?? 0, + endLine: node.endLine ?? 0, + startColumn: node.startColumn ?? 0, + endColumn: node.endColumn ?? 0, + docstring: node.docstring ?? null, + signature: node.signature ?? null, + visibility: node.visibility ?? null, + isExported: node.isExported ? 1 : 0, + isAsync: node.isAsync ? 1 : 0, + isStatic: node.isStatic ? 1 : 0, + isAbstract: node.isAbstract ? 1 : 0, + decorators: node.decorators ? JSON.stringify(node.decorators) : null, + typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null, + returnType: node.returnType ?? null, + updatedAt: node.updatedAt ?? Date.now(), + }); + } + + /** + * Insert multiple nodes in a transaction + */ + insertNodes(nodes: Node[]): void { + this.db.transaction(() => { + for (const node of nodes) { + this.insertNode(node); + } + })(); + } + + /** + * Update an existing node + */ + updateNode(node: Node): void { + if (!this.stmts.updateNode) { + this.stmts.updateNode = this.db.prepare(` + UPDATE nodes SET + kind = @kind, + name = @name, + qualified_name = @qualifiedName, + file_path = @filePath, + language = @language, + start_line = @startLine, + end_line = @endLine, + start_column = @startColumn, + end_column = @endColumn, + docstring = @docstring, + signature = @signature, + visibility = @visibility, + is_exported = @isExported, + is_async = @isAsync, + is_static = @isStatic, + is_abstract = @isAbstract, + decorators = @decorators, + type_parameters = @typeParameters, + return_type = @returnType, + updated_at = @updatedAt + WHERE id = @id + `); + } + + // Invalidate cache before update + this.nodeCache.delete(node.id); + + // Validate required fields + if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) { + console.error('[Atlas] Skipping node update with missing required fields:', node.id); + return; + } + + this.stmts.updateNode.run({ + id: node.id, + kind: node.kind, + name: node.name, + qualifiedName: node.qualifiedName ?? node.name, + filePath: node.filePath, + language: node.language, + startLine: node.startLine ?? 0, + endLine: node.endLine ?? 0, + startColumn: node.startColumn ?? 0, + endColumn: node.endColumn ?? 0, + docstring: node.docstring ?? null, + signature: node.signature ?? null, + visibility: node.visibility ?? null, + isExported: node.isExported ? 1 : 0, + isAsync: node.isAsync ? 1 : 0, + isStatic: node.isStatic ? 1 : 0, + isAbstract: node.isAbstract ? 1 : 0, + decorators: node.decorators ? JSON.stringify(node.decorators) : null, + typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null, + returnType: node.returnType ?? null, + updatedAt: node.updatedAt ?? Date.now(), + }); + } + + /** + * Delete a node by ID + */ + deleteNode(id: string): void { + if (!this.stmts.deleteNode) { + this.stmts.deleteNode = this.db.prepare('DELETE FROM nodes WHERE id = ?'); + } + // Invalidate cache + this.nodeCache.delete(id); + this.stmts.deleteNode.run(id); + } + + /** + * Delete all nodes for a file + */ + deleteNodesByFile(filePath: string): void { + if (!this.stmts.deleteNodesByFile) { + this.stmts.deleteNodesByFile = this.db.prepare('DELETE FROM nodes WHERE file_path = ?'); + } + // Invalidate cache for nodes in this file + for (const [id, node] of this.nodeCache) { + if (node.filePath === filePath) { + this.nodeCache.delete(id); + } + } + this.stmts.deleteNodesByFile.run(filePath); + } + + /** + * Get a node by ID + */ + getNodeById(id: string): Node | null { + // Check cache first + if (this.nodeCache.has(id)) { + const cached = this.nodeCache.get(id)!; + // Move to end to implement LRU (delete and re-add) + this.nodeCache.delete(id); + this.nodeCache.set(id, cached); + return cached; + } + + if (!this.stmts.getNodeById) { + this.stmts.getNodeById = this.db.prepare('SELECT * FROM nodes WHERE id = ?'); + } + const row = this.stmts.getNodeById.get(id) as NodeRow | undefined; + if (!row) { + return null; + } + + const node = rowToNode(row); + this.cacheNode(node); + return node; + } + + /** + * Batch lookup: fetch many nodes by ID in a single SQL round-trip. + * + * Replaces the N+1 pattern in graph traversal where every edge would + * trigger its own `getNodeById` call. For a function with 50 callers + * this collapses 50 point reads into one IN-list query (~10-50x + * faster end-to-end). + * + * Returns a Map keyed by id so callers can preserve their own ordering + * (typically the order edges were returned from the graph). Missing IDs + * are simply absent from the map. + * + * Cache-aware: ids already in the LRU cache are served from memory and + * the SQL query only touches the misses. + */ + getNodesByIds(ids: readonly string[]): Map { + const out = new Map(); + if (ids.length === 0) return out; + + // Serve cache hits first; build the miss list for SQL. + const misses: string[] = []; + for (const id of ids) { + const cached = this.nodeCache.get(id); + if (cached !== undefined) { + // LRU touch + this.nodeCache.delete(id); + this.nodeCache.set(id, cached); + out.set(id, cached); + } else { + misses.push(id); + } + } + if (misses.length === 0) return out; + + // Chunk under SQLite's parameter limit (default 999, raised to 32766 + // in better-sqlite3 builds — chunk at 500 for safety across both + // backends and to keep the query plan simple). + for (let i = 0; i < misses.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = misses.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare(`SELECT * FROM nodes WHERE id IN (${placeholders})`) + .all(...chunk) as NodeRow[]; + for (const row of rows) { + const node = rowToNode(row); + out.set(node.id, node); + this.cacheNode(node); + } + } + return out; + } + + private getExistingNodeIds(ids: readonly string[]): Set { + const out = new Set(); + if (ids.length === 0) return out; + + const uniqueIds = [...new Set(ids)]; + for (let i = 0; i < uniqueIds.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = uniqueIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare(`SELECT id FROM nodes WHERE id IN (${placeholders})`) + .all(...chunk) as { id: string }[]; + for (const row of rows) { + out.add(row.id); + } + } + + return out; + } + + /** + * Add a node to the cache, evicting oldest if needed + */ + private cacheNode(node: Node): void { + if (this.nodeCache.size >= this.maxCacheSize) { + // Evict oldest (first) entry + const firstKey = this.nodeCache.keys().next().value; + if (firstKey) { + this.nodeCache.delete(firstKey); + } + } + this.nodeCache.set(node.id, node); + } + + /** + * Clear the node cache + */ + clearCache(): void { + this.nodeCache.clear(); + } + + /** + * Get all nodes in a file + */ + getNodesByFile(filePath: string): Node[] { + if (!this.stmts.getNodesByFile) { + this.stmts.getNodesByFile = this.db.prepare( + 'SELECT * FROM nodes WHERE file_path = ? ORDER BY start_line' + ); + } + const rows = this.stmts.getNodesByFile.all(filePath) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Find the file that holds the densest concentration of the project's + * internal call graph — the "core" file. Used by context-builder to + * boost ranking of symbols in that file's directory (so e.g. sinatra + * queries surface `lib/sinatra/base.rb`'s `route!` instead of + * `sinatra-contrib/lib/sinatra/multi_route.rb`'s `route` extension). + * + * Returns null if no file has a meaningful concentration (e.g. spread + * evenly across many files, or empty index). + * + * "Internal" = source and target are in the same file. Cross-file + * edges aren't useful here — they don't tell us which file is the + * functional center. + * + * Excludes test/spec files from candidacy via path-pattern. The agent's + * typical question is "how does X work", not "how is X tested", so + * boosting a test file's directory would be a misfire. + */ + getDominantFile(): { filePath: string; edgeCount: number; nextEdgeCount: number } | null { + if (!this.stmts.getDominantFile) { + // Pull top 20 candidates; we then filter out test/generated files + // in code (regex-grade matching that SQL LIKE can't express). The + // generated-file filter is critical — without it, etcd's + // `api/etcdserverpb/rpc.pb.go` (1916 in-file edges, generated + // protobuf stub) outranks the real `server/etcdserver/server.go` + // (470 edges) by 4×, and the boost would push the agent toward + // generated code. + this.stmts.getDominantFile = this.db.prepare(` + SELECT n.file_path AS file_path, COUNT(*) AS edge_count + FROM edges e + JOIN nodes n ON e.source = n.id + JOIN nodes m ON e.target = m.id + WHERE n.file_path = m.file_path + GROUP BY n.file_path + ORDER BY edge_count DESC + LIMIT 20 + `); + } + const rows = this.stmts.getDominantFile.all() as Array<{ file_path: string; edge_count: number }>; + const filtered = rows.filter(r => !isLowValueFile(r.file_path)); + if (filtered.length === 0 || filtered[0]!.edge_count < 20) return null; + return { + filePath: filtered[0]!.file_path, + edgeCount: filtered[0]!.edge_count, + nextEdgeCount: filtered[1]?.edge_count ?? 0, + }; + } + + /** + * Find the file that holds the densest concentration of the project's + * `route` nodes (framework-emitted: Express/Gin/Flask/Rails/Drupal/etc.). + * Used by handleContext on small repos to inline the project's routing + * config when the agent's query is about request flow — eliminating the + * "Glob + Read routes.rb" pattern that beats atlas on tiny realworld + * template repos. + * + * Excludes test/generated files from candidacy. Returns null if there + * are fewer than 3 non-test routes total, or if no file holds at least + * 30% of them (diffuse routing → no single answer file). + */ + getTopRouteFile(): { filePath: string; routeCount: number; totalRoutes: number } | null { + if (!this.stmts.getTopRouteFile) { + this.stmts.getTopRouteFile = this.db.prepare(` + SELECT file_path, COUNT(*) AS cnt + FROM nodes + WHERE kind = 'route' + GROUP BY file_path + ORDER BY cnt DESC + LIMIT 20 + `); + } + const rows = this.stmts.getTopRouteFile.all() as Array<{ file_path: string; cnt: number }>; + const filtered = rows.filter(r => !isLowValueFile(r.file_path)); + if (filtered.length === 0) return null; + const totalRoutes = filtered.reduce((sum, r) => sum + r.cnt, 0); + const top = filtered[0]!; + if (totalRoutes < 3 || top.cnt < 3) return null; + if (top.cnt / totalRoutes < 0.30) return null; + return { filePath: top.file_path, routeCount: top.cnt, totalRoutes }; + } + + /** + * Build a URL → handler manifest from the index. Each route node's + * `references` edge points at the function/method that handles the + * request. We join them in one pass; the agent gets the canonical + * routing answer ("POST /users/login → AuthController#login") without + * having to parse the framework's route DSL itself. + * + * Also returns the file with the most handler endpoints — used as the + * "top handler file" to inline source for, so the agent has both the + * mapping AND the handler implementations. + */ + getRoutingManifest(limit: number = 40): { + entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>; + topHandlerFile: string | null; + topHandlerFileCount: number; + totalRoutes: number; + } | null { + if (!this.stmts.getRoutingManifest) { + // Edge kind varies across framework resolvers: Spring/Rails/ + // Laravel/Drupal emit `references`, Express emits `calls`. Accept + // both — the semantic is the same (route → its handler). + this.stmts.getRoutingManifest = this.db.prepare(` + SELECT + r.name AS url, + h.name AS handler, + h.file_path AS handler_file, + h.start_line AS handler_line, + h.kind AS handler_kind + FROM nodes r + JOIN edges e ON e.source = r.id + JOIN nodes h ON e.target = h.id + WHERE r.kind = 'route' + AND e.kind IN ('references', 'calls') + AND h.kind IN ('function', 'method', 'class') + ORDER BY r.file_path, r.start_line + LIMIT ? + `); + } + const rows = this.stmts.getRoutingManifest.all(limit) as Array<{ + url: string; handler: string; handler_file: string; handler_line: number; handler_kind: string; + }>; + // Drop test/generated handlers — same hygiene as elsewhere. + const filtered = rows.filter(r => !isLowValueFile(r.handler_file)); + if (filtered.length < 3) return null; + // Identify the file holding the most handlers (the "primary handler file"). + const fileCounts = new Map(); + for (const r of filtered) { + fileCounts.set(r.handler_file, (fileCounts.get(r.handler_file) ?? 0) + 1); + } + let topHandlerFile: string | null = null; + let topHandlerFileCount = 0; + for (const [file, count] of fileCounts) { + if (count > topHandlerFileCount) { + topHandlerFile = file; + topHandlerFileCount = count; + } + } + return { + entries: filtered.map(r => ({ + url: r.url, + handler: r.handler, + handlerFile: r.handler_file, + handlerLine: r.handler_line, + handlerKind: r.handler_kind, + })), + topHandlerFile, + topHandlerFileCount, + totalRoutes: filtered.length, + }; + } + + /** + * Get all nodes of a specific kind + */ + getNodesByKind(kind: NodeKind): Node[] { + if (!this.stmts.getNodesByKind) { + this.stmts.getNodesByKind = this.db.prepare('SELECT * FROM nodes WHERE kind = ?'); + } + const rows = this.stmts.getNodesByKind.all(kind) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Stream every node of a kind one at a time (lazy) instead of materializing + * them all like {@link getNodesByKind}. For unbounded kinds (`function`, + * `method`) on a symbol-dense project the full array is gigabytes; the + * dynamic-edge synthesizers only scan-and-filter, so they iterate to keep + * memory O(1) in the node count rather than O(nodes) (#610). + */ + *iterateNodesByKind(kind: NodeKind): IterableIterator { + // Fresh statement per call (not a cached one): an iterator holds an open + // cursor, so a shared statement would conflict across overlapping scans. + const stmt = this.db.prepare('SELECT * FROM nodes WHERE kind = ?'); + for (const row of stmt.iterate(kind)) { + yield rowToNode(row as NodeRow); + } + } + + /** + * Get all nodes in the database + */ + getAllNodes(): Node[] { + const rows = this.db.prepare('SELECT * FROM nodes').all() as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Get nodes by exact name match (uses idx_nodes_name index) + */ + getNodesByName(name: string): Node[] { + if (!this.stmts.getNodesByName) { + this.stmts.getNodesByName = this.db.prepare('SELECT * FROM nodes WHERE name = ?'); + } + const rows = this.stmts.getNodesByName.all(name) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Get nodes by exact qualified name match (uses idx_nodes_qualified_name index) + */ + getNodesByQualifiedNameExact(qualifiedName: string): Node[] { + if (!this.stmts.getNodesByQualifiedNameExact) { + this.stmts.getNodesByQualifiedNameExact = this.db.prepare( + 'SELECT * FROM nodes WHERE qualified_name = ?' + ); + } + const rows = this.stmts.getNodesByQualifiedNameExact.all(qualifiedName) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Get nodes by lowercase name match (uses idx_nodes_lower_name expression index) + */ + getNodesByLowerName(lowerName: string): Node[] { + if (!this.stmts.getNodesByLowerName) { + this.stmts.getNodesByLowerName = this.db.prepare( + 'SELECT * FROM nodes WHERE lower(name) = ?' + ); + } + const rows = this.stmts.getNodesByLowerName.all(lowerName) as NodeRow[]; + return rows.map(rowToNode); + } + + /** + * Search nodes by name using FTS with fallback to LIKE for better matching + * + * Search strategy: + * 1. Try FTS5 prefix match (query*) for word-start matching + * 2. If no results, try LIKE for substring matching (e.g., "signIn" finds "signInWithGoogle") + * 3. Score results based on match quality + */ + searchNodes(query: string, options: SearchOptions = {}): SearchResult[] { + const { limit = 100, offset = 0 } = options; + + // Parse field-qualified bits out of the raw query (kind:, lang:, + // path:, name:). Anything not recognised stays in `text` and goes + // to FTS unchanged. Filters compose with the SearchOptions arg — + // both are applied (intersection-style). + const parsed = parseQuery(query); + const mergedKinds = + parsed.kinds.length > 0 + ? Array.from(new Set([...(options.kinds ?? []), ...parsed.kinds])) + : options.kinds; + const mergedLanguages = + parsed.languages.length > 0 + ? Array.from(new Set([...(options.languages ?? []), ...parsed.languages])) + : options.languages; + const pathFilters = parsed.pathFilters; + const nameFilters = parsed.nameFilters; + // The text portion drives FTS/LIKE; if all the user typed was + // filters (`kind:function`), we still need *some* candidate set, + // so synthesise an empty-text path that returns everything matching + // the filters. + const text = parsed.text; + const kinds = mergedKinds; + const languages = mergedLanguages; + + // First try FTS5 with prefix matching + let results = text + ? this.searchNodesFTS(text, { kinds, languages, limit, offset }) + // Over-fetch by 5× when running filter-only (no text). The + // post-scoring path: + name: filters can be very selective, so + // a smaller multiplier risks returning fewer than `limit` + // results despite the DB having plenty of matches. + : this.searchAllByFilters({ kinds, languages, limit: limit * 5 }); + + // If no FTS results, try LIKE-based substring search + if (results.length === 0 && text.length >= 2) { + results = this.searchNodesLike(text, { kinds, languages, limit, offset }); + } + + // Final fuzzy fallback: scan all known names and keep those within + // a tight Levenshtein distance. Only fires when both FTS and LIKE + // returned nothing AND there's a text portion long enough to be + // worth fuzzing (1-char queries would match too much). + if (results.length === 0 && text.length >= 3) { + results = this.searchNodesFuzzy(text, { kinds, languages, limit }); + } + + // Supplement: ensure exact name matches are always candidates. + // BM25 can bury short exact-match names (e.g. "getBean") under hundreds of + // compound names (e.g. "getBeanDescriptor") in large codebases, + // pushing them past the FTS fetch limit before post-hoc scoring can help. + // Use the max BM25 score as the base so the nameMatchBonus (exact=30 vs + // prefix=20) actually differentiates them after rescoring. + if (results.length > 0 && query) { + const existingIds = new Set(results.map(r => r.node.id)); + const maxFtsScore = Math.max(...results.map(r => r.score)); + const terms = query.split(/\s+/).filter(t => t.length >= 2); + for (const term of terms) { + let sql = 'SELECT * FROM nodes WHERE name = ? COLLATE NOCASE'; + const params: (string | number)[] = [term]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + sql += ' LIMIT 20'; + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + for (const row of rows) { + if (!existingIds.has(row.id)) { + results.push({ node: rowToNode(row), score: maxFtsScore }); + existingIds.add(row.id); + } + } + } + } + + // Apply multi-signal scoring + if (results.length > 0 && (text || query)) { + const scoringQuery = text || query; + results = results.map(r => ({ + ...r, + score: r.score + + kindBonus(r.node.kind) + + scorePathRelevance(r.node.filePath, scoringQuery, this.projectNameTokens) + + nameMatchBonus(r.node.name, scoringQuery), + })); + results.sort((a, b) => b.score - a.score); + // Trim to requested limit after rescoring + if (results.length > limit) { + results = results.slice(0, limit); + } + } + + // Apply path: + name: filters AFTER scoring. Scoring already uses + // path/name as a soft signal; the explicit filters here are a hard + // gate. Done last so the FTS limit fetched plenty of candidates to + // narrow from. + if (pathFilters.length > 0) { + const lowered = pathFilters.map((p) => p.toLowerCase()); + results = results.filter((r) => { + const fp = r.node.filePath.toLowerCase(); + return lowered.some((p) => fp.includes(p)); + }); + } + if (nameFilters.length > 0) { + const lowered = nameFilters.map((n) => n.toLowerCase()); + results = results.filter((r) => { + const nm = r.node.name.toLowerCase(); + return lowered.some((n) => nm.includes(n)); + }); + } + + return results; + } + + /** + * Match-everything path used when the user supplied only field + * filters (`kind:function lang:typescript`) with no text. Returns + * candidates ordered by name; the caller's filter pass narrows to + * what was asked for. + */ + private searchAllByFilters(options: { + kinds?: NodeKind[]; + languages?: Language[]; + limit: number; + }): SearchResult[] { + const { kinds, languages, limit } = options; + let sql = 'SELECT * FROM nodes WHERE 1=1'; + const params: (string | number)[] = []; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + sql += ' ORDER BY name LIMIT ?'; + params.push(limit); + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + return rows.map((row) => ({ node: rowToNode(row), score: 1 })); + } + + /** + * Fuzzy fallback: when zero FTS/LIKE hits, try an edit-distance + * sweep over the distinct symbol-name set. Caps `maxDist` at 2 so + * `getUssr` finds `getUser` but `process` doesn't match `prosody`. + * Bounded edit distance keeps each comparison cheap; the per-query + * scan is O(distinct-name-count) which is far smaller than total + * node count on any real codebase. + */ + private searchNodesFuzzy( + text: string, + options: { kinds?: NodeKind[]; languages?: Language[]; limit: number } + ): SearchResult[] { + const { kinds, languages, limit } = options; + const lowered = text.toLowerCase(); + const maxDist = lowered.length <= 4 ? 1 : 2; + + // Pull the distinct name list once. The set is cached on QueryBuilder + // by getAllNodeNames(); even on a 200k-node project the distinct + // name set is typically O(10k) because most names repeat. The + // candidate-cap below bounds memory regardless. + const allNames = this.getAllNodeNames(); + const candidates: Array<{ name: string; dist: number }> = []; + for (const name of allNames) { + const dist = boundedEditDistance(name.toLowerCase(), lowered, maxDist); + if (dist <= maxDist) candidates.push({ name, dist }); + } + candidates.sort((a, b) => a.dist - b.dist); + + // Cap the per-name follow-up queries. Each survivor triggers a + // separate `SELECT * FROM nodes WHERE name = ?`; without this cap + // a project with many similar names (`getUser1`, `getUser2`...) + // could fan out far beyond `limit` queries before the inner-loop + // limit kicks in. + const FUZZY_FOLLOWUP_CAP = Math.max(limit * 2, 50); + const cappedCandidates = candidates.slice(0, FUZZY_FOLLOWUP_CAP); + + const results: SearchResult[] = []; + const seen = new Set(); + for (const c of cappedCandidates) { + if (results.length >= limit) break; + let sql = 'SELECT * FROM nodes WHERE name = ?'; + const params: (string | number)[] = [c.name]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + sql += ' LIMIT 5'; + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + for (const row of rows) { + if (seen.has(row.id)) continue; + seen.add(row.id); + // Lower the score for each edit step away from the query so + // exact-match fallbacks (dist 0) outrank dist-2 typos. + results.push({ node: rowToNode(row), score: 1 / (1 + c.dist) }); + if (results.length >= limit) break; + } + } + return results; + } + + /** + * FTS5 search with prefix matching + */ + private searchNodesFTS(query: string, options: SearchOptions): SearchResult[] { + const { kinds, languages, limit = 100, offset = 0 } = options; + + // Add prefix wildcard for better matching (e.g., "auth" matches "AuthService", "authenticate") + // Escape special FTS5 characters and add prefix wildcard. + // + // `::` is a qualifier separator in Rust/C++/Ruby, not a token char, + // so treat it as whitespace before the strip step. Otherwise queries + // like `stage_apply::run` collapse to `stage_applyrun` (the colons + // are stripped without splitting) and find nothing. See #173. + const ftsQuery = query + .replace(/::/g, ' ') // Rust/C++/Ruby qualifier separator + .replace(/['"*():^]/g, '') // Remove FTS5 special chars + .split(/\s+/) + .filter(term => term.length > 0) + // Strip FTS5 boolean operators to prevent query manipulation + .filter(term => !/^(AND|OR|NOT|NEAR)$/i.test(term)) + .map(term => `"${term}"*`) // Prefix match each term + .join(' OR '); + + if (!ftsQuery) { + return []; + } + + // BM25 column weights: id=0, name=20, qualified_name=5, docstring=1, signature=2 + // Heavy name weight ensures exact/prefix name matches rank above incidental + // mentions in long docstrings or qualified names of nested symbols. + // Fetch 5x requested limit so post-hoc rescoring (kindBonus, pathRelevance, + // nameMatchBonus) can promote results that BM25 alone undervalues. + const ftsLimit = Math.max(limit * 5, 100); + + let sql = ` + SELECT nodes.*, bm25(nodes_fts, 0, 20, 5, 1, 2) as score + FROM nodes_fts + JOIN nodes ON nodes_fts.id = nodes.id + WHERE nodes_fts MATCH ? + `; + + const params: (string | number)[] = [ftsQuery]; + + if (kinds && kinds.length > 0) { + sql += ` AND nodes.kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (languages && languages.length > 0) { + sql += ` AND nodes.language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + + sql += ' ORDER BY score LIMIT ? OFFSET ?'; + params.push(ftsLimit, offset); + + try { + const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + return rows.map((row) => ({ + node: rowToNode(row), + score: Math.abs(row.score), // bm25 returns negative scores + })); + } catch { + // FTS query failed, return empty + return []; + } + } + + /** + * LIKE-based substring search for cases where FTS doesn't match + * Useful for camelCase matching (e.g., "signIn" finds "signInWithGoogle") + */ + private searchNodesLike(query: string, options: SearchOptions): SearchResult[] { + const { kinds, languages, limit = 100, offset = 0 } = options; + + let sql = ` + SELECT nodes.*, + CASE + WHEN name = ? THEN 1.0 + WHEN name LIKE ? THEN 0.9 + WHEN name LIKE ? THEN 0.8 + WHEN qualified_name LIKE ? THEN 0.7 + ELSE 0.5 + END as score + FROM nodes + WHERE ( + name LIKE ? OR + qualified_name LIKE ? OR + name LIKE ? + ) + `; + + // Pattern variants for better matching + const exactMatch = query; + const startsWith = `${query}%`; + const contains = `%${query}%`; + + const params: (string | number)[] = [ + exactMatch, // Exact match score + startsWith, // Starts with score + contains, // Contains score + contains, // Qualified name score + contains, // WHERE: name contains + contains, // WHERE: qualified_name contains + startsWith, // WHERE: name starts with + ]; + + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + + sql += ' ORDER BY score DESC, length(name) ASC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + + return rows.map((row) => ({ + node: rowToNode(row), + score: row.score, + })); + } + + /** + * Find nodes by exact name match + * + * Used for hybrid search - looks up symbols by exact name or case-insensitive match. + * Returns high-confidence matches for known symbol names extracted from query. + * + * @param names - Array of symbol names to look up + * @param options - Search options (kinds, languages, limit) + * @returns SearchResult array with exact matches scored at 1.0 + */ + findNodesByExactName(names: string[], options: SearchOptions = {}): SearchResult[] { + if (names.length === 0) return []; + + const { kinds, languages, limit = 50 } = options; + + // Two-pass approach to handle common names (e.g., "run" has 40+ matches): + // Pass 1: Find which files contain distinctive (rare) symbols from the query. + // Pass 2: Query each name, boosting results that co-locate with distinctive symbols. + + // Pass 1: Find files containing each queried name, identify distinctive names + const nameToFiles = new Map>(); + for (const name of names) { + let sql = 'SELECT DISTINCT file_path FROM nodes WHERE name COLLATE NOCASE = ?'; + const params: (string | number)[] = [name]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + sql += ' LIMIT 100'; + const rows = this.db.prepare(sql).all(...params) as { file_path: string }[]; + nameToFiles.set(name.toLowerCase(), new Set(rows.map(r => r.file_path))); + } + + // Distinctive names are those with fewer than 10 file matches (e.g., "scrapeLoop" = 1 file) + const distinctiveFiles = new Set(); + for (const [, files] of nameToFiles) { + if (files.size > 0 && files.size < 10) { + for (const f of files) distinctiveFiles.add(f); + } + } + + // Pass 2: Query each name with per-name limit, scoring by co-location + const perNameLimit = Math.max(8, Math.ceil(limit / names.length)); + const allResults: SearchResult[] = []; + const seenIds = new Set(); + + for (const name of names) { + let sql = ` + SELECT nodes.*, 1.0 as score + FROM nodes + WHERE name COLLATE NOCASE = ? + `; + const params: (string | number)[] = [name]; + + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + + // Fetch enough to find co-located results among common names + sql += ' LIMIT ?'; + params.push(Math.max(perNameLimit * 3, 50)); + + const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + const nameResults: SearchResult[] = []; + for (const row of rows) { + const node = rowToNode(row); + if (seenIds.has(node.id)) continue; + // Boost results in files that also contain distinctive symbols + const coLocationBoost = distinctiveFiles.has(node.filePath) ? 20 : 0; + nameResults.push({ node, score: row.score + coLocationBoost }); + } + + // Sort by score (co-located first), take per-name limit + nameResults.sort((a, b) => b.score - a.score); + for (const r of nameResults.slice(0, perNameLimit)) { + seenIds.add(r.node.id); + allResults.push(r); + } + } + + // Sort all results by score so co-located results bubble up + allResults.sort((a, b) => b.score - a.score); + return allResults.slice(0, limit); + } + + /** + * Find nodes whose name contains a substring (LIKE-based). + * Useful for CamelCase-part matching where FTS fails because + * e.g. "TransportSearchAction" is one FTS token, not matchable by "Search"*. + * + * Results are ordered by name length (shorter = more likely to be the core type). + */ + findNodesByNameSubstring( + substring: string, + options: SearchOptions & { excludePrefix?: boolean } = {} + ): SearchResult[] { + const { kinds, languages, limit = 30, excludePrefix } = options; + + let sql = ` + SELECT nodes.*, 1.0 as score + FROM nodes + WHERE name LIKE ? + `; + const params: (string | number)[] = [`%${substring}%`]; + + // Exclude prefix matches (handled by FTS-based prefix search in Step 2b) + if (excludePrefix) { + sql += ` AND name NOT LIKE ?`; + params.push(`${substring}%`); + } + + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + + sql += ' ORDER BY length(name) ASC LIMIT ?'; + params.push(limit); + + const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + return rows.map((row) => ({ + node: rowToNode(row), + score: row.score, + })); + } + + // =========================================================================== + // Edge Operations + // =========================================================================== + + /** + * Insert a new edge + */ + insertEdge(edge: Edge): void { + if (!this.stmts.insertEdge) { + this.stmts.insertEdge = this.db.prepare(` + INSERT OR IGNORE INTO edges (source, target, kind, metadata, line, col, provenance) + VALUES (@source, @target, @kind, @metadata, @line, @col, @provenance) + `); + } + + this.stmts.insertEdge.run({ + source: edge.source, + target: edge.target, + kind: edge.kind, + metadata: edge.metadata ? JSON.stringify(edge.metadata) : null, + line: edge.line ?? null, + col: edge.column ?? null, + provenance: edge.provenance ?? null, + }); + } + + /** + * Insert multiple edges in a transaction + */ + insertEdges(edges: Edge[]): void { + if (edges.length === 0) return; + + this.db.transaction(() => { + const endpointIds = new Set(); + for (const edge of edges) { + endpointIds.add(edge.source); + endpointIds.add(edge.target); + } + const existingNodeIds = this.getExistingNodeIds([...endpointIds]); + + for (const edge of edges) { + if (!existingNodeIds.has(edge.source) || !existingNodeIds.has(edge.target)) { + continue; + } + this.insertEdge(edge); + } + })(); + } + + /** + * Delete all edges from a source node + */ + deleteEdgesBySource(sourceId: string): void { + if (!this.stmts.deleteEdgesBySource) { + this.stmts.deleteEdgesBySource = this.db.prepare('DELETE FROM edges WHERE source = ?'); + } + this.stmts.deleteEdgesBySource.run(sourceId); + } + + /** + * Get outgoing edges from a node + */ + getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string): Edge[] { + if ((kinds && kinds.length > 0) || provenance) { + let sql = 'SELECT * FROM edges WHERE source = ?'; + const params: (string | number)[] = [sourceId]; + + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (provenance) { + sql += ' AND provenance = ?'; + params.push(provenance); + } + + const rows = this.db.prepare(sql).all(...params) as EdgeRow[]; + return rows.map(rowToEdge); + } + + if (!this.stmts.getEdgesBySource) { + this.stmts.getEdgesBySource = this.db.prepare('SELECT * FROM edges WHERE source = ?'); + } + const rows = this.stmts.getEdgesBySource.all(sourceId) as EdgeRow[]; + return rows.map(rowToEdge); + } + + /** + * Get incoming edges to a node + */ + getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] { + if (kinds && kinds.length > 0) { + const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')})`; + const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[]; + return rows.map(rowToEdge); + } + + if (!this.stmts.getEdgesByTarget) { + this.stmts.getEdgesByTarget = this.db.prepare('SELECT * FROM edges WHERE target = ?'); + } + const rows = this.stmts.getEdgesByTarget.all(targetId) as EdgeRow[]; + return rows.map(rowToEdge); + } + + /** + * Find all edges where both source and target are in the given node set. + * Useful for recovering inter-node connectivity after BFS. + */ + findEdgesBetweenNodes(nodeIds: string[], kinds?: EdgeKind[]): Edge[] { + if (nodeIds.length === 0) return []; + + const idsJson = JSON.stringify(nodeIds); + let sql = `SELECT * FROM edges WHERE source IN (SELECT value FROM json_each(?)) AND target IN (SELECT value FROM json_each(?))`; + const params: string[] = [idsJson, idsJson]; + + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + const rows = this.db.prepare(sql).all(...params) as EdgeRow[]; + return rows.map(rowToEdge); + } + + /** + * Distinct file paths that DEPEND ON `filePath`: every file containing a + * symbol with a cross-file edge (any kind except `contains`) into a symbol + * of this file. This is the file-level projection of the symbol dependency + * graph and the basis for blast-radius / `affected` test selection. + * + * It deliberately does NOT restrict to `imports` edges. In this graph an + * `imports` edge connects a file to its own local import declarations + * (it is always same-file), so an imports-only lookup returns zero + * cross-file dependents for every file. The real cross-file dependency + * signal is the resolved call/reference graph — calls, references, + * instantiates, extends, implements, overrides, type_of, returns, + * decorates — exactly what {@link GraphTraverser.getImpactRadius} traverses. + * `contains` is excluded: a parent containing a symbol does not *depend* on + * it. One indexed query (idx_nodes_file_path + idx_edges_target_kind). + */ + getDependentFilePaths(filePath: string): string[] { + const sql = `SELECT DISTINCT src.file_path AS fp + FROM edges e + JOIN nodes tgt ON tgt.id = e.target + JOIN nodes src ON src.id = e.source + WHERE tgt.file_path = ? + AND e.kind != 'contains' + AND src.file_path != ?`; + const rows = this.db.prepare(sql).all(filePath, filePath) as Array<{ fp: string }>; + return rows.map((r) => r.fp); + } + + /** + * Distinct file paths that `filePath` DEPENDS ON — the inverse of + * {@link getDependentFilePaths}: every file containing a symbol that a + * symbol of this file has a cross-file edge into. Same edge-kind rules + * (all kinds except `contains`); same reason imports-only is insufficient. + */ + getDependencyFilePaths(filePath: string): string[] { + const sql = `SELECT DISTINCT tgt.file_path AS fp + FROM edges e + JOIN nodes src ON src.id = e.source + JOIN nodes tgt ON tgt.id = e.target + WHERE src.file_path = ? + AND e.kind != 'contains' + AND tgt.file_path != ?`; + const rows = this.db.prepare(sql).all(filePath, filePath) as Array<{ fp: string }>; + return rows.map((r) => r.fp); + } + + /** + * Cross-file edges whose TARGET is a node in `filePath` and whose SOURCE is a + * node in a *different* file, paired with the target node's (name, kind) so a + * caller can re-resolve the edge to the re-indexed target's new ID (node IDs + * are `sha256(filePath:kind:name:line)`, so any line shift in the callee file + * changes target IDs and a naive re-insert by old ID silently drops them). + * Used by `storeExtractionResult` to preserve incoming edges across a file + * re-index (issue #899). Same edge-kind rules as + * {@link getDependentFilePaths}: all kinds except `contains`. + */ + getCrossFileIncomingEdgesWithTarget(filePath: string): Array { + const sql = `SELECT e.*, tgt.name AS target_name, tgt.kind AS target_kind + FROM edges e + JOIN nodes tgt ON tgt.id = e.target + JOIN nodes src ON src.id = e.source + WHERE tgt.file_path = ? + AND e.kind != 'contains' + AND src.file_path != ?`; + const rows = this.db.prepare(sql).all(filePath, filePath) as Array; + return rows.map(row => ({ + ...rowToEdge(row), + targetName: row.target_name, + targetKind: row.target_kind, + })); + } + + // =========================================================================== + // File Operations + // =========================================================================== + + /** + * Insert or update a file record + */ + upsertFile(file: FileRecord): void { + if (!this.stmts.upsertFile) { + this.stmts.upsertFile = this.db.prepare(` + INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors) + VALUES (@path, @contentHash, @language, @size, @modifiedAt, @indexedAt, @nodeCount, @errors) + ON CONFLICT(path) DO UPDATE SET + content_hash = @contentHash, + language = @language, + size = @size, + modified_at = @modifiedAt, + indexed_at = @indexedAt, + node_count = @nodeCount, + errors = @errors + `); + } + + this.stmts.upsertFile.run({ + path: file.path, + contentHash: file.contentHash, + language: file.language, + size: file.size, + modifiedAt: file.modifiedAt, + indexedAt: file.indexedAt, + nodeCount: file.nodeCount, + errors: file.errors ? JSON.stringify(file.errors) : null, + }); + } + + /** + * Delete a file record and its nodes + */ + deleteFile(filePath: string): void { + this.db.transaction(() => { + this.deleteNodesByFile(filePath); + if (!this.stmts.deleteFile) { + this.stmts.deleteFile = this.db.prepare('DELETE FROM files WHERE path = ?'); + } + this.stmts.deleteFile.run(filePath); + })(); + } + + /** + * Get a file record by path + */ + getFileByPath(filePath: string): FileRecord | null { + if (!this.stmts.getFileByPath) { + this.stmts.getFileByPath = this.db.prepare('SELECT * FROM files WHERE path = ?'); + } + const row = this.stmts.getFileByPath.get(filePath) as FileRow | undefined; + return row ? rowToFileRecord(row) : null; + } + + /** + * Get all tracked files + */ + getAllFiles(): FileRecord[] { + if (!this.stmts.getAllFiles) { + this.stmts.getAllFiles = this.db.prepare('SELECT * FROM files ORDER BY path'); + } + const rows = this.stmts.getAllFiles.all() as FileRow[]; + return rows.map(rowToFileRecord); + } + + /** + * Most recent index timestamp (ms since epoch) across all tracked files, or + * null when nothing is indexed yet. One indexed aggregate, no per-row scan. (#329) + */ + getLastIndexedAt(): number | null { + const row = this.db + .prepare('SELECT MAX(indexed_at) AS last FROM files') + .get() as { last: number | null } | undefined; + return row?.last ?? null; + } + + /** + * Get files that need re-indexing (hash changed) + */ + getStaleFiles(currentHashes: Map): FileRecord[] { + const files = this.getAllFiles(); + return files.filter((f) => { + const currentHash = currentHashes.get(f.path); + return currentHash && currentHash !== f.contentHash; + }); + } + + // =========================================================================== + // Unresolved References + // =========================================================================== + + /** + * Insert an unresolved reference + */ + insertUnresolvedRef(ref: UnresolvedReference): void { + if (!this.stmts.insertUnresolved) { + this.stmts.insertUnresolved = this.db.prepare(` + INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language) + VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates, @filePath, @language) + `); + } + + this.stmts.insertUnresolved.run({ + fromNodeId: ref.fromNodeId, + referenceName: ref.referenceName, + referenceKind: ref.referenceKind, + line: ref.line, + col: ref.column, + candidates: ref.candidates ? JSON.stringify(ref.candidates) : null, + filePath: ref.filePath ?? '', + language: ref.language ?? 'unknown', + }); + } + + /** + * Insert multiple unresolved references in a transaction + */ + insertUnresolvedRefsBatch(refs: UnresolvedReference[]): void { + if (refs.length === 0) return; + const insert = this.db.transaction(() => { + for (const ref of refs) { + this.insertUnresolvedRef(ref); + } + }); + insert(); + } + + /** + * Delete unresolved references from a node + */ + deleteUnresolvedByNode(nodeId: string): void { + if (!this.stmts.deleteUnresolvedByNode) { + this.stmts.deleteUnresolvedByNode = this.db.prepare( + 'DELETE FROM unresolved_refs WHERE from_node_id = ?' + ); + } + this.stmts.deleteUnresolvedByNode.run(nodeId); + } + + /** + * Get unresolved references by name (for resolution) + */ + getUnresolvedByName(name: string): UnresolvedReference[] { + if (!this.stmts.getUnresolvedByName) { + this.stmts.getUnresolvedByName = this.db.prepare( + 'SELECT * FROM unresolved_refs WHERE reference_name = ?' + ); + } + const rows = this.stmts.getUnresolvedByName.all(name) as UnresolvedRefRow[]; + return rows.map((row) => ({ + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + })); + } + + /** + * Get all unresolved references + */ + getUnresolvedReferences(): UnresolvedReference[] { + const rows = this.db.prepare('SELECT * FROM unresolved_refs').all() as UnresolvedRefRow[]; + return rows.map((row) => ({ + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + })); + } + + /** + * Get the count of unresolved references without loading them into memory + */ + getUnresolvedReferencesCount(): number { + if (!this.stmts.getUnresolvedCount) { + this.stmts.getUnresolvedCount = this.db.prepare( + 'SELECT COUNT(*) as count FROM unresolved_refs' + ); + } + const row = this.stmts.getUnresolvedCount.get() as { count: number }; + return row.count; + } + + /** + * Get a batch of unresolved references using LIMIT/OFFSET pagination. + * Used to process references in bounded memory chunks. + */ + getUnresolvedReferencesBatch(offset: number, limit: number): UnresolvedReference[] { + if (!this.stmts.getUnresolvedBatch) { + this.stmts.getUnresolvedBatch = this.db.prepare( + 'SELECT * FROM unresolved_refs LIMIT ? OFFSET ?' + ); + } + const rows = this.stmts.getUnresolvedBatch.all(limit, offset) as UnresolvedRefRow[]; + return rows.map((row) => ({ + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + })); + } + + /** + * Get all tracked file paths (lightweight — no full FileRecord objects) + */ + getAllFilePaths(): string[] { + if (!this.stmts.getAllFilePaths) { + this.stmts.getAllFilePaths = this.db.prepare('SELECT path FROM files ORDER BY path'); + } + const rows = this.stmts.getAllFilePaths.all() as Array<{ path: string }>; + return rows.map((r) => r.path); + } + + /** + * Get all distinct node names (lightweight — just name strings for pre-filtering) + */ + getAllNodeNames(): string[] { + if (!this.stmts.getAllNodeNames) { + this.stmts.getAllNodeNames = this.db.prepare('SELECT DISTINCT name FROM nodes'); + } + const rows = this.stmts.getAllNodeNames.all() as Array<{ name: string }>; + return rows.map((r) => r.name); + } + + /** + * Get unresolved references scoped to specific file paths. + * Uses the idx_unresolved_file_path index for efficient lookup. + */ + getUnresolvedReferencesByFiles(filePaths: string[]): UnresolvedReference[] { + if (filePaths.length === 0) return []; + + // Chunk under SQLite's parameter limit: the first sync of a very large repo + // passes every changed file here, which an unbounded `IN (...)` would bind + // as one parameter each — exceeding MAX_VARIABLE_NUMBER and aborting with + // "too many SQL variables". (#540) + const rows: UnresolvedRefRow[] = []; + for (let i = 0; i < filePaths.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const chunkRows = this.db + .prepare(`SELECT * FROM unresolved_refs WHERE file_path IN (${placeholders})`) + .all(...chunk) as UnresolvedRefRow[]; + rows.push(...chunkRows); + } + + return rows.map((row) => ({ + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, + filePath: row.file_path, + language: row.language as Language, + })); + } + + /** + * Delete all unresolved references (after resolution) + */ + clearUnresolvedReferences(): void { + this.db.exec('DELETE FROM unresolved_refs'); + } + + /** + * Delete resolved references by their IDs + */ + deleteResolvedReferences(fromNodeIds: string[]): void { + if (fromNodeIds.length === 0) return; + // Chunk under SQLite's parameter limit, matching every other IN-list in + // this file. The internal resolution path uses deleteSpecificResolvedReferences + // instead, but QueryBuilder is part of the public API, so a library consumer + // passing more ids than SQLITE_MAX_VARIABLE_NUMBER (32766 on the bundled + // node:sqlite) would otherwise hit "too many SQL variables". (#540, #1001) + for (let i = 0; i < fromNodeIds.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = fromNodeIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + this.db.prepare(`DELETE FROM unresolved_refs WHERE from_node_id IN (${placeholders})`).run(...chunk); + } + } + + /** + * Delete specific resolved references by (fromNodeId, referenceName, referenceKind) tuples. + * More precise than deleteResolvedReferences — only removes refs that were actually resolved. + */ + deleteSpecificResolvedReferences(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): void { + if (refs.length === 0) return; + const stmt = this.db.prepare( + 'DELETE FROM unresolved_refs WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?' + ); + const deleteMany = this.db.transaction((items: typeof refs) => { + for (const ref of items) { + stmt.run(ref.fromNodeId, ref.referenceName, ref.referenceKind); + } + }); + deleteMany(refs); + } + + // =========================================================================== + // Statistics + // =========================================================================== + + /** + * Lightweight (nodes, edges) count snapshot. Used around an index/sync + * run to compute true additions across extraction + resolution + + * synthesis — the per-phase counter in the orchestrator only sees + * extraction's contribution, which is why the CLI summary under-reported + * the edge count (resolution + synthesizer edges were invisible). + */ + getNodeAndEdgeCount(): { nodes: number; edges: number } { + return this.db + .prepare('SELECT (SELECT COUNT(*) FROM nodes) AS nodes, (SELECT COUNT(*) FROM edges) AS edges') + .get() as { nodes: number; edges: number }; + } + + /** + * Get graph statistics + */ + getStats(): GraphStats { + // Single query for all three aggregate counts + const counts = this.db.prepare(` + SELECT + (SELECT COUNT(*) FROM nodes) AS node_count, + (SELECT COUNT(*) FROM edges) AS edge_count, + (SELECT COUNT(*) FROM files) AS file_count + `).get() as { node_count: number; edge_count: number; file_count: number }; + + const nodesByKind = {} as Record; + const nodeKindRows = this.db + .prepare('SELECT kind, COUNT(*) as count FROM nodes GROUP BY kind') + .all() as Array<{ kind: string; count: number }>; + for (const row of nodeKindRows) { + nodesByKind[row.kind as NodeKind] = row.count; + } + + const edgesByKind = {} as Record; + const edgeKindRows = this.db + .prepare('SELECT kind, COUNT(*) as count FROM edges GROUP BY kind') + .all() as Array<{ kind: string; count: number }>; + for (const row of edgeKindRows) { + edgesByKind[row.kind as EdgeKind] = row.count; + } + + const filesByLanguage = {} as Record; + const languageRows = this.db + .prepare('SELECT language, COUNT(*) as count FROM files GROUP BY language') + .all() as Array<{ language: string; count: number }>; + for (const row of languageRows) { + filesByLanguage[row.language as Language] = row.count; + } + + return { + nodeCount: counts.node_count, + edgeCount: counts.edge_count, + fileCount: counts.file_count, + nodesByKind, + edgesByKind, + filesByLanguage, + dbSizeBytes: 0, // Set by caller using DatabaseConnection.getSize() + lastUpdated: Date.now(), + }; + } + + // =========================================================================== + // Project Metadata + // =========================================================================== + + /** + * Get a metadata value by key + */ + getMetadata(key: string): string | null { + const row = this.db.prepare('SELECT value FROM project_metadata WHERE key = ?').get(key) as { value: string } | undefined; + return row?.value ?? null; + } + + /** + * Set a metadata key-value pair (upsert) + */ + setMetadata(key: string, value: string): void { + this.db.prepare( + 'INSERT INTO project_metadata (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at' + ).run(key, value, Date.now()); + } + + /** + * Get all metadata as a key-value record + */ + getAllMetadata(): Record { + const rows = this.db.prepare('SELECT key, value FROM project_metadata').all() as { key: string; value: string }[]; + const result: Record = {}; + for (const row of rows) { + result[row.key] = row.value; + } + return result; + } + + /** + * Clear all data from the database + */ + clear(): void { + this.nodeCache.clear(); + this.db.transaction(() => { + this.db.exec('DELETE FROM unresolved_refs'); + this.db.exec('DELETE FROM edges'); + this.db.exec('DELETE FROM nodes'); + this.db.exec('DELETE FROM files'); + })(); + } +} diff --git a/packages/atlas/src/db/schema.sql b/packages/atlas/src/db/schema.sql new file mode 100644 index 0000000..1e95b9d --- /dev/null +++ b/packages/atlas/src/db/schema.sql @@ -0,0 +1,163 @@ +-- Atlas SQLite Schema +-- Version 1 + +-- Schema version tracking +CREATE TABLE IF NOT EXISTS schema_versions ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL, + description TEXT +); + +-- Insert initial version +INSERT INTO schema_versions (version, applied_at, description) +VALUES (1, strftime('%s', 'now') * 1000, 'Initial schema'); + +-- ============================================================================= +-- Core Tables +-- ============================================================================= + +-- Nodes: Code symbols (functions, classes, variables, etc.) +CREATE TABLE IF NOT EXISTS nodes ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + name TEXT NOT NULL, + qualified_name TEXT NOT NULL, + file_path TEXT NOT NULL, + language TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + start_column INTEGER NOT NULL, + end_column INTEGER NOT NULL, + docstring TEXT, + signature TEXT, + visibility TEXT, + is_exported INTEGER DEFAULT 0, + is_async INTEGER DEFAULT 0, + is_static INTEGER DEFAULT 0, + is_abstract INTEGER DEFAULT 0, + decorators TEXT, -- JSON array + type_parameters TEXT, -- JSON array + return_type TEXT, -- normalized return/result type name (e.g. C++ method return, for receiver-type inference) + updated_at INTEGER NOT NULL +); + +-- Edges: Relationships between nodes +CREATE TABLE IF NOT EXISTS edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + target TEXT NOT NULL, + kind TEXT NOT NULL, + metadata TEXT, -- JSON object + line INTEGER, + col INTEGER, + provenance TEXT DEFAULT NULL, + FOREIGN KEY (source) REFERENCES nodes(id) ON DELETE CASCADE, + FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE +); + +-- Files: Tracked source files +CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, + content_hash TEXT NOT NULL, + language TEXT NOT NULL, + size INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + indexed_at INTEGER NOT NULL, + node_count INTEGER DEFAULT 0, + errors TEXT -- JSON array +); + +-- Unresolved References: References that need resolution after full indexing +CREATE TABLE IF NOT EXISTS unresolved_refs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_node_id TEXT NOT NULL, + reference_name TEXT NOT NULL, + reference_kind TEXT NOT NULL, + line INTEGER NOT NULL, + col INTEGER NOT NULL, + candidates TEXT, -- JSON array + file_path TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT 'unknown', + FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE +); + +-- ============================================================================= +-- Indexes for Query Performance +-- ============================================================================= + +-- Node indexes +CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind); +CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name); +CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name ON nodes(qualified_name); +CREATE INDEX IF NOT EXISTS idx_nodes_file_path ON nodes(file_path); +CREATE INDEX IF NOT EXISTS idx_nodes_language ON nodes(language); +CREATE INDEX IF NOT EXISTS idx_nodes_file_line ON nodes(file_path, start_line); +CREATE INDEX IF NOT EXISTS idx_nodes_lower_name ON nodes(lower(name)); + +-- Full-text search index on node names, docstrings, and signatures +CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5( + id, + name, + qualified_name, + docstring, + signature, + content='nodes', + content_rowid='rowid' +); + +-- Triggers to keep FTS index in sync +CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN + INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature) + VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); +END; + +CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature) + VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature); +END; + +CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature) + VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature); + INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature) + VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); +END; + +-- Edge indexes. +-- idx_edges_source / idx_edges_target are intentionally omitted — +-- the (source, kind) and (target, kind) composites below cover the +-- corresponding source-only / target-only lookups via SQLite's +-- left-prefix scan, so the narrow indexes are dead weight on writes. +-- Migration v4 drops them on existing databases. +CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind); +CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source, kind); +CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target, kind); + +-- Edge identity uniqueness. An edge IS uniquely (source, target, kind, line, +-- col); insertEdge uses `INSERT OR IGNORE`, but without something UNIQUE to +-- conflict on it behaved like a plain INSERT, so two passes emitting the same +-- edge produced byte-identical duplicate rows that inflated counts and flowed +-- into callers/impact (#1034). IFNULL folds the nullable line/col so +-- coordinate-less edges (synthesized / file-level) dedup too — SQLite treats +-- each NULL as distinct otherwise. Migration v6 dedups existing rows + adds +-- this on older databases. +CREATE UNIQUE INDEX IF NOT EXISTS idx_edges_identity + ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1)); + +-- File indexes +CREATE INDEX IF NOT EXISTS idx_files_language ON files(language); +CREATE INDEX IF NOT EXISTS idx_files_modified_at ON files(modified_at); + +-- Unresolved refs indexes +CREATE INDEX IF NOT EXISTS idx_unresolved_from_node ON unresolved_refs(from_node_id); +CREATE INDEX IF NOT EXISTS idx_unresolved_name ON unresolved_refs(reference_name); +CREATE INDEX IF NOT EXISTS idx_unresolved_file_path ON unresolved_refs(file_path); +CREATE INDEX IF NOT EXISTS idx_unresolved_from_name ON unresolved_refs(from_node_id, reference_name); +CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance); + +-- Project metadata for version/provenance tracking +CREATE TABLE IF NOT EXISTS project_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/packages/atlas/src/db/sqlite-adapter.ts b/packages/atlas/src/db/sqlite-adapter.ts new file mode 100644 index 0000000..1e200c1 --- /dev/null +++ b/packages/atlas/src/db/sqlite-adapter.ts @@ -0,0 +1,149 @@ +/** + * SQLite Adapter + * + * Thin wrapper over Node's built-in `node:sqlite` (`DatabaseSync`), exposed + * through a small better-sqlite3-shaped interface so the rest of the codebase + * is storage-agnostic. + * + * Atlas ships with a bundled Node runtime, so `node:sqlite` (real SQLite, + * with WAL + FTS5) is always available — there is no native build step and no + * wasm fallback. When run from source instead, it requires Node >= 22.5. + */ + +export interface SqliteStatement { + run(...params: any[]): { changes: number; lastInsertRowid: number | bigint }; + get(...params: any[]): any; + all(...params: any[]): any[]; + /** + * Lazily yield result rows one at a time instead of materializing the whole + * set with `all()`. Use for unbounded scans (e.g. every function/method node) + * so memory stays O(1) in the row count rather than O(rows) — see #610, where + * `all()`-ing every symbol on a dense project spiked the heap into an OOM. + */ + iterate(...params: any[]): IterableIterator; +} + +export interface SqliteDatabase { + prepare(sql: string): SqliteStatement; + exec(sql: string): void; + pragma(str: string, options?: { simple?: boolean }): any; + transaction(fn: (...args: any[]) => T): (...args: any[]) => T; + close(): void; + readonly open: boolean; +} + +/** + * The active SQLite backend. Only one now (`node:sqlite`); kept as a named type + * so `atlas status` and the per-instance reporting have a stable shape. + */ +export type SqliteBackend = 'node-sqlite'; + +/** + * Wraps Node's built-in `node:sqlite` (`DatabaseSync`) to match the + * better-sqlite3 interface the rest of the code expects. + * + * node:sqlite is real SQLite compiled into Node, so it supports WAL, FTS5, + * mmap, and `@named` params natively — the only shims needed are the + * better-sqlite3 conveniences node:sqlite omits: a `.pragma()` helper, a + * `.transaction()` helper, and `open` (node:sqlite exposes `isOpen`). + */ +class NodeSqliteAdapter implements SqliteDatabase { + private _db: any; + + constructor(dbPath: string) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseSync } = require('node:sqlite'); + this._db = new DatabaseSync(dbPath); + } + + get open(): boolean { + return this._db.isOpen; + } + + prepare(sql: string): SqliteStatement { + // node:sqlite matches better-sqlite3's calling convention (variadic + // positional args, or a single object for @named params), so params forward + // through unchanged. + const stmt = this._db.prepare(sql); + return { + run(...params: any[]) { + const r = stmt.run(...params); + return { + changes: Number(r?.changes ?? 0), + lastInsertRowid: r?.lastInsertRowid ?? 0, + }; + }, + get(...params: any[]) { + return stmt.get(...params); + }, + all(...params: any[]) { + return stmt.all(...params); + }, + iterate(...params: any[]) { + return stmt.iterate(...params); + }, + }; + } + + exec(sql: string): void { + this._db.exec(sql); + } + + pragma(str: string, options?: { simple?: boolean }): any { + const trimmed = str.trim(); + // Write pragma ("key = value"): node:sqlite is real SQLite, so every pragma + // (WAL, mmap, synchronous, …) applies as-is. + if (trimmed.includes('=')) { + this._db.exec(`PRAGMA ${trimmed}`); + return; + } + // Read pragma. Default: the row object (e.g. { journal_mode: 'wal' }). + // `{ simple: true }` returns just the single column value, like better-sqlite3. + const row = this._db.prepare(`PRAGMA ${trimmed}`).get(); + if (options?.simple) { + return row && typeof row === 'object' ? Object.values(row)[0] : row; + } + return row; + } + + transaction(fn: (...args: any[]) => T): (...args: any[]) => T { + return (...args: any[]) => { + this._db.exec('BEGIN'); + try { + const result = fn(...args); + this._db.exec('COMMIT'); + return result; + } catch (error) { + this._db.exec('ROLLBACK'); + throw error; + } + }; + } + + close(): void { + // node:sqlite's DatabaseSync.close() throws if already closed; make it + // idempotent to match better-sqlite3 (callers may close more than once). + if (this._db.isOpen) this._db.close(); + } +} + +/** + * Create a database connection backed by `node:sqlite`. + * + * Returns the active backend alongside the db so each `DatabaseConnection` can + * report it per-instance — MCP can open multiple project DBs in one process, so + * a process-global would race. + */ +export function createDatabase(dbPath: string): { db: SqliteDatabase; backend: SqliteBackend } { + try { + return { db: new NodeSqliteAdapter(dbPath), backend: 'node-sqlite' }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error( + 'Failed to open SQLite via the built-in node:sqlite module.\n' + + 'Atlas requires node:sqlite (Node.js 22.5+). Install the self-contained\n' + + 'Atlas release (it bundles a compatible Node), or run on Node 22.5+.\n' + + `Underlying error: ${msg}` + ); + } +} diff --git a/packages/atlas/src/directory.ts b/packages/atlas/src/directory.ts new file mode 100644 index 0000000..3f6f3cd --- /dev/null +++ b/packages/atlas/src/directory.ts @@ -0,0 +1,617 @@ +/** + * Directory Management + * + * Manages the .tempest/atlas/ directory structure for Atlas data. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +/** The default per-project data directory name (lives inside .tempest/). */ +const DEFAULT_ATLAS_DIR = 'atlas'; + +let warnedBadDirName = false; + +/** + * Resolve the per-project data directory name, honoring the `ATLAS_DIR` + * environment override (default `atlas`). The override is a single path + * segment that lives inside `.tempest/` in the project root. + * + * Why this exists: two environments that share one working tree must NOT share + * one `.atlas/` — most concretely Windows-native and WSL (issue #636). The + * daemon lockfile (`.atlas/daemon.pid`) records a platform-specific pid and + * socket path (a Windows named pipe vs a WSL Unix socket), and SQLite file + * locking across the WSL2 ↔ Windows filesystem boundary is unreliable, so two + * daemons sharing one index risks corruption. Setting `ATLAS_DIR=.atlas-win` + * on one side gives each environment its own index in the same tree. + * + * Read live (not captured at load) so it is both process-accurate and testable. + * An override that isn't a plain directory name — empty, containing a path + * separator, `.`, `..`/traversal, or absolute — is ignored (we keep the + * default) rather than risk writing the index outside the project or into the + * project root itself; we warn once to stderr so the misconfiguration is seen. + */ +export function atlasDirName(): string { + const raw = process.env.ATLAS_DIR?.trim(); + if (!raw) return DEFAULT_ATLAS_DIR; + const invalid = + raw === '.' || + raw.includes('..') || + raw.includes('/') || + raw.includes('\\') || + path.isAbsolute(raw); + if (invalid) { + if (!warnedBadDirName) { + warnedBadDirName = true; + // stderr only — stdout is the MCP protocol channel. + console.warn( + `[atlas] Ignoring invalid ATLAS_DIR="${raw}" — it must be a plain ` + + `directory name (no path separators, no "..", not absolute). Using "${DEFAULT_ATLAS_DIR}".` + ); + } + return DEFAULT_ATLAS_DIR; + } + return raw; +} + +/** + * Atlas directory name — a load-time snapshot of {@link atlasDirName}. + * A running process's environment is fixed, so this equals the live value; + * it's kept as a stable string export for backward compatibility. Internal code + * resolves the name through {@link atlasDirName} / {@link getAtlasDir} + * so the `ATLAS_DIR` override always applies. + */ +export const ATLAS_DIR = atlasDirName(); + +/** + * Is `name` (a single path segment) an Atlas data directory? Matches the + * default `.atlas`, the active `ATLAS_DIR` override, and any + * `.atlas-*` sibling. File-watching and the indexer skip ALL of these, so + * when two environments share one working tree (Windows + WSL, issue #636) + * neither indexes or watches the other's index directory. + */ +export function isAtlasDataDir(name: string): boolean { + return ( + name === DEFAULT_ATLAS_DIR || + name === atlasDirName() || + name.startsWith(DEFAULT_ATLAS_DIR + '-') + ); +} + +/** + * Get the atlas data directory path for a project (.tempest/atlas/ inside the project root) + */ +export function getAtlasDir(projectRoot: string): string { + return path.join(projectRoot, '.tempest', atlasDirName()); +} + +/** + * Check if a project has been initialized with Atlas + * Requires both .atlas/ directory AND atlas.db to exist + */ +export function isInitialized(projectRoot: string): boolean { + const atlasDir = getAtlasDir(projectRoot); + if (!fs.existsSync(atlasDir) || !fs.statSync(atlasDir).isDirectory()) { + return false; + } + // Must have atlas.db, not just .atlas folder + const dbPath = path.join(atlasDir, 'atlas.db'); + return fs.existsSync(dbPath); +} + +/** + * Find the nearest parent directory containing .atlas/ + * + * Walks up from the given path to find an Atlas-initialized project, + * similar to how git finds .git/ directories. + * + * @param startPath - Directory to start searching from + * @returns The project root containing .atlas/, or null if not found + */ +/** + * Reason a directory is unsafe to use as an index ROOT, or null when it's fine. + * + * Indexing your home directory or a filesystem root drags in caches, `Library`, + * every other project, etc. — a multi-GB index, constant file-watcher churn, and + * (pre-1.0 on macOS) a file-descriptor blowup that exhausted `kern.maxfiles` and + * took unrelated apps / the whole machine down (#845). The classic trigger: + * running the installer or `atlas init` from `$HOME`, which auto-indexes the + * current directory. These are never intended project roots, so the installer + * and `init`/`index` refuse them (overridable with `--force`). + * + * Pure-ish (reads only `os.homedir()` + realpath) so it's easy to unit-test. + * The returned string is a human phrase that slots into "… looks like {reason}". + */ +export function unsafeIndexRootReason(projectRoot: string): string | null { + const resolve = (p: string): string => { + try { + return fs.realpathSync(path.resolve(p)); + } catch { + return path.resolve(p); + } + }; + const resolved = resolve(projectRoot); + + // Filesystem root: `/` on POSIX, a drive root like `C:\` on Windows. + if (path.parse(resolved).root === resolved) { + return 'the filesystem root'; + } + + const home = resolve(os.homedir()); + // Case-insensitive on macOS/Windows (case-preserving but case-insensitive FS). + const norm = (p: string): string => + process.platform === 'darwin' || process.platform === 'win32' ? p.toLowerCase() : p; + const r = norm(resolved); + const h = norm(home); + + if (r === h) { + return 'your home directory'; + } + // An ancestor of home (e.g. `/Users`, `/home`) — even broader than home. + if (h.startsWith(r + path.sep)) { + return 'a parent of your home directory'; + } + return null; +} + +export function findNearestAtlasRoot(startPath: string): string | null { + let current = path.resolve(startPath); + const root = path.parse(current).root; + + while (current !== root) { + if (isInitialized(current)) { + return current; + } + const parent = path.dirname(current); + if (parent === current) break; // Reached filesystem root + current = parent; + } + + // Check root as well + if (isInitialized(current)) { + return current; + } + + return null; +} + +/** Heavy/irrelevant directory names the sub-project scan never descends into. */ +const SUBPROJECT_SCAN_SKIP = new Set([ + 'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out', 'target', + 'vendor', 'bin', 'obj', '.next', '.nuxt', '.svelte-kit', '.cache', 'coverage', + '.venv', 'venv', '__pycache__', '.turbo', '.idea', '.vscode', 'tmp', 'temp', +]); + +/** Manifests that mark a directory as a project/workspace root. The down-scan + * is gated on one of these so a non-project cwd (e.g. `$HOME`) is a cheap + * no-op instead of a deep filesystem crawl. */ +const WORKSPACE_ROOT_MANIFESTS = [ + 'package.json', 'pnpm-workspace.yaml', 'lerna.json', 'nx.json', 'turbo.json', + 'go.work', 'go.mod', 'Cargo.toml', 'pom.xml', 'build.gradle', 'build.gradle.kts', + 'settings.gradle', 'pyproject.toml', 'composer.json', 'Gemfile', 'rush.json', + 'WORKSPACE', 'WORKSPACE.bazel', +]; + +function looksLikeProjectRoot(dir: string): boolean { + return WORKSPACE_ROOT_MANIFESTS.some((m) => fs.existsSync(path.join(dir, m))); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Indexed sub-project roots beneath `root` (bounded breadth-first scan). For + * the monorepo case behind #964: the index lives in a CHILD + * (`packages/x/.atlas/`), not at the workspace root the agent's cwd points + * at. Descent stops at the first indexed directory on a branch (a project's + * own sub-dirs aren't separate projects) and is bounded by depth + count so it + * never turns into a full-tree crawl on a large repo. + */ +export function findIndexedSubprojectRoots( + root: string, + opts: { maxDepth?: number; max?: number } = {}, +): string[] { + const maxDepth = opts.maxDepth ?? 4; + const max = opts.max ?? 64; + const out: string[] = []; + const walk = (dir: string, depth: number): void => { + if (out.length >= max || depth > maxDepth) return; + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (out.length >= max) return; + if (!e.isDirectory()) continue; + if (e.name.startsWith('.') || SUBPROJECT_SCAN_SKIP.has(e.name)) continue; + const child = path.join(dir, e.name); + if (isInitialized(child)) { out.push(child); continue; } // don't descend into an indexed project + walk(child, depth + 1); + } + }; + walk(root, 1); + return out; +} + +/** + * English structural keywords, matched with `\b` word boundaries so a keyword + * inside a longer word doesn't false-positive ("flow" in "flower"). + */ +const STRUCTURAL_EN = /\b(how|where|trace|flow|path|reach(?:es|ed)?|call(?:s|ed|er|ers|ee)?|depend|impact|affect|wired?|connect|implement|architect|structure|breaks?|what calls|why does)\b/i; + +/** + * Non-English (CJK) structural keywords, matched WITHOUT `\b`. JS's `\b` is + * ASCII-only — it only fires at `[A-Za-z0-9_]` boundaries, never between Han + * characters — so a Chinese keyword wrapped in `\b…\b` could never match. That + * was issue #994: the English-only gate silently no-op'd every Chinese prompt, + * so non-English users got no front-load nudge and no error to explain why. The + * set mirrors the English intent (如何=how, 在哪/哪里=where, 流程/流向=flow, + * 路径=path, 调用=call, 依赖=depend, 影响=impact/affect, 实现=implement, + * 架构=architect, 结构=structure, 追踪/跟踪=trace) plus structural-overview words + * with no single clean English equivalent (介绍/解析/分析/原理/机制). + */ +const STRUCTURAL_CJK = /如何|怎么|在哪|哪里|追踪|跟踪|流程|流向|路径|调用|依赖|影响|实现|架构|结构|介绍|解析|分析|原理|机制/; + +/** Doc/data/asset file extensions — a `name.ext` of this kind is a file + * reference, not a code symbol, so it must not trip the member-access signal. */ +const DOC_DATA_EXT = /\.(md|markdown|txt|rst|json|ya?ml|toml|lock|csv|tsv|log|ini|cfg|conf|env|xml|html?|png|jpe?g|gif|svg|pdf)$/i; + +/** + * Does `prompt` contain an explicit structural keyword (English or CJK)? A + * keyword is a strong, self-contained signal, so the front-load hook fires on it + * directly — no graph check needed. (A *code-token* match, by contrast, is only + * a candidate the hook verifies against the graph first; see {@link extractCodeTokens}.) + */ +export function hasStructuralKeyword(prompt: string): boolean { + return !!prompt && (STRUCTURAL_EN.test(prompt) || STRUCTURAL_CJK.test(prompt)); +} + +/** + * Identifier-shaped tokens in `prompt` — camelCase / PascalCase-with-inner-cap, + * snake_case, a `name(` call, or the two sides of an `a.b` member access. Naming + * a symbol is a code question whatever the surrounding human language, and these + * shapes almost never occur in ordinary prose, so they catch the common + * " 的调用链?" / "where is 定義" prompts no keyword list would. + * + * These are *candidates*, not a verdict: a tech brand like `JavaScript` or + * `GitHub` is identifier-shaped too, so the front-load hook checks each token + * against the actual index ({@link getNodesByName}) and only fires when one is a + * real symbol here — otherwise a brand-name prompt would inject ~16KB of + * low-relevance context (issue #994 follow-up). A doc/data filename ("README.md") + * is excluded from the member-access form since it's a file reference, not a symbol. + */ +export function extractCodeTokens(prompt: string): string[] { + if (!prompt) return []; + const out = new Set(); + // camelCase / PascalCase-with-inner-cap (getUserId, parseToken, UserService) or + // snake_case (article_publish, get_user) — a whole identifier run that has an + // inner lower→upper transition or an underscore flanked by alphanumerics. + for (const m of prompt.matchAll(/[A-Za-z_$][\w$]*/g)) { + const w = m[0]; + if (/[a-z][A-Z]/.test(w) || /[A-Za-z0-9]_[A-Za-z0-9]/.test(w)) out.add(w); + } + // call form: an identifier directly before '(' — parseToken(, render(). No + // whitespace before '(' so prose like "the function (entry point)" doesn't trip it. + for (const m of prompt.matchAll(/([A-Za-z_$][\w$]*)\(/g)) out.add(m[1]!); + // member access on identifiers (user.login) — but not a doc/data filename. + for (const m of prompt.matchAll(/([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)/g)) { + if (!DOC_DATA_EXT.test(m[0])) { out.add(m[1]!); out.add(m[2]!); } + } + return [...out]; +} + +/** + * Cheap, graph-free candidate gate for the front-load hook: could `prompt` be a + * structural / flow / impact / "where-how" question worth front-loading context + * for? True on an explicit keyword (English or CJK, issue #994) OR an + * identifier-shaped token. A keyword is sufficient to fire on its own; a + * token-only match is only a candidate the hook then verifies against the graph + * (a brand name like `JavaScript` is token-shaped but isn't a symbol). Every + * non-candidate prompt ("fix this typo", in any language) stays a zero-cost no-op. + */ +export function isStructuralPrompt(prompt: string): boolean { + return hasStructuralKeyword(prompt) || extractCodeTokens(prompt).length > 0; +} + +/** + * What the front-load hook should do for a prompt issued from a directory. + */ +export interface FrontloadPlan { + /** Open + explore this project and inject its source as context. `null` when + * there's no single project to front-load (none indexed, or several indexed + * sub-projects with no clear match — see {@link nudgeProjects}). */ + exploreRoot: string | null; + /** Indexed sub-projects to surface in a "pass `projectPath`" nudge: the rest + * of a monorepo's indexed projects alongside `exploreRoot`, or — when no one + * project clearly matches — the full list (with `exploreRoot` null). */ + nudgeProjects: string[]; + /** True when the plan came from scanning DOWN into sub-projects (cwd itself + * is not under any index) — the monorepo case, where a follow-up + * `atlas_explore` needs an explicit `projectPath`. */ + viaSubScan: boolean; +} + +/** + * Decide what the front-load hook injects for a `prompt` issued from `cwd`, + * shaped by where the `.atlas/` index(es) actually are: + * 1. **cwd (or an ancestor) is indexed** → front-load that project. The + * normal single-project / nested-file case. + * 2. **cwd isn't indexed but looks like a workspace root** → the indexes live + * in sub-projects (the monorepo case behind #964). One indexed + * sub-project → front-load it; several → front-load the one the prompt + * names (by relative path like `packages/api`, or package directory name) + * and nudge about the rest; several with no match → nudge the full list so + * the agent passes `projectPath`, rather than guessing wrong. + * 3. **nothing indexed reachable** → do nothing (the agent's own tools apply). + */ +export function planFrontload(cwd: string, prompt: string): FrontloadPlan { + const none: FrontloadPlan = { exploreRoot: null, nudgeProjects: [], viaSubScan: false }; + + // 1. up-walk — nearest indexed ancestor (incl. cwd). Cheap; covers the common + // single-project case without a down-scan. + let dir = path.resolve(cwd); + for (let i = 0; i < 6; i++) { + if (isInitialized(dir)) return { exploreRoot: dir, nudgeProjects: [], viaSubScan: false }; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + // 2. down-scan — only from something that looks like a workspace root, so a + // non-project cwd (e.g. $HOME) is a cheap no-op, not a deep crawl. + const base = path.resolve(cwd); + if (!looksLikeProjectRoot(base)) return none; + const subs = findIndexedSubprojectRoots(base); + if (subs.length === 0) return none; + if (subs.length === 1) return { exploreRoot: subs[0]!, nudgeProjects: [], viaSubScan: true }; + + // Several indexed sub-projects — pick the one the prompt points at, if any. + const p = prompt.toLowerCase(); + let best: { root: string; score: number; relLen: number } | null = null; + for (const s of subs) { + const rel = path.relative(base, s); + const relLc = rel.split(path.sep).join('/').toLowerCase(); + const name = path.basename(s).toLowerCase(); + let score = 0; + if (relLc && p.includes(relLc)) score = 10; // "packages/api" + else if (name.length >= 3 && new RegExp(`\\b${escapeRegExp(name)}\\b`).test(p)) score = 5; // "api" + if (score > 0 && (!best || score > best.score || (score === best.score && rel.length < best.relLen))) { + best = { root: s, score, relLen: rel.length }; + } + } + if (best) { + return { exploreRoot: best.root, nudgeProjects: subs.filter((s) => s !== best!.root), viaSubScan: true }; + } + // No clear match — nudge the full list rather than front-load a guess. + return { exploreRoot: null, nudgeProjects: subs, viaSubScan: true }; +} + +/** + * Contents of `.atlas/.gitignore`. A single wildcard ignore keeps every + * transient file in the index dir — the database, `daemon.pid`, the socket, + * logs, cache, and anything future versions add — out of git, without having + * to enumerate each name (issues #788, #492, #484). Older versions wrote an + * explicit allowlist that never listed `daemon.pid` or the socket, so those + * runtime files were silently committed. + */ +const GITIGNORE_CONTENT = `# Atlas data files — local to each machine, not for committing. +# Ignore everything in .tempest/atlas/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore +`; + +/** Header line that prefixes every .gitignore Atlas has auto-generated. */ +const GITIGNORE_MARKER = '# Atlas data files'; + +/** + * Is `content` a stale Atlas-generated `.gitignore` that should be + * regenerated in place? True when it carries our header but predates the + * wildcard ignore (it has no bare `*` line) — i.e. one of the old explicit + * allowlists (`*.db`, `cache/`, `.dirty`, …) that never ignored `daemon.pid` + * or the socket (issue #788). A file WITHOUT our header is user-authored and + * is left untouched; one that already has the wildcard is current. Matching + * on the header (not a byte-exact list of past defaults) heals every old + * variant — v0.7.x through 0.9.9 — and is idempotent once upgraded. + */ +function isStaleDefaultGitignore(content: string): boolean { + if (!content.trimStart().startsWith(GITIGNORE_MARKER)) return false; + return !content.split('\n').some((line) => line.trim() === '*'); +} + +/** + * Write `.atlas/.gitignore` if it's absent, or upgrade a stale + * Atlas-generated default in place; a user-customized file is left alone. + * Best-effort — returns `false` only if a needed write failed. + */ +function ensureGitignore(gitignorePath: string): boolean { + let existing: string | null; + try { + existing = fs.readFileSync(gitignorePath, 'utf-8'); + } catch { + existing = null; // absent (ENOENT) or unreadable — (re)create below + } + // Current default or a user-authored file: nothing to do. + if (existing !== null && !isStaleDefaultGitignore(existing)) return true; + try { + fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8'); + return true; + } catch { + return false; + } +} + +/** + * Create the .atlas directory structure + * Note: Only throws if atlas.db already exists, not just if .atlas/ exists. + */ +export function createDirectory(projectRoot: string): void { + const atlasDir = getAtlasDir(projectRoot); + const dbPath = path.join(atlasDir, 'atlas.db'); + + // Only throw if Atlas is actually initialized (db exists) + // .atlas/ folder alone is fine + if (fs.existsSync(dbPath)) { + throw new Error(`Atlas already initialized in ${projectRoot}`); + } + + // Create main directory (if it doesn't exist) + fs.mkdirSync(atlasDir, { recursive: true }); + + // Write .gitignore inside .atlas (create if absent, upgrade a stale + // pre-wildcard default left by an older version — issue #788). + ensureGitignore(path.join(atlasDir, '.gitignore')); +} + +/** + * Remove the .atlas directory + */ +export function removeDirectory(projectRoot: string): void { + const atlasDir = getAtlasDir(projectRoot); + + if (!fs.existsSync(atlasDir)) { + return; + } + + // Verify .atlas is a real directory, not a symlink pointing elsewhere + const lstat = fs.lstatSync(atlasDir); + if (lstat.isSymbolicLink()) { + // Only remove the symlink itself, never follow it for recursive delete + fs.unlinkSync(atlasDir); + return; + } + + if (!lstat.isDirectory()) { + // Not a directory - remove the single file + fs.unlinkSync(atlasDir); + return; + } + + // Recursively remove directory + fs.rmSync(atlasDir, { recursive: true, force: true }); +} + +/** + * Get all files in the .atlas directory + */ +export function listDirectoryContents(projectRoot: string): string[] { + const atlasDir = getAtlasDir(projectRoot); + + if (!fs.existsSync(atlasDir)) { + return []; + } + + const files: string[] = []; + + function walkDir(dir: string, prefix: string = ''): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + + // Skip symlinks to prevent following links outside .atlas + if (entry.isSymbolicLink()) { + continue; + } + + if (entry.isDirectory()) { + walkDir(path.join(dir, entry.name), relativePath); + } else { + files.push(relativePath); + } + } + } + + walkDir(atlasDir); + return files; +} + +/** + * Get the total size of the .atlas directory in bytes + */ +export function getDirectorySize(projectRoot: string): number { + const atlasDir = getAtlasDir(projectRoot); + + if (!fs.existsSync(atlasDir)) { + return 0; + } + + let totalSize = 0; + + function walkDir(dir: string): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + // Skip symlinks to prevent following links outside .atlas + if (entry.isSymbolicLink()) { + continue; + } + + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + walkDir(fullPath); + } else { + const stats = fs.statSync(fullPath); + totalSize += stats.size; + } + } + } + + walkDir(atlasDir); + return totalSize; +} + +/** + * Ensure a subdirectory exists within .atlas + */ +export function ensureSubdirectory(projectRoot: string, subdirName: string): string { + if (subdirName.includes('..') || subdirName.includes(path.sep) || subdirName.includes('/')) { + throw new Error(`Invalid subdirectory name: ${subdirName}`); + } + + const subdirPath = path.join(getAtlasDir(projectRoot), subdirName); + + if (!fs.existsSync(subdirPath)) { + fs.mkdirSync(subdirPath, { recursive: true }); + } + + return subdirPath; +} + +/** + * Check if the .atlas directory has valid structure + */ +export function validateDirectory(projectRoot: string): { + valid: boolean; + errors: string[]; +} { + const errors: string[] = []; + const atlasDir = getAtlasDir(projectRoot); + + if (!fs.existsSync(atlasDir)) { + errors.push('Atlas directory does not exist'); + return { valid: false, errors }; + } + + if (!fs.statSync(atlasDir).isDirectory()) { + errors.push('.atlas exists but is not a directory'); + return { valid: false, errors }; + } + + // Auto-repair / upgrade .gitignore (non-critical file). A missing one is + // recreated; a stale pre-wildcard default that never ignored daemon.pid is + // regenerated in place (issue #788); a user-authored file is left alone. + const gitignorePath = path.join(atlasDir, '.gitignore'); + const existedBefore = fs.existsSync(gitignorePath); + if (!ensureGitignore(gitignorePath) && !existedBefore) { + // Only a missing-and-uncreatable file is surfaced; a failed in-place + // upgrade of an existing file is non-fatal — the index still works. + errors.push('.gitignore missing in .atlas directory and could not be created'); + } + + return { + valid: errors.length === 0, + errors, + }; +} diff --git a/packages/atlas/src/errors.ts b/packages/atlas/src/errors.ts new file mode 100644 index 0000000..a173c89 --- /dev/null +++ b/packages/atlas/src/errors.ts @@ -0,0 +1,240 @@ +/** + * Atlas Error Classes + * + * Custom error types for better error handling and debugging. + * + * @module errors + * + * @example + * ```typescript + * import { FileError, ParseError, setLogger, silentLogger } from 'atlas'; + * + * // Catch specific error types + * try { + * await cg.indexAll(); + * } catch (error) { + * if (error instanceof FileError) { + * console.log(`File error at ${error.filePath}: ${error.message}`); + * } else if (error instanceof ParseError) { + * console.log(`Parse error at ${error.filePath}:${error.line}`); + * } + * } + * + * // Disable logging for tests + * setLogger(silentLogger); + * ``` + */ + +/** + * Base error class for all Atlas errors. + * + * All Atlas-specific errors extend this class, allowing you to catch + * all Atlas errors with a single catch block. + * + * @example + * ```typescript + * try { + * await cg.indexAll(); + * } catch (error) { + * if (error instanceof AtlasError) { + * console.log(`Atlas error [${error.code}]: ${error.message}`); + * } + * } + * ``` + */ +export class AtlasError extends Error { + /** Error code for categorization (e.g., 'FILE_ERROR', 'PARSE_ERROR') */ + readonly code: string; + /** Additional context about the error */ + readonly context?: Record; + + constructor(message: string, code: string, context?: Record) { + super(message); + this.name = 'AtlasError'; + this.code = code; + this.context = context; + + // Maintain proper stack trace for V8 + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +/** + * Error reading or accessing files + */ +export class FileError extends AtlasError { + readonly filePath: string; + + constructor(message: string, filePath: string, cause?: Error) { + super(message, 'FILE_ERROR', { filePath, cause: cause?.message }); + this.name = 'FileError'; + this.filePath = filePath; + if (cause) { + this.cause = cause; + } + } +} + +/** + * Error parsing source code + */ +export class ParseError extends AtlasError { + readonly filePath: string; + readonly line?: number; + readonly column?: number; + + constructor( + message: string, + filePath: string, + options?: { line?: number; column?: number; cause?: Error } + ) { + super(message, 'PARSE_ERROR', { + filePath, + line: options?.line, + column: options?.column, + cause: options?.cause?.message, + }); + this.name = 'ParseError'; + this.filePath = filePath; + this.line = options?.line; + this.column = options?.column; + if (options?.cause) { + this.cause = options.cause; + } + } +} + +/** + * Error with database operations + */ +export class DatabaseError extends AtlasError { + readonly operation: string; + + constructor(message: string, operation: string, cause?: Error) { + super(message, 'DATABASE_ERROR', { operation, cause: cause?.message }); + this.name = 'DatabaseError'; + this.operation = operation; + if (cause) { + this.cause = cause; + } + } +} + +/** + * Error with search operations + */ +export class SearchError extends AtlasError { + readonly query: string; + + constructor(message: string, query: string, cause?: Error) { + super(message, 'SEARCH_ERROR', { query, cause: cause?.message }); + this.name = 'SearchError'; + this.query = query; + if (cause) { + this.cause = cause; + } + } +} + +/** + * Error with vector/embedding operations + */ +export class VectorError extends AtlasError { + constructor(message: string, operation: string, cause?: Error) { + super(message, 'VECTOR_ERROR', { operation, cause: cause?.message }); + this.name = 'VectorError'; + if (cause) { + this.cause = cause; + } + } +} + +/** + * Error with configuration + */ +export class ConfigError extends AtlasError { + constructor(message: string, details?: Record) { + super(message, 'CONFIG_ERROR', details); + this.name = 'ConfigError'; + } +} + +/** + * Simple logger for Atlas operations + * + * By default, logs to console.warn for warnings and console.error for errors. + * Can be configured to use custom logging. + */ +export interface Logger { + debug(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, context?: Record): void; +} + +/** + * Default console-based logger + */ +export const defaultLogger: Logger = { + debug(message: string, context?: Record): void { + if (process.env.ATLAS_DEBUG) { + console.debug(`[Atlas] ${message}`, context ?? ''); + } + }, + warn(message: string, context?: Record): void { + console.warn(`[Atlas] ${message}`, context ?? ''); + }, + error(message: string, context?: Record): void { + console.error(`[Atlas] ${message}`, context ?? ''); + }, +}; + +/** + * Silent logger (no output) - useful for tests + */ +export const silentLogger: Logger = { + debug(): void {}, + warn(): void {}, + error(): void {}, +}; + +/** + * Current logger instance (can be replaced) + */ +let currentLogger: Logger = defaultLogger; + +/** + * Set the global logger + */ +export function setLogger(logger: Logger): void { + currentLogger = logger; +} + +/** + * Get the current logger + */ +export function getLogger(): Logger { + return currentLogger; +} + +/** + * Log a debug message + */ +export function logDebug(message: string, context?: Record): void { + currentLogger.debug(message, context); +} + +/** + * Log a warning message + */ +export function logWarn(message: string, context?: Record): void { + currentLogger.warn(message, context); +} + +/** + * Log an error message + */ +export function logError(message: string, context?: Record): void { + currentLogger.error(message, context); +} diff --git a/packages/atlas/src/extraction/astro-extractor.ts b/packages/atlas/src/extraction/astro-extractor.ts new file mode 100644 index 0000000..e389893 --- /dev/null +++ b/packages/atlas/src/extraction/astro-extractor.ts @@ -0,0 +1,365 @@ +import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; +import { TreeSitterExtractor } from './tree-sitter'; +import { isLanguageSupported } from './grammars'; + +/** + * Astro built-in components — compiler-provided (``) or shipped by + * `astro:components` (``, ``), not user code. + */ +const ASTRO_BUILTIN_COMPONENTS = new Set(['Fragment', 'Code', 'Debug']); + +/** + * AstroExtractor - Extracts code relationships from Astro component files + * + * Astro files are multi-language: a TypeScript frontmatter block fenced by + * `---` lines, a JSX-like HTML template, and optional and ranges + const tagRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g; + let tagMatch; + while ((tagMatch = tagRegex.exec(this.source)) !== null) { + const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length; + const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length; + coveredRanges.push([startLine, endLine]); + } + + // Find template expressions: {...} outside of script/style blocks + // Matches curly-brace expressions, excluding Svelte block syntax ({#if}, {:else}, {/if}, {@html}, {@render}) + const lines = this.source.split('\n'); + const exprRegex = /\{([^}#/:@][^}]*)\}/g; + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + // Skip lines inside script/style blocks + if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue; + + const line = lines[lineIdx]!; + let exprMatch; + while ((exprMatch = exprRegex.exec(line)) !== null) { + const expr = exprMatch[1]!; + // Extract function calls: identifiers followed by ( + // Matches: cn(...), buttonVariants(...), obj.method(...) + const callRegex = /\b([a-zA-Z_$][\w$.]*)\s*\(/g; + let callMatch; + while ((callMatch = callRegex.exec(expr)) !== null) { + const calleeName = callMatch[1]!; + // Skip Svelte runes, control flow keywords, and common non-function patterns + if (SVELTE_RUNES.has(calleeName)) continue; + if (calleeName === 'if' || calleeName === 'else' || calleeName === 'each' || calleeName === 'await') continue; + + this.unresolvedReferences.push({ + fromNodeId: componentNodeId, + referenceName: calleeName, + referenceKind: 'calls', + line: lineIdx + 1, // 1-indexed + column: exprMatch.index + callMatch.index, + filePath: this.filePath, + language: 'svelte', + }); + } + } + } + } + + /** + * Extract component usages from the Svelte template. + * + * PascalCase tags like ,