mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
Merge branch 'main' into feat/Desktop-app
This commit is contained in:
commit
16604d5a19
49 changed files with 3756 additions and 532 deletions
|
|
@ -203,6 +203,7 @@ gitnexus analyze --skip-git # Index folders that are not Git repositories
|
|||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (default: cores-1, capped at 16; 0 = sequential)
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
|
|
@ -237,8 +238,11 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
|||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size. `0` disables the pool (sequential fallback). Equivalent to `--workers <n>`. | Constrained containers (cgroup CPU limits), CI runners with explicit quotas, or debugging a worker-only crash via `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
|
||||
|
|
|
|||
591
gitnexus-web/package-lock.json
generated
591
gitnexus-web/package-lock.json
generated
|
|
@ -54,7 +54,7 @@
|
|||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vercel/node": "^5.8.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
@ -522,6 +522,13 @@
|
|||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@bytecodealliance/preview2-shim": {
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.6.tgz",
|
||||
"integrity": "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw==",
|
||||
"dev": true,
|
||||
"license": "(Apache-2.0 WITH LLVM-exception)"
|
||||
},
|
||||
"node_modules/@cfworker/json-schema": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
|
||||
|
|
@ -534,30 +541,6 @@
|
|||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
|
|
@ -1216,16 +1199,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/generative-ai": {
|
||||
"version": "0.24.1",
|
||||
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
|
||||
|
|
@ -1723,6 +1696,17 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@renovatebot/pep440": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.1.tgz",
|
||||
"integrity": "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.9.0 || ^22.11.0 || ^24",
|
||||
"pnpm": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.18",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
|
||||
|
|
@ -2430,6 +2414,24 @@
|
|||
"path-browserify": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
|
|
@ -2443,34 +2445,6 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
|
|
@ -2951,23 +2925,35 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vercel/build-utils": {
|
||||
"version": "13.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.2.11.tgz",
|
||||
"integrity": "sha512-jbsg78iS8SLpOkLw378bBLchmzeQ+YtPnztMMuEFBORjY1G4lDxiStMacD3xp5HImCAl1wz4dNV4I8jHKd/3Tg==",
|
||||
"version": "13.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.25.0.tgz",
|
||||
"integrity": "sha512-p2wqxi2I95T+g/+uP+Dc/uq2PApW7F9RbE/Vvwp28JY8SoCGHNUs2pxigttgaNDNF6IlUEMOTz+eJvsXToV/1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@vercel/python-analysis": "0.11.1",
|
||||
"cjs-module-lexer": "1.2.3",
|
||||
"es-module-lexer": "1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/build-utils/node_modules/es-module-lexer": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz",
|
||||
"integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/error-utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.0.3.tgz",
|
||||
"integrity": "sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-DiJcXBOB9N6QM4d7hYPM9Ck/AUjzBl58XNQPxS74o7CuvIanjzrGgygP/70VsyEASeIJMazk1LrhwcNTR/eZGQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@vercel/nft": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.1.1.tgz",
|
||||
"integrity": "sha512-mKMGa7CEUcXU75474kOeqHbtvK1kAcu4wiahhmlUenB5JbTQB8wVlDI8CyHR3rpGo0qlzoRWqcDzI41FUoBJCA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz",
|
||||
"integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2992,20 +2978,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vercel/node": {
|
||||
"version": "5.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.5.23.tgz",
|
||||
"integrity": "sha512-dDJtroLF4D/H9vRMt/x/qI2bKujMOPbk6aIqRKI9WXddngjKziuHxsjcF3zEm5YXGUYDSC2lEVEFrXPbbP+hhw==",
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.2.tgz",
|
||||
"integrity": "sha512-Wt6KBr0LoIhUuzeH7E8S+1HRlS6oSA+98FJH/59wY2tYoxsHXbx5uiNkSBqBsM0nIwlJWR3B86tm9q9Xi22XdQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@edge-runtime/node-utils": "2.3.0",
|
||||
"@edge-runtime/primitives": "4.1.0",
|
||||
"@edge-runtime/vm": "3.2.0",
|
||||
"@types/node": "16.18.11",
|
||||
"@vercel/build-utils": "13.2.11",
|
||||
"@vercel/error-utils": "2.0.3",
|
||||
"@vercel/nft": "1.1.1",
|
||||
"@vercel/static-config": "3.1.2",
|
||||
"@types/node": "20.11.0",
|
||||
"@vercel/build-utils": "13.25.0",
|
||||
"@vercel/error-utils": "2.1.0",
|
||||
"@vercel/nft": "1.5.0",
|
||||
"@vercel/static-config": "3.3.0",
|
||||
"async-listen": "3.0.0",
|
||||
"cjs-module-lexer": "1.2.3",
|
||||
"edge-runtime": "2.5.9",
|
||||
|
|
@ -3017,37 +3003,58 @@
|
|||
"path-to-regexp": "6.1.0",
|
||||
"path-to-regexp-updated": "npm:path-to-regexp@6.3.0",
|
||||
"ts-morph": "12.0.0",
|
||||
"ts-node": "10.9.1",
|
||||
"typescript": "4.9.5",
|
||||
"typescript5": "npm:typescript@5.9.3",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "npm:typescript@5.9.3",
|
||||
"undici": "5.28.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/node/node_modules/@types/node": {
|
||||
"version": "16.18.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz",
|
||||
"integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==",
|
||||
"version": "20.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz",
|
||||
"integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/node/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/node/node_modules/typescript": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"node_modules/@vercel/python-analysis": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.11.1.tgz",
|
||||
"integrity": "sha512-EPPLuXJQhIDUx08H9nG76AR2HSgBquwe3OAX5s2w20M923iaWeGGVkhX/4yZ89CJfXEZgE1Aj/mX7lVHOVIcYA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
"dependencies": {
|
||||
"@bytecodealliance/preview2-shim": "0.17.6",
|
||||
"@renovatebot/pep440": "4.2.1",
|
||||
"fs-extra": "11.1.1",
|
||||
"js-yaml": "4.1.1",
|
||||
"minimatch": "10.1.1",
|
||||
"smol-toml": "1.5.2",
|
||||
"zod": "3.22.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/python-analysis/node_modules/zod": {
|
||||
"version": "3.22.4",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
|
||||
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/static-config": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.1.2.tgz",
|
||||
"integrity": "sha512-2d+TXr6K30w86a+WbMbGm2W91O0UzO5VeemZYBBUJbCjk/5FLLGIi8aV6RS2+WmaRvtcqNTn2pUA7nCOK3bGcQ==",
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.3.0.tgz",
|
||||
"integrity": "sha512-GpS3tPwUeDJCkrKbMNtS2XLRFgfxTlN7YNUL+Bo23+fGolrDw6Oq79R3yvxTYgqRaJMGSEqC7iMw6mj6I5loxg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -3274,19 +3281,6 @@
|
|||
"acorn": "^8"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
|
|
@ -3298,16 +3292,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz",
|
||||
"integrity": "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js": "^4.2.2"
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -3337,12 +3331,12 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
|
|
@ -3438,11 +3432,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
|
|
@ -3495,14 +3492,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
|
|
@ -3762,13 +3761,6 @@
|
|||
"layout-base": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
|
|
@ -4448,16 +4440,6 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
|
|
@ -4759,6 +4741,23 @@
|
|||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
|
|
@ -4863,6 +4862,21 @@
|
|||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz",
|
||||
"integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -4933,23 +4947,36 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/gitnexus-shared": {
|
||||
"resolved": "../gitnexus-shared",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
|
||||
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.1.1",
|
||||
"minipass": "^7.1.2",
|
||||
"path-scurry": "^2.0.0"
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
|
|
@ -4968,6 +4995,22 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
|
|
@ -5475,6 +5518,19 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
|
|
@ -5610,6 +5666,19 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.27",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz",
|
||||
|
|
@ -6049,13 +6118,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
|
|
@ -7037,13 +7099,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"version": "10.2.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz",
|
||||
"integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
|
|
@ -7052,29 +7114,6 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
|
|
@ -7086,11 +7125,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
|
|
@ -7417,9 +7456,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
|
|
@ -7427,16 +7466,16 @@
|
|||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz",
|
||||
"integrity": "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
|
||||
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -7881,6 +7920,16 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
|
|
@ -8057,6 +8106,19 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
|
||||
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyyynthia"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -8181,9 +8243,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
|
|
@ -8364,50 +8426,6 @@
|
|||
"code-block-writer": "^10.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz",
|
||||
"integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ts-toolbelt": {
|
||||
"version": "6.15.5",
|
||||
"resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz",
|
||||
|
|
@ -8422,22 +8440,27 @@
|
|||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"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",
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript5": {
|
||||
"name": "typescript",
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
|
|
@ -8458,16 +8481,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "5.28.4",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz",
|
||||
"integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==",
|
||||
"version": "6.24.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz",
|
||||
"integrity": "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
|
|
@ -8564,6 +8584,16 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
|
@ -8595,16 +8625,6 @@
|
|||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||
|
|
@ -8618,13 +8638,6 @@
|
|||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
|
@ -8938,16 +8951,6 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.30",
|
||||
|
|
@ -30,6 +29,7 @@
|
|||
"axios": "^1.16.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.3",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-layout-force": "^0.2.4",
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vercel/node": "^5.8.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
@ -73,5 +73,18 @@
|
|||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5",
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"@vercel/static-config": {
|
||||
"ajv": "8.18.0"
|
||||
},
|
||||
"@vercel/node": {
|
||||
"path-to-regexp": "6.3.0",
|
||||
"undici": "6.24.0"
|
||||
},
|
||||
"@vercel/python-analysis": {
|
||||
"minimatch": "10.2.3",
|
||||
"smol-toml": "1.6.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"installCommand": "cd ../gitnexus-shared && npm install && npm run build && cd ../gitnexus-web && npm install"
|
||||
"installCommand": "cd ../gitnexus-shared && npm install && npm run build && cd ../gitnexus-web && npm ci --include=dev"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu
|
|||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI
|
||||
gitnexus index # Register an existing .gitnexus/ folder into the global registry
|
||||
|
|
@ -307,6 +308,7 @@ Configure the behavior with two environment variables:
|
|||
|----------|--------|---------|--------|
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
|
||||
|
||||
```bash
|
||||
# Offline/airgapped: never reach the network for extensions
|
||||
|
|
|
|||
236
gitnexus/package-lock.json
generated
236
gitnexus/package-lock.json
generated
|
|
@ -172,9 +172,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -189,9 +189,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -206,9 +206,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -223,9 +223,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -240,9 +240,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -257,9 +257,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -274,9 +274,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -291,9 +291,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -308,9 +308,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -325,9 +325,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -342,9 +342,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -359,9 +359,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -376,9 +376,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
|
|
@ -393,9 +393,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -410,9 +410,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -427,9 +427,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -444,9 +444,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -461,9 +461,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -478,9 +478,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -495,9 +495,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -512,9 +512,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -529,9 +529,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -546,9 +546,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -563,9 +563,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -580,9 +580,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -597,9 +597,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1792,12 +1792,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz",
|
||||
"integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==",
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.21.0"
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
|
|
@ -2558,9 +2558,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
|
@ -2571,32 +2571,32 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
|
|
@ -5045,13 +5045,13 @@
|
|||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.1.tgz",
|
||||
"integrity": "sha512-5QE2Q04cN1u0993w0LT5rPw3faZqZU1fFn1mGE0pV53N1Dn7c+QFFxQu1mBeSgeOXwFyTicZw02wVgp3Tb5cAQ==",
|
||||
"version": "4.22.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz",
|
||||
"integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0"
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
|
|
@ -5121,9 +5121,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
|
||||
"integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==",
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ async function upsertGitNexusSection(
|
|||
content: string,
|
||||
projectName: string,
|
||||
stats: RepoStats,
|
||||
noStats?: boolean,
|
||||
): Promise<'created' | 'updated' | 'appended' | 'preserved'> {
|
||||
const exists = await fileExists(filePath);
|
||||
|
||||
|
|
@ -246,14 +247,23 @@ async function upsertGitNexusSection(
|
|||
// like `({target: "symbolName", direction: "upstream"})`
|
||||
// when noStats is set
|
||||
// Passing projectName + stats explicitly makes the contract obvious.
|
||||
// noStats controls template generation, not keep-section stat updates — the user opted into a stats line by keeping it.
|
||||
// --no-stats wins in the keep path too (#1706): a lean block committed
|
||||
// to git would otherwise churn the volatile counts on every analyze,
|
||||
// producing no-value merge conflicts between branches. Under noStats we
|
||||
// drop the parenthetical but still refresh the project name so renames
|
||||
// propagate.
|
||||
const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`;
|
||||
const statsLine = `Indexed as **${projectName}** (${newStatsInner})`;
|
||||
const statsLine = noStats
|
||||
? `Indexed as **${projectName}**`
|
||||
: `Indexed as **${projectName}** (${newStatsInner})`;
|
||||
|
||||
// Match either canonical phrasing at line start (`^` with `m` flag) so we
|
||||
// cannot replace prose embedded mid-paragraph. Deliberately no `$`: text
|
||||
// after the closing `)` on the same line (e.g. ". MCP tools.") stays intact.
|
||||
const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/m;
|
||||
// after the line on the same line (e.g. ". MCP tools.") stays intact.
|
||||
// The parenthetical is optional so a count-free line left by a prior
|
||||
// --no-stats run still matches — letting the name refresh, and letting
|
||||
// counts return if --no-stats is later dropped.
|
||||
const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\*(?: \([^)]+\))?/m;
|
||||
|
||||
if (statsPattern.test(existingSection)) {
|
||||
const updatedSection = existingSection.replace(statsPattern, statsLine);
|
||||
|
|
@ -389,12 +399,24 @@ export async function generateAIContextFiles(
|
|||
if (!options?.skipAgentsMd) {
|
||||
// Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
|
||||
const agentsPath = path.join(repoPath, 'AGENTS.md');
|
||||
const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats);
|
||||
const agentsResult = await upsertGitNexusSection(
|
||||
agentsPath,
|
||||
content,
|
||||
projectName,
|
||||
stats,
|
||||
options?.noStats,
|
||||
);
|
||||
createdFiles.push(`AGENTS.md (${agentsResult})`);
|
||||
|
||||
// Create CLAUDE.md (for Claude Code)
|
||||
const claudePath = path.join(repoPath, 'CLAUDE.md');
|
||||
const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats);
|
||||
const claudeResult = await upsertGitNexusSection(
|
||||
claudePath,
|
||||
content,
|
||||
projectName,
|
||||
stats,
|
||||
options?.noStats,
|
||||
);
|
||||
createdFiles.push(`CLAUDE.md (${claudeResult})`);
|
||||
} else {
|
||||
createdFiles.push('AGENTS.md (skipped via --skip-agents-md)');
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ import { spawn } from 'child_process';
|
|||
import v8 from 'v8';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { closeLbug } from '../core/lbug/lbug-adapter.js';
|
||||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js';
|
||||
import {
|
||||
isLbugCheckpointIoError,
|
||||
isWalCorruptionError,
|
||||
parseWalCheckpointThreshold,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
} from '../core/lbug/lbug-config.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
getGlobalRegistryPath,
|
||||
|
|
@ -415,6 +420,15 @@ const forceHeapOOMForTestIfEnabled = (): void => {
|
|||
for (;;) chunks.push('x'.repeat(1024 * 1024));
|
||||
};
|
||||
|
||||
// 64 MiB keeps auto-checkpoint enabled but triggers less frequently than
|
||||
// Ladybug's stock ~16 MiB threshold, reducing rename/remove churn on large
|
||||
// runs. Also matches the GitNexus default in `lbug-config.ts`.
|
||||
//
|
||||
// IMPORTANT: keep README examples (`README.md`, `gitnexus/README.md`) and
|
||||
// the `DEFAULT_WAL_CHECKPOINT_THRESHOLD` constant in
|
||||
// `gitnexus/src/core/lbug/lbug-config.ts` in sync with this value.
|
||||
const RECOMMENDED_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;
|
||||
|
||||
/** Re-exec the process with a 16GB heap and larger stack if we're currently below that. */
|
||||
async function ensureHeap(): Promise<boolean> {
|
||||
const nodeOpts = process.env.NODE_OPTIONS || '';
|
||||
|
|
@ -473,8 +487,12 @@ async function ensureHeap(): Promise<boolean> {
|
|||
*/
|
||||
const ANALYZE_CLI_ENV_KEYS = [
|
||||
'GITNEXUS_VERBOSE',
|
||||
'GITNEXUS_PROFILE_DEFERRED',
|
||||
'GITNEXUS_PROFILE_DEFERRED_SLOW_MS',
|
||||
'GITNEXUS_MAX_FILE_SIZE',
|
||||
'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS',
|
||||
'GITNEXUS_WAL_CHECKPOINT_THRESHOLD',
|
||||
'GITNEXUS_WAL_MANUAL_CHECKPOINT',
|
||||
'GITNEXUS_EMBEDDING_THREADS',
|
||||
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
||||
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
||||
|
|
@ -560,6 +578,8 @@ export interface AnalyzeOptions {
|
|||
maxFileSize?: string;
|
||||
/** Override worker sub-batch idle timeout in seconds. */
|
||||
workerTimeout?: string;
|
||||
/** Control LadybugDB WAL auto-checkpoint threshold during analyze. */
|
||||
walCheckpointThreshold?: string;
|
||||
/** Parse worker pool size; 0 disables workers (sequential fallback). */
|
||||
workers?: string;
|
||||
embeddingThreads?: string;
|
||||
|
|
@ -631,6 +651,16 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
);
|
||||
}
|
||||
|
||||
if (options?.walCheckpointThreshold !== undefined) {
|
||||
const parsed = parseWalCheckpointThreshold(options.walCheckpointThreshold);
|
||||
if (parsed === undefined) {
|
||||
cliError(' --wal-checkpoint-threshold must be an integer >= -1.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = String(parsed);
|
||||
}
|
||||
|
||||
// `--workers` is threaded through `runFullAnalysis` options → PipelineOptions
|
||||
// → createWorkerPool, intentionally bypassing the GITNEXUS_WORKER_POOL_SIZE
|
||||
// env channel so this CLI surface never mutates `process.env` for pool size.
|
||||
|
|
@ -1128,6 +1158,20 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
return;
|
||||
}
|
||||
|
||||
if (isLbugCheckpointIoError(err)) {
|
||||
cliError(
|
||||
` LadybugDB failed while rotating/removing WAL checkpoint files.\n` +
|
||||
` This can happen when auto-checkpoint runs at the default threshold (~16MB).\n` +
|
||||
` Retry with a larger checkpoint threshold to reduce checkpoint frequency:\n` +
|
||||
` gitnexus analyze --wal-checkpoint-threshold ${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD}\n` +
|
||||
` (or set GITNEXUS_WAL_CHECKPOINT_THRESHOLD=${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD})\n` +
|
||||
` (Try 33554432 = 32 MiB on small-disk / CI runners.)\n`,
|
||||
{ recoveryHint: 'wal-checkpoint-threshold' },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// HF download failure — show clean guidance without the raw stack trace.
|
||||
// Checked before writeFatalToStderr so the user sees one focused message
|
||||
// rather than a stack-trace dump followed by a second remediation block.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,38 @@
|
|||
*/
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
/**
|
||||
* String-literal union of all `recoveryHint` tags emitted by the CLI.
|
||||
*
|
||||
* Centralized so a new recovery branch added in `analyze.ts` cannot land
|
||||
* without updating this union — TypeScript will reject the unknown literal
|
||||
* passed via `cliError({ recoveryHint: '...' })`. To add a new hint:
|
||||
* 1. Add the tag string to this union.
|
||||
* 2. Pass it as the `recoveryHint` field at the relevant `cliError`
|
||||
* call site.
|
||||
*
|
||||
* Consumers can import this type to narrow log-record `recoveryHint`
|
||||
* fields without restating the literal list.
|
||||
*/
|
||||
export type RecoveryHint =
|
||||
| 'wal-corruption'
|
||||
| 'wal-checkpoint-threshold'
|
||||
| 'heap-oom-respawn'
|
||||
| 'native-worker-abort'
|
||||
| 'hf-endpoint-unreachable'
|
||||
| 'large-repo'
|
||||
| 'npm-resolution'
|
||||
| 'module-not-found';
|
||||
|
||||
/**
|
||||
* Common shape for the optional structured-field bag passed to
|
||||
* `cliError`/`cliWarn`/`cliInfo`. Typed so the `recoveryHint` slot is
|
||||
* checked against the {@link RecoveryHint} union.
|
||||
*/
|
||||
export interface CliMessageFields extends Record<string, unknown> {
|
||||
recoveryHint?: RecoveryHint;
|
||||
}
|
||||
|
||||
function writeStderr(msg: string): void {
|
||||
// Direct write — bypassing `console.*` so it cannot be intercepted by
|
||||
// progress-bar redirection (see `cli/analyze.ts:barLog`) or other
|
||||
|
|
@ -41,7 +73,7 @@ function writeStderr(msg: string): void {
|
|||
* User-facing informational message. Use for banners, listening URLs,
|
||||
* and any message the user expects to read in plain text.
|
||||
*/
|
||||
export function cliInfo(msg: string, fields?: Record<string, unknown>): void {
|
||||
export function cliInfo(msg: string, fields?: CliMessageFields): void {
|
||||
writeStderr(msg);
|
||||
logger.info(fields ?? {}, msg);
|
||||
}
|
||||
|
|
@ -50,7 +82,7 @@ export function cliInfo(msg: string, fields?: Record<string, unknown>): void {
|
|||
* User-facing warning. Operator-actionable but non-fatal — `cliWarn`
|
||||
* indicates the command can still proceed in some form.
|
||||
*/
|
||||
export function cliWarn(msg: string, fields?: Record<string, unknown>): void {
|
||||
export function cliWarn(msg: string, fields?: CliMessageFields): void {
|
||||
writeStderr(msg);
|
||||
logger.warn(fields ?? {}, msg);
|
||||
}
|
||||
|
|
@ -59,7 +91,7 @@ export function cliWarn(msg: string, fields?: Record<string, unknown>): void {
|
|||
* User-facing error. Indicates the command cannot proceed; usually
|
||||
* paired with a non-zero exit code at the call site.
|
||||
*/
|
||||
export function cliError(msg: string, fields?: Record<string, unknown>): void {
|
||||
export function cliError(msg: string, fields?: CliMessageFields): void {
|
||||
writeStderr(msg);
|
||||
logger.error(fields ?? {}, msg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ program
|
|||
'--worker-timeout <seconds>',
|
||||
'Worker sub-batch idle timeout before retry/fallback. Default: 30.',
|
||||
)
|
||||
.option(
|
||||
'--wal-checkpoint-threshold <bytes>',
|
||||
'LadybugDB WAL auto-checkpoint threshold in bytes during analyze ' +
|
||||
'(integer >= -1; default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).',
|
||||
)
|
||||
.option(
|
||||
'--workers <n>',
|
||||
'Parse worker pool size. Default: cores-1 capped at 16. Pass 0 to disable workers (sequential).',
|
||||
|
|
@ -85,6 +90,7 @@ program
|
|||
' GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n' +
|
||||
' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' +
|
||||
' GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' +
|
||||
' GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n' +
|
||||
' GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n' +
|
||||
|
|
@ -93,6 +99,7 @@ program
|
|||
' GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n' +
|
||||
' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' +
|
||||
' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n' +
|
||||
'\nFlags override the corresponding env vars when both are provided.\n' +
|
||||
'\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' +
|
||||
' `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export const cCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.C,
|
||||
|
|
@ -9,4 +10,168 @@ export const cCallConfig: CallExtractionConfig = {
|
|||
|
||||
export const cppCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.CPlusPlus,
|
||||
extractLanguageCallSite(callNode) {
|
||||
return extractCppOperatorCallSite(callNode);
|
||||
},
|
||||
};
|
||||
|
||||
function extractCppOperatorCallSite(callNode: SyntaxNode) {
|
||||
if (callNode.type !== 'binary_expression') return null;
|
||||
if (isPrimitiveOnlyBinaryOperatorCall(callNode)) return null;
|
||||
|
||||
const operator = callNode.childForFieldName('operator')?.text.trim();
|
||||
// Keep the legacy DAG conservative: only simple identifier operands are
|
||||
// modeled here. Complex expressions stay unresolved instead of guessed.
|
||||
if (operator === '+') {
|
||||
const left = callNode.childForFieldName('left');
|
||||
const right = callNode.childForFieldName('right');
|
||||
if (left?.type !== 'identifier' || right?.type !== 'identifier') return null;
|
||||
return {
|
||||
calledName: 'operator+',
|
||||
callForm: 'member' as const,
|
||||
receiverName: left.text,
|
||||
argCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (operator === '<<') {
|
||||
const right = callNode.childForFieldName('right');
|
||||
if (right?.type !== 'identifier') return null;
|
||||
return {
|
||||
calledName: 'operator<<',
|
||||
callForm: 'free' as const,
|
||||
argCount: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPrimitiveOnlyBinaryOperatorCall(callNode: SyntaxNode): boolean {
|
||||
const left = callNode.childForFieldName('left');
|
||||
const right = callNode.childForFieldName('right');
|
||||
if (left === null || right === null) return false;
|
||||
return isBuiltinOperatorOperand(left) && isBuiltinOperatorOperand(right);
|
||||
}
|
||||
|
||||
function isBuiltinOperatorOperand(node: SyntaxNode): boolean {
|
||||
return isBuiltinOperatorType(inferCppOperatorOperandType(node));
|
||||
}
|
||||
|
||||
function inferCppOperatorOperandType(node: SyntaxNode): string {
|
||||
const literalType = inferCppLiteralType(node);
|
||||
if (literalType !== '') return literalType;
|
||||
if (node.type === 'identifier') return lookupCppIdentifierType(node);
|
||||
return '';
|
||||
}
|
||||
|
||||
function inferCppLiteralType(node: SyntaxNode): string {
|
||||
if (node.type === 'number_literal') return node.text.includes('.') ? 'double' : 'int';
|
||||
if (node.type === 'char_literal') return 'char';
|
||||
if (node.type === 'true' || node.type === 'false') return 'bool';
|
||||
return '';
|
||||
}
|
||||
|
||||
function lookupCppIdentifierType(identNode: SyntaxNode): string {
|
||||
const varName = identNode.text;
|
||||
let scope: SyntaxNode | null = identNode.parent;
|
||||
while (
|
||||
scope !== null &&
|
||||
scope.type !== 'compound_statement' &&
|
||||
scope.type !== 'translation_unit'
|
||||
) {
|
||||
scope = scope.parent;
|
||||
}
|
||||
if (scope === null) return '';
|
||||
|
||||
const parameterType = lookupCppFunctionParameterType(scope, varName);
|
||||
if (parameterType !== '') return parameterType;
|
||||
|
||||
for (let i = 0; i < scope.childCount; i++) {
|
||||
const stmt = scope.child(i);
|
||||
if (stmt === null || stmt.type !== 'declaration') continue;
|
||||
const typeNode = stmt.childForFieldName('type');
|
||||
const declarator = stmt.childForFieldName('declarator');
|
||||
if (typeNode === null || declarator === null) continue;
|
||||
if (extractDeclaratorLeafName(declarator) === varName)
|
||||
return normalizeCppTypeText(typeNode.text);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function lookupCppFunctionParameterType(scope: SyntaxNode, varName: string): string {
|
||||
let node: SyntaxNode | null = scope.parent;
|
||||
while (node !== null) {
|
||||
if (node.type === 'function_definition' || node.type === 'function_declarator') {
|
||||
const fnDecl =
|
||||
node.type === 'function_declarator'
|
||||
? node
|
||||
: findFirstDescendantOfType(node, 'function_declarator');
|
||||
const params = fnDecl?.childForFieldName('parameters') ?? null;
|
||||
if (params === null) return '';
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const param = params.namedChild(i);
|
||||
if (param === null || param.type !== 'parameter_declaration') continue;
|
||||
const declarator = param.childForFieldName('declarator');
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (
|
||||
declarator !== null &&
|
||||
typeNode !== null &&
|
||||
extractDeclaratorLeafName(declarator) === varName
|
||||
) {
|
||||
return normalizeCppTypeText(typeNode.text);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function findFirstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null {
|
||||
if (node.type === type) return node;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const found = findFirstDescendantOfType(node.namedChild(i)!, type);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractDeclaratorLeafName(node: SyntaxNode): string {
|
||||
if (
|
||||
node.type === 'identifier' ||
|
||||
node.type === 'field_identifier' ||
|
||||
node.type === 'operator_name'
|
||||
) {
|
||||
return node.text;
|
||||
}
|
||||
|
||||
const named = node.namedChildren;
|
||||
for (let i = named.length - 1; i >= 0; i--) {
|
||||
const name = extractDeclaratorLeafName(named[i]!);
|
||||
if (name !== '') return name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeCppTypeText(text: string): string {
|
||||
return text
|
||||
.replace(/\b(const|volatile|static|extern|register|mutable|inline|constexpr)\b/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isBuiltinOperatorType(type: string): boolean {
|
||||
return (
|
||||
type === 'bool' ||
|
||||
type === 'char' ||
|
||||
type === 'double' ||
|
||||
type === 'float' ||
|
||||
type === 'int' ||
|
||||
type === 'long' ||
|
||||
type === 'short' ||
|
||||
type === 'signed' ||
|
||||
type === 'unsigned'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@ import { generateId } from '../../lib/utils.js';
|
|||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
import { isRegistryPrimary } from './registry-primary-flag.js';
|
||||
import { isVerboseIngestionEnabled } from './utils/verbose.js';
|
||||
import {
|
||||
deferredCallFileSlowMs,
|
||||
deferredCallLogEveryN,
|
||||
getDeferredProfileDroppedCount,
|
||||
isDeferredResolutionProfileEnabled,
|
||||
logDeferredProfile,
|
||||
profileElapsedMs,
|
||||
resetDeferredProfileDroppedCount,
|
||||
startTimer,
|
||||
} from './utils/deferred-resolution-profile.js';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
|
||||
import {
|
||||
|
|
@ -2909,6 +2919,39 @@ export const processCallsFromExtracted = async (
|
|||
}
|
||||
const totalFiles = byFile.size;
|
||||
let filesProcessed = 0;
|
||||
// Counts only files that survived the registry-primary skip — what the user
|
||||
// is actually waiting on. Keyed by this counter, the first per-file progress
|
||||
// log fires on the first *resolved* file rather than file #1 of byFile,
|
||||
// which would silently land inside the skip block on mixed Python+JVM repos
|
||||
// where the skipped language sorts first.
|
||||
let resolvedFiles = 0;
|
||||
const profileCalls = isDeferredResolutionProfileEnabled();
|
||||
const slowFileMs = profileCalls ? deferredCallFileSlowMs() : 0;
|
||||
const logEveryN = profileCalls ? deferredCallLogEveryN() : 0;
|
||||
let skippedRegistryPrimaryFiles = 0;
|
||||
|
||||
// Fresh dropped-log counter per analyze run — the module-private counter
|
||||
// in deferred-resolution-profile.ts is process-lived, so without a reset
|
||||
// here it would accumulate across consecutive analyze invocations in the
|
||||
// same Node process (e.g., the MCP server, eval harness, integration
|
||||
// tests).
|
||||
if (profileCalls) resetDeferredProfileDroppedCount();
|
||||
|
||||
// One-pass pre-count of the eventual non-skipped total so the live progress
|
||||
// denominator stays stable as the loop iterates. Otherwise `${totalFiles -
|
||||
// skippedRegistryPrimaryFiles}` drifts upward — files iterated before later
|
||||
// registry-primary skips have been seen carry an inflated denominator, and
|
||||
// the ratio only self-corrects after every file has been classified. Pre-
|
||||
// count runs only on the enabled path so the disabled path stays free of
|
||||
// the extra Map iteration. Defaults to 0 on the disabled path; the live log
|
||||
// gate is also disabled there, so the value is never read.
|
||||
let resolvedTotal = 0;
|
||||
if (profileCalls) {
|
||||
for (const filePath of byFile.keys()) {
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !isRegistryPrimary(lang)) resolvedTotal++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [filePath, calls] of byFile) {
|
||||
filesProcessed++;
|
||||
|
|
@ -2920,7 +2963,19 @@ export const processCallsFromExtracted = async (
|
|||
// Registry-primary gate: skip Python (etc.) entirely when the
|
||||
// scope-based phase owns CALLS for this language.
|
||||
const fileLanguage = getLanguageFromFilename(filePath);
|
||||
if (fileLanguage && isRegistryPrimary(fileLanguage)) continue;
|
||||
if (fileLanguage && isRegistryPrimary(fileLanguage)) {
|
||||
skippedRegistryPrimaryFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
resolvedFiles++;
|
||||
const tFile = startTimer(profileCalls);
|
||||
|
||||
if (profileCalls && (resolvedFiles === 1 || resolvedFiles % logEveryN === 0)) {
|
||||
logDeferredProfile(
|
||||
`calls ${resolvedFiles}/${resolvedTotal} file=${filePath} sites=${calls.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.enableCache(filePath);
|
||||
const widenCache: WidenCache = new Map();
|
||||
|
|
@ -3079,6 +3134,25 @@ export const processCallsFromExtracted = async (
|
|||
}
|
||||
|
||||
ctx.clearCache();
|
||||
|
||||
if (tFile !== null) {
|
||||
const elapsed = profileElapsedMs(tFile);
|
||||
if (elapsed >= slowFileMs) {
|
||||
logDeferredProfile(
|
||||
`slow file ${elapsed.toFixed(0)}ms path=${filePath} calls=${calls.length} lang=${fileLanguage ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (profileCalls) {
|
||||
logDeferredProfile(
|
||||
`processCallsFromExtracted done: ${totalFiles} files, ${extractedCalls.length} call sites, skipped registry-primary files=${skippedRegistryPrimaryFiles}`,
|
||||
);
|
||||
const droppedCount = getDeferredProfileDroppedCount();
|
||||
if (droppedCount > 0) {
|
||||
logDeferredProfile(`note: ${droppedCount} profile log lines dropped (logger errors)`);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(totalFiles, totalFiles);
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ const cCppExtractFunctionName = (
|
|||
c?.type === 'qualified_identifier' ||
|
||||
c?.type === 'identifier' ||
|
||||
c?.type === 'field_identifier' ||
|
||||
c?.type === 'operator_name' ||
|
||||
c?.type === 'parenthesized_declarator'
|
||||
) {
|
||||
innerDeclarator = c;
|
||||
|
|
@ -244,7 +245,7 @@ const cCppExtractFunctionName = (
|
|||
if (!nameNode) {
|
||||
for (let i = 0; i < innerDeclarator.childCount; i++) {
|
||||
const c = innerDeclarator.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
if (c?.type === 'identifier' || c?.type === 'operator_name') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
|
|
@ -256,7 +257,8 @@ const cCppExtractFunctionName = (
|
|||
}
|
||||
} else if (
|
||||
innerDeclarator?.type === 'identifier' ||
|
||||
innerDeclarator?.type === 'field_identifier'
|
||||
innerDeclarator?.type === 'field_identifier' ||
|
||||
innerDeclarator?.type === 'operator_name'
|
||||
) {
|
||||
// field_identifier is used for method names inside C++ class bodies
|
||||
funcName = innerDeclarator.text;
|
||||
|
|
@ -275,7 +277,7 @@ const cCppExtractFunctionName = (
|
|||
if (!nameNode) {
|
||||
for (let i = 0; i < nestedId.childCount; i++) {
|
||||
const c = nestedId.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
if (c?.type === 'identifier' || c?.type === 'operator_name') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,17 +182,41 @@ export function emitCppScopeCaptures(
|
|||
grouped['@reference.call.free'] ??
|
||||
grouped['@reference.call.member'] ??
|
||||
grouped['@reference.call.qualified'];
|
||||
const operatorAnchor = grouped['@reference.operator'];
|
||||
if (operatorAnchor !== undefined) {
|
||||
const operatorNode =
|
||||
callAnchor !== undefined
|
||||
? findNodeAtRange(tree.rootNode, callAnchor.range, 'binary_expression')
|
||||
: null;
|
||||
if (operatorNode !== null && isPrimitiveOnlyBinaryOperator(operatorNode)) continue;
|
||||
}
|
||||
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression');
|
||||
if (callNode !== null) {
|
||||
const callNode =
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'binary_expression');
|
||||
if (callNode?.type === 'call_expression') {
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
String(computeCppCallArity(callNode)),
|
||||
);
|
||||
} else if (callNode?.type === 'binary_expression') {
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
grouped['@reference.call.member'] !== undefined ? '1' : '2',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (operatorAnchor !== undefined && grouped['@reference.name'] === undefined) {
|
||||
grouped['@reference.name'] = syntheticCapture(
|
||||
'@reference.name',
|
||||
findNodeAtRange(tree.rootNode, operatorAnchor.range, operatorAnchor.text) ?? tree.rootNode,
|
||||
`operator${operatorAnchor.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Enrich constructor calls (new Foo()) with arity ─────────────
|
||||
const ctorCallAnchor = grouped['@reference.call.constructor'];
|
||||
if (ctorCallAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
|
|
@ -211,9 +235,13 @@ export function emitCppScopeCaptures(
|
|||
if (anyCallAnchor !== undefined && grouped['@reference.parameter-types'] === undefined) {
|
||||
const cNode =
|
||||
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression');
|
||||
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'binary_expression');
|
||||
if (cNode !== null) {
|
||||
const argTypes = inferCppCallArgTypes(cNode);
|
||||
const argTypes =
|
||||
cNode.type === 'binary_expression'
|
||||
? inferCppBinaryOperatorArgTypes(cNode, grouped['@reference.call.free'] !== undefined)
|
||||
: inferCppCallArgTypes(cNode);
|
||||
if (argTypes !== undefined && argTypes.length > 0) {
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
|
|
@ -221,7 +249,13 @@ export function emitCppScopeCaptures(
|
|||
JSON.stringify(argTypes),
|
||||
);
|
||||
}
|
||||
const argTypeClasses = inferCppCallArgTypeClasses(cNode);
|
||||
const argTypeClasses =
|
||||
cNode.type === 'binary_expression'
|
||||
? inferCppBinaryOperatorArgTypeClasses(
|
||||
cNode,
|
||||
grouped['@reference.call.free'] !== undefined,
|
||||
)
|
||||
: inferCppCallArgTypeClasses(cNode);
|
||||
if (argTypeClasses !== undefined && argTypeClasses.length > 0) {
|
||||
grouped['@reference.parameter-type-classes'] = syntheticCapture(
|
||||
'@reference.parameter-type-classes',
|
||||
|
|
@ -716,6 +750,69 @@ function inferCppCallArgTypeClasses(node: SyntaxNode): ParameterTypeClass[] | un
|
|||
return classes.length > 0 ? classes : undefined;
|
||||
}
|
||||
|
||||
function inferCppBinaryOperatorArgTypes(
|
||||
node: SyntaxNode,
|
||||
includeLeftOperand: boolean,
|
||||
): string[] | undefined {
|
||||
const operands = binaryOperatorOperands(node, includeLeftOperand);
|
||||
if (operands.length === 0) return undefined;
|
||||
const types = operands.map(inferCppExpressionType);
|
||||
return types.length > 0 ? types : undefined;
|
||||
}
|
||||
|
||||
function inferCppBinaryOperatorArgTypeClasses(
|
||||
node: SyntaxNode,
|
||||
includeLeftOperand: boolean,
|
||||
): ParameterTypeClass[] | undefined {
|
||||
const operands = binaryOperatorOperands(node, includeLeftOperand);
|
||||
if (operands.length === 0) return undefined;
|
||||
const classes = operands.map(inferCppExpressionTypeClass);
|
||||
return classes.length > 0 ? classes : undefined;
|
||||
}
|
||||
|
||||
function binaryOperatorOperands(node: SyntaxNode, includeLeftOperand: boolean): SyntaxNode[] {
|
||||
const operands: SyntaxNode[] = [];
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
if (includeLeftOperand && left !== null) operands.push(left);
|
||||
if (right !== null) operands.push(right);
|
||||
return operands;
|
||||
}
|
||||
|
||||
function isPrimitiveOnlyBinaryOperator(node: SyntaxNode): boolean {
|
||||
const operands = binaryOperatorOperands(node, true);
|
||||
return operands.length > 0 && operands.every((operand) => isBuiltinOperatorType(operand));
|
||||
}
|
||||
|
||||
function isBuiltinOperatorType(node: SyntaxNode): boolean {
|
||||
const type = inferCppExpressionType(node);
|
||||
return (
|
||||
type === 'bool' ||
|
||||
type === 'char' ||
|
||||
type === 'double' ||
|
||||
type === 'float' ||
|
||||
type === 'int' ||
|
||||
type === 'long' ||
|
||||
type === 'short' ||
|
||||
type === 'signed' ||
|
||||
type === 'unsigned'
|
||||
);
|
||||
}
|
||||
|
||||
function inferCppExpressionType(node: SyntaxNode): string {
|
||||
const litType = inferCppLiteralType(node);
|
||||
if (litType !== '') return litType;
|
||||
if (node.type === 'identifier') return lookupDeclaredTypeForIdentifier(node);
|
||||
return '';
|
||||
}
|
||||
|
||||
function inferCppExpressionTypeClass(node: SyntaxNode): ParameterTypeClass {
|
||||
const litType = inferCppLiteralType(node);
|
||||
if (litType !== '') return valueTypeClass(litType);
|
||||
if (node.type === 'identifier') return lookupDeclaredTypeClassForIdentifier(node);
|
||||
return unknownTypeClass('unknown');
|
||||
}
|
||||
|
||||
function valueTypeClass(base: string): ParameterTypeClass {
|
||||
return { base, cv: 'none', indirection: 'value', pointerDepth: 0 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@ const CPP_SCOPE_QUERY = `
|
|||
declarator: (qualified_identifier
|
||||
name: (identifier) @declaration.name))) @declaration.method
|
||||
|
||||
;; Out-of-class operator method: Point::operator+(...)
|
||||
(function_definition
|
||||
declarator: (function_declarator
|
||||
declarator: (qualified_identifier
|
||||
name: (operator_name) @declaration.name))) @declaration.method
|
||||
|
||||
;; ─── Declarations — out-of-class method with pointer return ─────────
|
||||
(function_definition
|
||||
declarator: (pointer_declarator
|
||||
|
|
@ -130,6 +136,11 @@ const CPP_SCOPE_QUERY = `
|
|||
declarator: (function_declarator
|
||||
declarator: (field_identifier) @declaration.name)) @declaration.method
|
||||
|
||||
;; Inline operator method in class body: Point operator+(Point) const { ... }
|
||||
(function_definition
|
||||
declarator: (function_declarator
|
||||
declarator: (operator_name) @declaration.name)) @declaration.method
|
||||
|
||||
;; ─── Declarations — inline method with pointer return (field_identifier) ──
|
||||
;; Covers: User* lookup(int id) { ... } inside a class body
|
||||
;; AST: function_definition > pointer_declarator > function_declarator > field_identifier
|
||||
|
|
@ -145,17 +156,49 @@ const CPP_SCOPE_QUERY = `
|
|||
(function_declarator
|
||||
declarator: (field_identifier) @declaration.name))) @declaration.method
|
||||
|
||||
;; Inline operator method with reference return: Point& operator+=(Point) { ... }
|
||||
(field_declaration_list
|
||||
(function_definition
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: (operator_name) @declaration.name))) @declaration.method)
|
||||
|
||||
;; Free operator definition with reference return: std::ostream& operator<<(...) { ... }
|
||||
(translation_unit
|
||||
(function_definition
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: (operator_name) @declaration.name))) @declaration.function)
|
||||
|
||||
(namespace_definition
|
||||
body: (declaration_list
|
||||
(function_definition
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: (operator_name) @declaration.name))) @declaration.function))
|
||||
|
||||
;; ─── Declarations — function prototype (forward declaration) ────────
|
||||
(declaration
|
||||
declarator: (function_declarator
|
||||
declarator: (identifier) @declaration.name)) @declaration.function
|
||||
|
||||
;; Free operator prototype: std::ostream& operator<<(std::ostream&, T)
|
||||
(declaration
|
||||
declarator: (function_declarator
|
||||
declarator: (operator_name) @declaration.name)) @declaration.function
|
||||
|
||||
;; ─── Declarations — function prototype with pointer return ──────────
|
||||
(declaration
|
||||
declarator: (pointer_declarator
|
||||
declarator: (function_declarator
|
||||
declarator: (identifier) @declaration.name))) @declaration.function
|
||||
|
||||
;; Free operator prototype with reference return.
|
||||
(declaration
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: (operator_name) @declaration.name))) @declaration.function
|
||||
|
||||
;; ─── Declarations — typedef ─────────────────────────────────────────
|
||||
(type_definition
|
||||
declarator: (type_identifier) @declaration.name) @declaration.typedef
|
||||
|
|
@ -171,6 +214,11 @@ const CPP_SCOPE_QUERY = `
|
|||
declarator: (function_declarator
|
||||
declarator: (field_identifier) @declaration.name)) @declaration.method
|
||||
|
||||
;; Operator method prototype in class body: Point operator+(Point) const;
|
||||
(field_declaration
|
||||
declarator: (function_declarator
|
||||
declarator: (operator_name) @declaration.name)) @declaration.method
|
||||
|
||||
;; Method prototype with pointer return: User* lookup(int id);
|
||||
(field_declaration
|
||||
declarator: (pointer_declarator
|
||||
|
|
@ -183,6 +231,11 @@ const CPP_SCOPE_QUERY = `
|
|||
(function_declarator
|
||||
declarator: (field_identifier) @declaration.name))) @declaration.method
|
||||
|
||||
(field_declaration
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: (operator_name) @declaration.name))) @declaration.method
|
||||
|
||||
;; ─── Declarations — fields ──────────────────────────────────────────
|
||||
(field_declaration
|
||||
declarator: (field_identifier) @declaration.name) @declaration.field
|
||||
|
|
@ -473,6 +526,22 @@ const CPP_SCOPE_QUERY = `
|
|||
argument: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.call.member
|
||||
|
||||
;; Conservative operator-call support (#1636): model a + b as a
|
||||
;; member-style operator+ lookup, and lhs << rhs as a free
|
||||
;; operator<< lookup. Free operator+(T,T), member operator<<, and
|
||||
;; complex operand expressions remain false negatives for now.
|
||||
;; Built-in operators remain unresolved because no user-defined
|
||||
;; operator target exists.
|
||||
(binary_expression
|
||||
left: (_) @reference.receiver
|
||||
operator: "+" @reference.operator
|
||||
right: (_)) @reference.call.member
|
||||
|
||||
(binary_expression
|
||||
left: (_)
|
||||
operator: "<<" @reference.operator
|
||||
right: (_)) @reference.call.free
|
||||
|
||||
;; ─── References — template calls (func<T>()) ────────────────────────
|
||||
(call_expression
|
||||
function: (template_function
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import type { Capture, CaptureMatch, Range } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
|
|
@ -35,6 +35,7 @@ export function emitKotlinScopeCaptures(
|
|||
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
|
||||
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
|
||||
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
|
||||
out.push(...synthesizeKotlinSmartCastBindings(tree.rootNode));
|
||||
|
||||
for (const match of getKotlinScopeQuery().matches(tree.rootNode)) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
|
|
@ -72,6 +73,30 @@ export function emitKotlinScopeCaptures(
|
|||
if (navNode === null || !shouldEmitReadMember(navNode)) continue;
|
||||
}
|
||||
|
||||
// Virtual dispatch via constructor type (#1762). When a property
|
||||
// declaration carries BOTH an explicit type annotation AND a
|
||||
// constructor-style call value (e.g. `val animal: Animal = Dog()`),
|
||||
// suppress the annotation capture so the constructor-inferred
|
||||
// binding wins. This matches Kotlin's virtual dispatch semantics:
|
||||
// `animal.speak()` should resolve to the overriding `Dog.speak`
|
||||
// (the dynamic type), not `Animal.speak` (the static annotation).
|
||||
//
|
||||
// The annotation source has higher precedence than constructor-
|
||||
// inferred in the generic scope-extractor (see
|
||||
// `typeBindingStrength` in scope-extractor.ts), so the only way to
|
||||
// make the constructor type prevail is to drop the annotation at
|
||||
// emission time.
|
||||
if (
|
||||
grouped['@type-binding.annotation'] !== undefined &&
|
||||
grouped['@type-binding.name'] !== undefined &&
|
||||
grouped['@type-binding.type'] !== undefined
|
||||
) {
|
||||
const annotation = grouped['@type-binding.annotation']!;
|
||||
if (propertyDeclHasConstructorValue(tree.rootNode, annotation.range)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
out.push(grouped);
|
||||
const fnNode = findNodeAtRange(
|
||||
|
|
@ -186,15 +211,114 @@ function synthesizeKotlinLoopBindings(
|
|||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize narrowed type-bindings for Kotlin smart-cast forms — issue #1758.
|
||||
*
|
||||
* For each `when (x) { is T -> body }` and `if (x is T) body`, emits a
|
||||
* `@type-binding.annotation` capture binding `x → T` anchored on the body
|
||||
* node. The capture lands in the matching `@scope.block` scope (see query.ts
|
||||
* smart-cast scopes), shadowing the outer parameter binding for calls inside
|
||||
* the body without leaking across sibling arms or to `else`.
|
||||
*
|
||||
* Only narrows when:
|
||||
* - the `when` subject is a `simple_identifier` (not a call or field chain);
|
||||
* - the `when_entry` condition is exactly one `type_test` (skips `!is`,
|
||||
* compound conditions, range/`in`/value patterns);
|
||||
* - the `if_expression` condition is a `check_expression` of the form
|
||||
* `<simple_identifier> is <user_type>` and the then-branch is a
|
||||
* `control_structure_body`.
|
||||
*
|
||||
* `else` arms and non-narrowing conditions emit nothing — the fall-through to
|
||||
* the outer scope's declared type is the correct semantic.
|
||||
*/
|
||||
function synthesizeKotlinSmartCastBindings(rootNode: SyntaxNode): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const whenNode of descendantsOfType(rootNode, 'when_expression')) {
|
||||
const subjectName = extractWhenSubjectIdentifier(whenNode);
|
||||
if (subjectName === null) continue;
|
||||
|
||||
for (const entry of whenNode.namedChildren) {
|
||||
if (entry.type !== 'when_entry') continue;
|
||||
const narrowedType = extractIsTestTargetType(entry);
|
||||
if (narrowedType === null) continue;
|
||||
const body = entry.namedChildren.find((child) => child.type === 'control_structure_body');
|
||||
if (body === undefined) continue;
|
||||
out.push(buildNarrowedTypeBindingCapture(subjectName.node, body, narrowedType));
|
||||
}
|
||||
}
|
||||
|
||||
for (const ifNode of descendantsOfType(rootNode, 'if_expression')) {
|
||||
const check = ifNode.namedChildren.find((child) => child.type === 'check_expression');
|
||||
if (check === undefined) continue;
|
||||
const subject = check.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const typeNode = check.namedChildren.find((child) => isKotlinTypeNode(child));
|
||||
if (subject === undefined || typeNode === undefined) continue;
|
||||
// The first control_structure_body sibling is the then-branch; else
|
||||
// branches (when present) appear as the second control_structure_body
|
||||
// and are intentionally not narrowed.
|
||||
const body = ifNode.namedChildren.find((child) => child.type === 'control_structure_body');
|
||||
if (body === undefined) continue;
|
||||
out.push(buildNarrowedTypeBindingCapture(subject, body, typeNode));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractWhenSubjectIdentifier(whenNode: SyntaxNode): { node: SyntaxNode } | null {
|
||||
const subject = whenNode.namedChildren.find((child) => child.type === 'when_subject');
|
||||
if (subject === undefined) return null;
|
||||
const ident = subject.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
return ident === undefined ? null : { node: ident };
|
||||
}
|
||||
|
||||
function extractIsTestTargetType(whenEntry: SyntaxNode): SyntaxNode | null {
|
||||
const condition = whenEntry.namedChildren.find((child) => child.type === 'when_condition');
|
||||
if (condition === undefined) return null;
|
||||
// Exactly one when_condition child must be a positive type_test.
|
||||
// Compound conditions (multiple `when_condition` siblings joined with
|
||||
// commas in some grammars) or negated `!is` are not safe to narrow.
|
||||
if (condition.namedChildCount !== 1) return null;
|
||||
const test = condition.namedChild(0);
|
||||
if (test === null || test.type !== 'type_test') return null;
|
||||
// `!is` produces a different node (`negated_type_test` in some grammars,
|
||||
// or an extra `!` child in others) — defend by checking text prefix.
|
||||
if (test.text.trim().startsWith('!')) return null;
|
||||
return test.namedChildren.find((child) => isKotlinTypeNode(child)) ?? null;
|
||||
}
|
||||
|
||||
function buildNarrowedTypeBindingCapture(
|
||||
subject: SyntaxNode,
|
||||
bodyAnchor: SyntaxNode,
|
||||
typeNode: SyntaxNode,
|
||||
): CaptureMatch {
|
||||
return {
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', bodyAnchor),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', subject, subject.text),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
typeNode,
|
||||
normalizeKotlinType(typeNode.text),
|
||||
),
|
||||
// Marker consumed by `kotlinBindingScopeFor` in simple-hooks.ts to
|
||||
// override the scope-extractor's auto-hoist. Unbraced arm bodies
|
||||
// (`is User -> obj.save()`) make the body anchor coincide with the
|
||||
// Block scope's range; without this marker the binding would hoist
|
||||
// to the enclosing function scope and lose its arm-local narrowing.
|
||||
'@type-binding.narrowed': syntheticCapture('@type-binding.narrowed', bodyAnchor, '1'),
|
||||
};
|
||||
}
|
||||
|
||||
function synthesizeKotlinLocalAssignmentBindings(
|
||||
rootNode: SyntaxNode,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
const classMembers = collectKotlinClassMembers(rootNode);
|
||||
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
|
||||
const localTypes = new Map<string, string>();
|
||||
for (const prop of descendantsOfType(fnNode, 'property_declaration')) {
|
||||
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes);
|
||||
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes, classMembers);
|
||||
if (inferred === null) continue;
|
||||
localTypes.set(inferred.name.text, inferred.rawType);
|
||||
if (inferred.synthetic) {
|
||||
|
|
@ -217,6 +341,77 @@ function synthesizeKotlinLocalAssignmentBindings(
|
|||
return out;
|
||||
}
|
||||
|
||||
interface KotlinClassMembers {
|
||||
/** className → fieldName → raw type text */
|
||||
readonly fields: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
||||
/** className → methodName → raw return type text */
|
||||
readonly methods: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file class-member index — primary-constructor `val`/`var` params,
|
||||
* body property declarations, and method return types. Used by
|
||||
* `inferKotlinPropertyType` to walk single-level field and method chains
|
||||
* like `val addr = user.address` and `val city = addr.getCity()` (#1760).
|
||||
*
|
||||
* Indexes by simple class name only. Multi-class collisions inside a
|
||||
* single file will pick whichever class was visited last for that name
|
||||
* — acceptable because Kotlin forbids same-name top-level classes in
|
||||
* one file and per-file resolution is the design boundary here.
|
||||
*/
|
||||
function collectKotlinClassMembers(rootNode: SyntaxNode): KotlinClassMembers {
|
||||
const fields = new Map<string, Map<string, string>>();
|
||||
const methods = new Map<string, Map<string, string>>();
|
||||
for (const cls of descendantsOfType(rootNode, 'class_declaration')) {
|
||||
const className = cls.namedChildren.find((child) => child.type === 'type_identifier')?.text;
|
||||
if (className === undefined) continue;
|
||||
const fmap = fields.get(className) ?? new Map<string, string>();
|
||||
const mmap = methods.get(className) ?? new Map<string, string>();
|
||||
|
||||
const primary = cls.namedChildren.find((child) => child.type === 'primary_constructor');
|
||||
if (primary !== undefined) {
|
||||
for (const param of primary.namedChildren) {
|
||||
if (param.type !== 'class_parameter') continue;
|
||||
// Constructor params are class fields ONLY when prefixed with
|
||||
// `val`/`var` (binding_pattern_kind). Plain `fn(x: Int)`-style
|
||||
// params remain locals to the constructor.
|
||||
if (param.namedChildren.find((c) => c.type === 'binding_pattern_kind') === undefined) {
|
||||
continue;
|
||||
}
|
||||
const fname = param.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
const ftype = param.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
|
||||
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
|
||||
}
|
||||
}
|
||||
|
||||
const body = cls.namedChildren.find((child) => child.type === 'class_body');
|
||||
if (body !== undefined) {
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type === 'property_declaration') {
|
||||
const v = member.namedChildren.find((c) => c.type === 'variable_declaration');
|
||||
const fname = v?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
const ftype = v?.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
|
||||
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
|
||||
} else if (member.type === 'function_declaration') {
|
||||
const mname = member.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
const paramsIdx = member.namedChildren.findIndex(
|
||||
(c) => c.type === 'function_value_parameters',
|
||||
);
|
||||
const rtype =
|
||||
paramsIdx < 0
|
||||
? undefined
|
||||
: member.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
|
||||
if (mname !== undefined && rtype !== undefined) mmap.set(mname, rtype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields.set(className, fmap);
|
||||
methods.set(className, mmap);
|
||||
}
|
||||
return { fields, methods };
|
||||
}
|
||||
|
||||
function collectKotlinLocalTypeTexts(
|
||||
fnNode: SyntaxNode,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
|
|
@ -258,6 +453,7 @@ function inferKotlinPropertyType(
|
|||
prop: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
classMembers?: KotlinClassMembers,
|
||||
): { name: SyntaxNode; rawType: string; source: SyntaxNode; synthetic: boolean } | null {
|
||||
const variable = prop.namedChildren.find((child) => child.type === 'variable_declaration');
|
||||
const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
|
|
@ -276,18 +472,74 @@ function inferKotlinPropertyType(
|
|||
return rawType === undefined ? null : { name, rawType, source: value, synthetic: true };
|
||||
}
|
||||
|
||||
if (value?.type === 'navigation_expression') {
|
||||
// `val addr = user.address` — receiver type → field on that class (#1760).
|
||||
const chained = inferKotlinNavigationFieldType(value, localTypes, classMembers);
|
||||
if (chained === null) return null;
|
||||
return { name, rawType: chained, source: value, synthetic: true };
|
||||
}
|
||||
|
||||
if (value?.type === 'call_expression') {
|
||||
const callee = value.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const callee = value.namedChildren.find(
|
||||
(child) => child.type === 'simple_identifier' || child.type === 'navigation_expression',
|
||||
);
|
||||
if (callee === undefined) return null;
|
||||
const rawType =
|
||||
returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
|
||||
if (rawType === null) return null;
|
||||
return { name, rawType, source: callee, synthetic: true };
|
||||
if (callee.type === 'simple_identifier') {
|
||||
const rawType =
|
||||
returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
|
||||
if (rawType === null) return null;
|
||||
return { name, rawType, source: callee, synthetic: true };
|
||||
}
|
||||
// `val city = addr.getCity()` — receiver type → method return on that class (#1760).
|
||||
const chained = inferKotlinNavigationCallReturnType(callee, localTypes, classMembers);
|
||||
if (chained === null) return null;
|
||||
return { name, rawType: chained, source: callee, synthetic: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve `receiver.field` → field's declared type, where `receiver`
|
||||
* is a simple identifier whose type is in `localTypes` and `field`
|
||||
* is declared on that type in `classMembers.fields`. Returns null
|
||||
* when any link in the chain is unknown — safe over-conservative. */
|
||||
function inferKotlinNavigationFieldType(
|
||||
nav: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
classMembers: KotlinClassMembers | undefined,
|
||||
): string | null {
|
||||
if (classMembers === undefined) return null;
|
||||
const receiver = nav.namedChild(0);
|
||||
if (receiver === null || receiver.type !== 'simple_identifier') return null;
|
||||
const member = nav.namedChildren
|
||||
.find((c) => c.type === 'navigation_suffix')
|
||||
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
if (member === undefined) return null;
|
||||
const recvType = localTypes.get(receiver.text);
|
||||
if (recvType === undefined) return null;
|
||||
return classMembers.fields.get(normalizeKotlinType(recvType))?.get(member) ?? null;
|
||||
}
|
||||
|
||||
/** Resolve `receiver.method()` → method's declared return type, where
|
||||
* `receiver` is a simple identifier whose type is in `localTypes` and
|
||||
* `method` is declared on that type in `classMembers.methods`. */
|
||||
function inferKotlinNavigationCallReturnType(
|
||||
navCallee: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
classMembers: KotlinClassMembers | undefined,
|
||||
): string | null {
|
||||
if (classMembers === undefined) return null;
|
||||
const receiver = navCallee.namedChild(0);
|
||||
if (receiver === null || receiver.type !== 'simple_identifier') return null;
|
||||
const methodName = navCallee.namedChildren
|
||||
.find((c) => c.type === 'navigation_suffix')
|
||||
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
if (methodName === undefined) return null;
|
||||
const recvType = localTypes.get(receiver.text);
|
||||
if (recvType === undefined) return null;
|
||||
return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
|
||||
}
|
||||
|
||||
function inferKotlinIterableElementType(
|
||||
iterable: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
|
|
@ -312,7 +564,16 @@ function inferKotlinIterableElementType(
|
|||
const callee = iterable.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
if (callee === undefined) return null;
|
||||
const raw = returnTypes.get(callee.text);
|
||||
return raw === undefined ? null : kotlinContainerElementType(raw, 'values');
|
||||
if (raw !== undefined) return kotlinContainerElementType(raw, 'values');
|
||||
// Cross-file fallback (#1759): the callee's return type is unknown
|
||||
// locally because the function lives in another file. Emit the
|
||||
// callee name itself as the binding's rawName; `propagateImported
|
||||
// ReturnTypes` will chain-follow `loopvar → callee → <ElementType>`
|
||||
// once the imported module's `callee → ElementType` mirror lands at
|
||||
// module scope. If `callee` isn't actually an imported callable
|
||||
// (e.g. a local lambda or unrelated symbol), chain-follow fails
|
||||
// safely and no edge is emitted.
|
||||
return callee.text;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -409,6 +670,21 @@ function shouldEmitReadMember(navNode: SyntaxNode): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
/** True when the property_declaration anchored at `range` has a
|
||||
* `call_expression` value sibling (i.e. `val x: T = Foo()`). Used to
|
||||
* suppress the explicit-annotation type-binding capture so the
|
||||
* constructor-inferred binding wins (#1762). */
|
||||
function propertyDeclHasConstructorValue(rootNode: SyntaxNode, range: Range): boolean {
|
||||
const propNode = findNodeAtRange(rootNode, range, 'property_declaration');
|
||||
if (propNode === null) return false;
|
||||
const variable = propNode.namedChildren.find((c) => c.type === 'variable_declaration');
|
||||
if (variable === undefined) return false;
|
||||
const value = propNode.namedChildren.find(
|
||||
(c) => c.id !== variable.id && c.type !== 'binding_pattern_kind',
|
||||
);
|
||||
return value?.type === 'call_expression';
|
||||
}
|
||||
|
||||
function callArguments(callNode: SyntaxNode): SyntaxNode[] {
|
||||
const suffix = callNode.namedChildren.find((child) => child.type === 'call_suffix');
|
||||
if (suffix === undefined) return [];
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface KotlinResolveContext {
|
|||
export function resolveKotlinImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
): string | readonly string[] | null {
|
||||
const ctx = workspaceIndex as KotlinResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
|
|
@ -25,22 +25,49 @@ export function resolveKotlinImportTarget(
|
|||
: parsedImport.targetRaw;
|
||||
const pathLike = target.replace(/\./g, '/');
|
||||
|
||||
// Resolution tiers, most-specific first:
|
||||
// 1. The full `pathLike` matches a `.kt`/`.kts` file directly
|
||||
// (`import util.User` → `util/User.kt`).
|
||||
// 2. Stripped (last-segment removed) `pathLike` matches a file
|
||||
// directly (`import util.OneArg.writeAudit` → `util/OneArg.kt`,
|
||||
// a class-or-object holding `writeAudit`).
|
||||
// 3. Stripped `pathLike` matches a *package directory* — fan out to
|
||||
// every `.kt`/`.kts` file inside it (`import models.getRepo` →
|
||||
// `[models/User.kt, models/Repo.kt]`). The finalize pass walks
|
||||
// each candidate and picks the one whose `localDefs` actually
|
||||
// export the imported name (#1759).
|
||||
// 4. Progressive prefix strip for deeper namespace aliases that
|
||||
// don't map 1:1 to directories.
|
||||
const stripped = pathLike.split('/').slice(0, -1).join('/');
|
||||
return (
|
||||
findKotlinFile(ctx.allFilePaths, pathLike) ??
|
||||
findKotlinFile(ctx.allFilePaths, pathLike.split('/').slice(0, -1).join('/')) ??
|
||||
findKotlinExactOrSuffix(ctx.allFilePaths, stripped) ??
|
||||
findKotlinPackageFiles(ctx.allFilePaths, stripped) ??
|
||||
findByProgressivePrefixStrip(ctx.allFilePaths, pathLike)
|
||||
);
|
||||
}
|
||||
|
||||
function findKotlinFile(allFilePaths: ReadonlySet<string>, pathLike: string): string | null {
|
||||
return (
|
||||
findKotlinExactOrSuffix(allFilePaths, pathLike) ??
|
||||
findKotlinDirectoryChild(allFilePaths, pathLike)
|
||||
);
|
||||
}
|
||||
|
||||
/** Exact (`file === pathLike+ext`) or suffix (`file ends with /pathLike+ext`)
|
||||
* match — does NOT fall back to picking an arbitrary file inside a
|
||||
* `pathLike/` directory. Used by the stripped-path tier in
|
||||
* `resolveKotlinImportTarget` so a package import like `models.getRepo`
|
||||
* delegates to `findKotlinPackageFiles` (multi-file fan-out) instead of
|
||||
* silently committing to the first directory child. */
|
||||
function findKotlinExactOrSuffix(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
if (pathLike === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const suffix = `/${pathLike}`;
|
||||
const dirPrefix = `${pathLike}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
|
||||
let suffixFile: string | null = null;
|
||||
let directoryChild: string | null = null;
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
|
|
@ -49,18 +76,73 @@ function findKotlinFile(allFilePaths: ReadonlySet<string>, pathLike: string): st
|
|||
if (file === `${pathLike}${ext}`) return raw;
|
||||
if (suffixFile === null && file.endsWith(`${suffix}${ext}`)) suffixFile = raw;
|
||||
}
|
||||
if (directoryChild === null) {
|
||||
const atRoot = file.startsWith(dirPrefix);
|
||||
const atNested = file.includes(suffixDirPrefix);
|
||||
if (atRoot || atNested) {
|
||||
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
||||
const after = file.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) directoryChild = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suffixFile ?? directoryChild;
|
||||
return suffixFile;
|
||||
}
|
||||
|
||||
/** First directory child of `pathLike/` — preserves the legacy single-
|
||||
* file fallback for cases where `pathLike` itself is an unqualified
|
||||
* package reference (rare in real Kotlin code; some fixtures rely on
|
||||
* it). Multi-file package fan-out goes through
|
||||
* `findKotlinPackageFiles` instead. */
|
||||
function findKotlinDirectoryChild(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
if (pathLike === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const dirPrefix = `${pathLike}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
const atRoot = file.startsWith(dirPrefix);
|
||||
const atNested = file.includes(suffixDirPrefix);
|
||||
if (!atRoot && !atNested) continue;
|
||||
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
||||
const after = file.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) return raw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return every `.kt`/`.kts` file inside the package directory `dirPath`
|
||||
* (e.g. `models` → `['models/User.kt', 'models/Repo.kt']`). Used as a
|
||||
* fallback when an import like `models.getRepo` does not resolve to a
|
||||
* file named after the symbol — in Kotlin the symbol can live in any
|
||||
* file inside the package directory. The finalize pass walks each
|
||||
* candidate and picks the one whose `localDefs` actually export the
|
||||
* imported name (#1759).
|
||||
*/
|
||||
function findKotlinPackageFiles(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
dirPath: string,
|
||||
): readonly string[] | null {
|
||||
if (dirPath === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const dirPrefix = `${dirPath}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
const out: string[] = [];
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
const atRoot = file.startsWith(dirPrefix);
|
||||
const atNested = file.includes(suffixDirPrefix);
|
||||
if (!atRoot && !atNested) continue;
|
||||
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
||||
const after = file.slice(idx + dirPrefix.length);
|
||||
// Direct children only — `models/sub/Util.kt` is a different package
|
||||
// (`models.sub`) and must not be merged with `models`.
|
||||
if (after.length === 0 || after.includes('/')) continue;
|
||||
out.push(raw);
|
||||
}
|
||||
|
||||
return out.length === 0 ? null : out;
|
||||
}
|
||||
|
||||
function findByProgressivePrefixStrip(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,29 @@ import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/s
|
|||
export function populateKotlinOwners(parsed: ParsedFile): void {
|
||||
populateClassOwnedMembers(parsed);
|
||||
populateCompanionMembersOnEnclosingClass(parsed);
|
||||
upgradeClassOwnedFunctionsToMethods(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Align scope-resolution `def.type` with the graph's node-label
|
||||
* conventions: a `Function` def that lives inside a class body becomes
|
||||
* a `Method`. The Kotlin extractor labels every `function_declaration`
|
||||
* as `Function`, but the graph parsing-processor emits a `Method`
|
||||
* graph-node label for class members. Without this realignment,
|
||||
* `resolveDefGraphId`'s parameter-typed key lookup (gated on
|
||||
* `def.type === 'Method'`) falls through to the simple-name fallback
|
||||
* for class methods, collapsing same-name same-arity overloads onto
|
||||
* the first-registered node (#1761).
|
||||
*
|
||||
* Only Method-bearing types are touched. Methods have a class owner
|
||||
* (set by `populateClassOwnedMembers`) and a class-qualified name.
|
||||
*/
|
||||
function upgradeClassOwnedFunctionsToMethods(parsed: ParsedFile): void {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (def.type !== 'Function') continue;
|
||||
if (def.ownerId === undefined) continue;
|
||||
(def as { type: SymbolDefinition['type'] }).type = 'Method';
|
||||
}
|
||||
}
|
||||
|
||||
function populateCompanionMembersOnEnclosingClass(parsed: ParsedFile): void {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,19 @@ const KOTLIN_SCOPE_QUERY = `
|
|||
(companion_object) @scope.class
|
||||
(function_declaration) @scope.function
|
||||
|
||||
;; Smart-cast narrowing scopes (RFC #909 Ring 3, issue #1758).
|
||||
;; Each is-test arm body and each if-then body becomes its own Block
|
||||
;; scope so synthesized narrowed type-bindings (see captures.ts
|
||||
;; synthesizeKotlinSmartCastBindings) shadow the outer parameter
|
||||
;; binding for calls inside the body — without leaking across arms.
|
||||
(when_entry
|
||||
(when_condition (type_test))
|
||||
(control_structure_body) @scope.block)
|
||||
|
||||
(if_expression
|
||||
(check_expression)
|
||||
(control_structure_body) @scope.block)
|
||||
|
||||
;; Declarations — types
|
||||
(class_declaration
|
||||
"interface"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { SupportedLanguages, type ParsedFile } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
|
||||
import { kotlinProvider } from '../kotlin.js';
|
||||
import {
|
||||
kotlinArityCompatibility,
|
||||
|
|
@ -16,14 +20,26 @@ import {
|
|||
* Kotlin is intentionally registered but not yet listed in
|
||||
* `MIGRATED_LANGUAGES`, matching the Java migration pattern from #1482:
|
||||
* the resolver can run in shadow/forced mode, while production default
|
||||
* stays on the legacy DAG until registry-primary parity reaches the
|
||||
* RFC threshold. Forced mode currently passes 154/175 fixtures (88%),
|
||||
* including core import, receiver, companion, default-param, vararg,
|
||||
* constructor, local assignment-chain, and collection-iteration fixtures.
|
||||
* Remaining gaps are advanced TypeEnv behaviors such as smart casts,
|
||||
* cross-file iterable return propagation, method-chain fixpoint cases,
|
||||
* overload target-id selection, virtual dispatch, and interface default
|
||||
* method dispatch.
|
||||
* stays on the legacy DAG until the RFC flip criteria in #1746 are met.
|
||||
*
|
||||
* **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 175/175 fixtures
|
||||
* after the migration sub-issues #1758–#1763 closed. Covers core
|
||||
* import, receiver, companion, default-param, vararg, constructor,
|
||||
* local assignment-chain, collection-iteration, smart casts
|
||||
* (`when (x) { is T -> … }` and `if (x is T)` — #1758), cross-file
|
||||
* iterable return propagation (#1759), single-level method-chain
|
||||
* fixpoint receiver types (#1760), parameter-type-narrowed overload
|
||||
* target-id selection (#1761), virtual dispatch via constructor RHS
|
||||
* (`val x: Animal = Dog()` — #1762), and interface default-method
|
||||
* dispatch via implements-split MRO (#1763).
|
||||
*
|
||||
* **Remaining pre-flip blockers (#1746):** #1755 (forced-mode preview
|
||||
* CI workflow — obviated once Kotlin lands in `MIGRATED_LANGUAGES`
|
||||
* because the existing scope-parity matrix auto-discovers it), #1756
|
||||
* (companion vs instance member dispatch), and #1757 (lambda scopes
|
||||
* and lambda-parameter bindings). The flip PR adds
|
||||
* `SupportedLanguages.Kotlin` to `MIGRATED_LANGUAGES` after the named
|
||||
* blockers close.
|
||||
*/
|
||||
export const kotlinScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Kotlin,
|
||||
|
|
@ -42,8 +58,7 @@ export const kotlinScopeResolver: ScopeResolver = {
|
|||
|
||||
arityCompatibility: (callsite, def) => kotlinArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
|
||||
buildMro: (graph, parsedFiles, nodeLookup) => buildKotlinMro(graph, parsedFiles, nodeLookup),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateKotlinOwners(parsed),
|
||||
|
||||
|
|
@ -54,3 +69,94 @@ export const kotlinScopeResolver: ScopeResolver = {
|
|||
collapseMemberCallsByCallerTarget: false,
|
||||
hoistTypeBindingsToModule: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Kotlin MRO builder — extends `defaultLinearize` (EXTENDS-only) with
|
||||
* interface ancestors discovered via `IMPLEMENTS` edges. Interface
|
||||
* default methods (`interface Validator { fun validate(): Boolean = true }`)
|
||||
* are inherited by implementing classes without an explicit override;
|
||||
* the generic MRO would not surface them because the implementor has
|
||||
* no `EXTENDS` link to the interface (#1763).
|
||||
*
|
||||
* Interfaces are appended after the EXTENDS chain (Kotlin resolves
|
||||
* conflicts by requiring an explicit override, so first-seen-in-MRO
|
||||
* ordering is a reasonable approximation for method lookup). Transitive
|
||||
* interface inheritance (`interface A : B`) is closed via BFS.
|
||||
*/
|
||||
function buildKotlinMro(
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): Map<string, string[]> {
|
||||
const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
|
||||
|
||||
const defIdByGraphId = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (!isClassLike(def.type)) continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct IMPLEMENTS targets per class-like def.
|
||||
const directImpls = new Map<string, string[]>();
|
||||
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
const source = defIdByGraphId.get(rel.sourceId);
|
||||
const target = defIdByGraphId.get(rel.targetId);
|
||||
if (source === undefined || target === undefined) continue;
|
||||
let list = directImpls.get(source);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
directImpls.set(source, list);
|
||||
}
|
||||
if (!list.includes(target)) list.push(target);
|
||||
}
|
||||
|
||||
// For each class, append the transitive closure of interfaces reachable
|
||||
// through its own + ancestor classes' IMPLEMENTS edges. Walking
|
||||
// ancestors picks up interfaces inherited via the EXTENDS chain
|
||||
// (e.g. `class C : B; class B : A; interface A` — C inherits A's
|
||||
// interface methods through B).
|
||||
for (const [classDefId, extendsMro] of mro) {
|
||||
const ancestorChain = [classDefId, ...extendsMro];
|
||||
const seeds: string[] = [];
|
||||
for (const ancestorId of ancestorChain) {
|
||||
for (const ifaceId of directImpls.get(ancestorId) ?? []) {
|
||||
seeds.push(ifaceId);
|
||||
}
|
||||
}
|
||||
if (seeds.length === 0) continue;
|
||||
const interfaces = closeInterfaces(seeds, directImpls);
|
||||
mro.set(classDefId, [...extendsMro, ...interfaces.filter((i) => !extendsMro.includes(i))]);
|
||||
}
|
||||
|
||||
// Classes with no EXTENDS still need an MRO entry when they implement
|
||||
// interfaces (e.g. `class User : Validator` — no `mro` entry from the
|
||||
// EXTENDS-only pass because no EXTENDS edges exist).
|
||||
for (const [classDefId, ifaces] of directImpls) {
|
||||
if (mro.has(classDefId)) continue;
|
||||
mro.set(classDefId, closeInterfaces([...ifaces], directImpls));
|
||||
}
|
||||
|
||||
return mro;
|
||||
}
|
||||
|
||||
function closeInterfaces(
|
||||
seeds: readonly string[],
|
||||
directImpls: ReadonlyMap<string, readonly string[]>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const queue: string[] = [...seeds];
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
if (seen.has(cur)) continue;
|
||||
seen.add(cur);
|
||||
out.push(cur);
|
||||
for (const next of directImpls.get(cur) ?? []) {
|
||||
if (!seen.has(next)) queue.push(next);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ export function kotlinBindingScopeFor(
|
|||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
// Smart-cast narrowed bindings (issue #1758) must stay at the innermost
|
||||
// (Block) scope. Their anchor coincides with the Block's range for
|
||||
// unbraced arm bodies (`is User -> obj.save()`), which would otherwise
|
||||
// trigger scope-extractor auto-hoist into the enclosing function scope
|
||||
// and erase the arm-local narrowing.
|
||||
if (decl['@type-binding.narrowed'] !== undefined) return innermost.id;
|
||||
|
||||
if (decl['@type-binding.return'] === undefined) return null;
|
||||
|
||||
let current: Scope | undefined = innermost;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
|
||||
import type { ResolutionContext } from './resolution-context.js';
|
||||
import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared';
|
||||
import {
|
||||
isDeferredResolutionProfileEnabled,
|
||||
logDeferredProfile,
|
||||
} from '../utils/deferred-resolution-profile.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExtractedHeritage — the shape produced by the parse worker / heritage
|
||||
|
|
@ -176,11 +180,35 @@ export const buildHeritageMap = (
|
|||
// interfaceName → Set<filePath> (implementor lookup for interface dispatch)
|
||||
const implementorFiles = new Map<string, Set<string>>();
|
||||
|
||||
const profileHeritage = isDeferredResolutionProfileEnabled();
|
||||
let maxNameCartesian = 0;
|
||||
let ambiguousHeritageRecords = 0;
|
||||
let unresolvedChildLookups = 0;
|
||||
let unresolvedParentLookups = 0;
|
||||
|
||||
for (const h of heritage) {
|
||||
// ── Parent lookup (nodeId-based) ────────────────────────────────
|
||||
const childDefs = ctx.model.types.lookupClassByName(h.className);
|
||||
const parentDefs = ctx.model.types.lookupClassByName(h.parentName);
|
||||
|
||||
// Unresolved-side counters live in a separate guard so they observe
|
||||
// records the ambiguity block below skips. On JVM monorepos the
|
||||
// pathological fan-out case is precisely "many same-named children
|
||||
// with an unresolved external supertype" (or the inverse) — both
|
||||
// sides non-empty is the case `ambiguousHeritageRecords` already
|
||||
// covers; the unresolved cases were silently dropped from the
|
||||
// metric before this counter.
|
||||
if (profileHeritage) {
|
||||
if (childDefs.length === 0) unresolvedChildLookups++;
|
||||
if (parentDefs.length === 0) unresolvedParentLookups++;
|
||||
}
|
||||
|
||||
if (profileHeritage && childDefs.length > 0 && parentDefs.length > 0) {
|
||||
const product = childDefs.length * parentDefs.length;
|
||||
if (product > 1) ambiguousHeritageRecords++;
|
||||
if (product > maxNameCartesian) maxNameCartesian = product;
|
||||
}
|
||||
|
||||
if (childDefs.length > 0 && parentDefs.length > 0) {
|
||||
for (const child of childDefs) {
|
||||
for (const parent of parentDefs) {
|
||||
|
|
@ -368,6 +396,17 @@ export const buildHeritageMap = (
|
|||
return implementorFiles.get(interfaceName) ?? EMPTY_SET;
|
||||
};
|
||||
|
||||
if (profileHeritage) {
|
||||
logDeferredProfile(
|
||||
`buildHeritageMap: ${heritage.length} heritage records, ` +
|
||||
`${ambiguousHeritageRecords} with child×parent lookup product >1, ` +
|
||||
`max product ${maxNameCartesian}, ` +
|
||||
`${unresolvedChildLookups} unresolved child lookups, ` +
|
||||
`${unresolvedParentLookups} unresolved parent lookups, ` +
|
||||
`${implementorFiles.size} interface implementor keys`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getParents,
|
||||
getAncestors,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|||
|
||||
import { isDev } from '../utils/env.js';
|
||||
import { isVerboseIngestionEnabled } from '../utils/verbose.js';
|
||||
import {
|
||||
endTimer,
|
||||
isDeferredResolutionProfileEnabled,
|
||||
logDeferredProfile,
|
||||
startTimer,
|
||||
} from '../utils/deferred-resolution-profile.js';
|
||||
import { synthesizeWildcardImportBindings, needsSynthesis } from './wildcard-synthesis.js';
|
||||
import { extractORMQueriesInline } from './orm-extraction.js';
|
||||
|
||||
|
|
@ -698,7 +704,15 @@ export async function runChunkedParseAndResolve(
|
|||
// heritage: 75 -> 80 (5)
|
||||
// routes: 80 -> 85 (5)
|
||||
// calls: 85 -> 95 (10)
|
||||
const deferredProfile = isDeferredResolutionProfileEnabled();
|
||||
if (deferredProfile) {
|
||||
logDeferredProfile(
|
||||
`deferred band start: imports=${deferredWorkerImports.length} heritage=${deferredWorkerHeritage.length} ` +
|
||||
`calls=${deferredWorkerCalls.length} routes=${allExtractedRoutes.length}`,
|
||||
);
|
||||
}
|
||||
if (deferredWorkerImports.length > 0) {
|
||||
const tImports = startTimer(deferredProfile);
|
||||
await processImportsFromExtracted(
|
||||
graph,
|
||||
allPathObjects,
|
||||
|
|
@ -721,6 +735,11 @@ export async function runChunkedParseAndResolve(
|
|||
repoPath,
|
||||
importCtx,
|
||||
);
|
||||
endTimer(
|
||||
tImports,
|
||||
(ms) =>
|
||||
`processImportsFromExtracted: ${ms.toFixed(0)}ms (${deferredWorkerImports.length} import batches before drain)`,
|
||||
);
|
||||
// U15 (lightweight M1): processImportsFromExtracted is the sole
|
||||
// consumer of `deferredWorkerImports`. Free the array now so the
|
||||
// GC can reclaim the per-file ExtractedImport records before the
|
||||
|
|
@ -732,8 +751,10 @@ export async function runChunkedParseAndResolve(
|
|||
deferredWorkerImports.length = 0;
|
||||
}
|
||||
if (anyChunkNeedsWildcardSynth) {
|
||||
const tWildcard = startTimer(deferredProfile);
|
||||
synthesizeWildcardImportBindings(graph, ctx);
|
||||
hasSynthesized = true;
|
||||
endTimer(tWildcard, (ms) => `synthesizeWildcardImportBindings: ${ms.toFixed(0)}ms`);
|
||||
}
|
||||
// L5 from PR #1693 review: populate `exportedTypeMap` from the in-progress
|
||||
// graph BEFORE `seedCrossFileReceiverTypes` runs. Previously the seeding
|
||||
|
|
@ -754,11 +775,22 @@ export async function runChunkedParseAndResolve(
|
|||
ctx.namedImportMap,
|
||||
exportedTypeMap,
|
||||
);
|
||||
if (isDev && enrichedCount > 0) {
|
||||
logger.info(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (all chunks)`);
|
||||
if (enrichedCount > 0) {
|
||||
// Two independent gates, not else-if: when both isDev AND
|
||||
// deferredProfile are active, BOTH lines fire — log scrapers keyed
|
||||
// on the original "🔗 E1" emoji marker keep matching, AND operators
|
||||
// grepping the [deferred-profile] prefix see no gap between the
|
||||
// wildcard-synth and heritage timings.
|
||||
if (isDev) {
|
||||
logger.info(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (all chunks)`);
|
||||
}
|
||||
if (deferredProfile) {
|
||||
logDeferredProfile(`E1: seeded ${enrichedCount} cross-file receiver types (all chunks)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (deferredWorkerHeritage.length > 0) {
|
||||
const tHeritage = startTimer(deferredProfile);
|
||||
await processHeritageFromExtracted(graph, deferredWorkerHeritage, ctx, (current, total) => {
|
||||
const ratio = total > 0 ? current / total : 1;
|
||||
onProgress({
|
||||
|
|
@ -773,8 +805,14 @@ export async function runChunkedParseAndResolve(
|
|||
},
|
||||
});
|
||||
});
|
||||
endTimer(
|
||||
tHeritage,
|
||||
(ms) =>
|
||||
`processHeritageFromExtracted: ${ms.toFixed(0)}ms (${deferredWorkerHeritage.length} records)`,
|
||||
);
|
||||
}
|
||||
if (allExtractedRoutes.length > 0) {
|
||||
const tRoutes = startTimer(deferredProfile);
|
||||
await processRoutesFromExtracted(graph, allExtractedRoutes, ctx, (current, total) => {
|
||||
const ratio = total > 0 ? current / total : 1;
|
||||
onProgress({
|
||||
|
|
@ -789,12 +827,25 @@ export async function runChunkedParseAndResolve(
|
|||
},
|
||||
});
|
||||
});
|
||||
endTimer(
|
||||
tRoutes,
|
||||
(ms) =>
|
||||
`processRoutesFromExtracted: ${ms.toFixed(0)}ms (${allExtractedRoutes.length} routes)`,
|
||||
);
|
||||
}
|
||||
|
||||
const fullWorkerHeritageMap =
|
||||
deferredWorkerHeritage.length > 0
|
||||
? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage)
|
||||
: undefined;
|
||||
let fullWorkerHeritageMap: ReturnType<typeof buildHeritageMap> | undefined;
|
||||
if (deferredWorkerHeritage.length > 0) {
|
||||
const tBuildHeritage = startTimer(deferredProfile);
|
||||
fullWorkerHeritageMap = buildHeritageMap(
|
||||
deferredWorkerHeritage,
|
||||
ctx,
|
||||
getHeritageStrategyForLanguage,
|
||||
);
|
||||
endTimer(tBuildHeritage, (ms) => `buildHeritageMap wall: ${ms.toFixed(0)}ms`);
|
||||
} else if (deferredProfile) {
|
||||
logDeferredProfile('buildHeritageMap: skipped (no heritage records)');
|
||||
}
|
||||
// U15 (lightweight M1): buildHeritageMap is the LAST consumer of the
|
||||
// raw `deferredWorkerHeritage` records — processCallsFromExtracted
|
||||
// below reads from the derived `fullWorkerHeritageMap` instead. Free
|
||||
|
|
@ -804,6 +855,12 @@ export async function runChunkedParseAndResolve(
|
|||
deferredWorkerHeritage.length = 0;
|
||||
|
||||
if (deferredWorkerCalls.length > 0) {
|
||||
if (deferredProfile) {
|
||||
logDeferredProfile(
|
||||
`processCallsFromExtracted: starting (${deferredWorkerCalls.length} call sites, heritageMap=${fullWorkerHeritageMap !== undefined})`,
|
||||
);
|
||||
}
|
||||
const tCalls = startTimer(deferredProfile);
|
||||
await processCallsFromExtracted(
|
||||
graph,
|
||||
deferredWorkerCalls,
|
||||
|
|
@ -829,6 +886,7 @@ export async function runChunkedParseAndResolve(
|
|||
fullWorkerHeritageMap,
|
||||
bindingAccumulator,
|
||||
);
|
||||
endTimer(tCalls, (ms) => `processCallsFromExtracted: ${ms.toFixed(0)}ms total`);
|
||||
}
|
||||
|
||||
if (deferredAssignments.length > 0) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { parseTruthyEnv } from './utils/env.js';
|
||||
|
||||
/**
|
||||
* Languages whose RFC #909 Ring 3 scope-resolution migration is complete.
|
||||
|
|
@ -115,10 +116,6 @@ export function primaryLanguages(): ReadonlySet<SupportedLanguages> {
|
|||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Accepted truthy strings (case-insensitive, trimmed). */
|
||||
const TRUTHY_VALUES: ReadonlySet<string> = new Set(['true', '1', 'yes']);
|
||||
|
||||
function parseFlag(raw: string | undefined): boolean {
|
||||
if (raw === undefined) return false;
|
||||
return TRUTHY_VALUES.has(raw.trim().toLowerCase());
|
||||
return parseTruthyEnv(raw);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1048,6 +1048,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
|
|||
'@type-binding.type',
|
||||
'@reference.name',
|
||||
'@reference.receiver',
|
||||
'@reference.operator',
|
||||
'@reference.arity',
|
||||
'@reference.parameter-types',
|
||||
'@reference.parameter-type-classes',
|
||||
|
|
|
|||
|
|
@ -702,7 +702,9 @@ export const CPP_QUERIES = `
|
|||
|
||||
; Functions & Methods (direct declarator)
|
||||
(function_definition declarator: (function_declarator declarator: (identifier) @name)) @definition.function
|
||||
(function_definition declarator: (function_declarator declarator: (operator_name) @name)) @definition.function
|
||||
(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (identifier) @name))) @definition.method
|
||||
(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (operator_name) @name))) @definition.method
|
||||
|
||||
; Functions/methods returning pointers (pointer_declarator wraps function_declarator)
|
||||
(function_definition declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function
|
||||
|
|
@ -714,14 +716,18 @@ export const CPP_QUERIES = `
|
|||
|
||||
; Functions/methods returning references (reference_declarator wraps function_declarator)
|
||||
(function_definition declarator: (reference_declarator (function_declarator declarator: (identifier) @name))) @definition.function
|
||||
(function_definition declarator: (reference_declarator (function_declarator declarator: (operator_name) @name))) @definition.function
|
||||
(function_definition declarator: (reference_declarator (function_declarator declarator: (qualified_identifier name: (identifier) @name)))) @definition.method
|
||||
(function_definition declarator: (reference_declarator (function_declarator declarator: (qualified_identifier name: (operator_name) @name)))) @definition.method
|
||||
|
||||
; Destructors (destructor_name is distinct from identifier in tree-sitter-cpp)
|
||||
(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (destructor_name) @name))) @definition.method
|
||||
|
||||
; Function declarations / prototypes (common in headers)
|
||||
(declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.function
|
||||
(declaration declarator: (function_declarator declarator: (operator_name) @name)) @definition.function
|
||||
(declaration declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function
|
||||
(declaration declarator: (reference_declarator (function_declarator declarator: (operator_name) @name))) @definition.function
|
||||
|
||||
; Class/struct data member fields (Address address; int count;)
|
||||
; Uses field_identifier to exclude method declarations (which use function_declarator)
|
||||
|
|
@ -740,13 +746,13 @@ export const CPP_QUERIES = `
|
|||
|
||||
; Inline class method declarations (inside class body, no body: void save();)
|
||||
; tree-sitter-cpp uses field_identifier (not identifier) for names inside class bodies
|
||||
(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name)) @definition.method
|
||||
(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier) (operator_name)] @name)) @definition.method
|
||||
|
||||
; Inline class method declarations returning a pointer (User* lookup();)
|
||||
(field_declaration declarator: (pointer_declarator declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method
|
||||
|
||||
; Inline class method declarations returning a reference (User& lookup();)
|
||||
(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method
|
||||
(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier) (operator_name)] @name))) @definition.method
|
||||
|
||||
; Inline class method definitions (inside class body, with body: void Foo() { ... })
|
||||
(field_declaration_list
|
||||
|
|
@ -785,6 +791,8 @@ export const CPP_QUERIES = `
|
|||
(call_expression function: (field_expression field: (field_identifier) @call.name)) @call
|
||||
(call_expression function: (qualified_identifier name: (identifier) @call.name)) @call
|
||||
(call_expression function: (template_function name: (identifier) @call.name)) @call
|
||||
(binary_expression operator: "+" @call.name) @call
|
||||
(binary_expression operator: "<<" @call.name) @call
|
||||
|
||||
; Constructor calls: new User()
|
||||
(new_expression type: (type_identifier) @call.name) @call
|
||||
|
|
|
|||
120
gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts
Normal file
120
gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Wall-clock logging for the post-chunk deferred resolution band
|
||||
* (imports → heritage → heritage map → legacy call resolution).
|
||||
*
|
||||
* Enabled when either:
|
||||
* - `GITNEXUS_VERBOSE=1` / `gitnexus analyze -v` (primary path for #1741), or
|
||||
* - `GITNEXUS_PROFILE_DEFERRED=1` (force on without full verbose ingestion noise)
|
||||
*
|
||||
* Issue #1741: large Java/Kotlin repos appear stuck at "Resolving calls"
|
||||
* because the UI progress bar updates every 100 files and intermediate
|
||||
* stages emit little to the log.
|
||||
*/
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
import { parseTruthyEnv } from './env.js';
|
||||
import { isVerboseIngestionEnabled } from './verbose.js';
|
||||
|
||||
// Module-private tuning constants for the gates below. Not exported — these
|
||||
// are internal knobs, not part of the module's API surface.
|
||||
const LOG_EVERY_N_VERBOSE = 10;
|
||||
const LOG_EVERY_N_PROFILE = 100;
|
||||
const DEFAULT_SLOW_MS_VERBOSE = 3_000;
|
||||
const DEFAULT_SLOW_MS = 5_000;
|
||||
|
||||
/** True when deferred-stage timing / progress logs should emit. */
|
||||
export const isDeferredResolutionProfileEnabled = (): boolean =>
|
||||
isVerboseIngestionEnabled() || parseTruthyEnv(process.env.GITNEXUS_PROFILE_DEFERRED);
|
||||
|
||||
/** Log a call-resolution progress line every N files (finer when verbose). */
|
||||
export const deferredCallLogEveryN = (): number =>
|
||||
isVerboseIngestionEnabled() ? LOG_EVERY_N_VERBOSE : LOG_EVERY_N_PROFILE;
|
||||
|
||||
/** Per-file call-resolution log threshold (ms). Lower default when verbose. */
|
||||
export const deferredCallFileSlowMs = (): number => {
|
||||
const raw = process.env.GITNEXUS_PROFILE_DEFERRED_SLOW_MS;
|
||||
if (raw) {
|
||||
// Use Number() not parseInt: parseInt('1e9', 10) === 1 (prefix-parses, drops the exponent),
|
||||
// which would turn a user-intended "effectively disabled" threshold into a 1 ms log storm.
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
return isVerboseIngestionEnabled() ? DEFAULT_SLOW_MS_VERBOSE : DEFAULT_SLOW_MS;
|
||||
};
|
||||
|
||||
export const profileNow = (): bigint => process.hrtime.bigint();
|
||||
|
||||
export const profileElapsedMs = (start: bigint): number =>
|
||||
Number(process.hrtime.bigint() - start) / 1e6;
|
||||
|
||||
// Module-private counter for `[deferred-profile]` log lines the underlying
|
||||
// logger refused to accept. Pino's SonicBoom transport is sync:false today,
|
||||
// so steady-state `logger.info(string)` calls don't throw — but first-use
|
||||
// construction paths (pino-pretty resolve, level validation) and any future
|
||||
// transport reconfiguration could. The wrap below catches and counts so a
|
||||
// failing logger cannot abort the deferred band, and the count surfaces in
|
||||
// the deferred-band done-summary (see processCallsFromExtracted) so the
|
||||
// failure is visible rather than silently swallowed (DoD §2.8).
|
||||
let droppedLogLines = 0;
|
||||
|
||||
/**
|
||||
* Number of `logDeferredProfile` calls whose underlying `logger.info` threw.
|
||||
* Surfaced in the deferred-band done-summary when greater than zero.
|
||||
*/
|
||||
export const getDeferredProfileDroppedCount = (): number => droppedLogLines;
|
||||
|
||||
/**
|
||||
* Reset the dropped-line counter. Call from test `afterEach` to keep the
|
||||
* module-private state from leaking across tests. Also used inside
|
||||
* `processCallsFromExtracted` at function entry so each analyze run gets
|
||||
* a fresh count rather than accumulating across the process lifetime.
|
||||
*/
|
||||
export const resetDeferredProfileDroppedCount = (): void => {
|
||||
droppedLogLines = 0;
|
||||
};
|
||||
|
||||
export const logDeferredProfile = (message: string): void => {
|
||||
try {
|
||||
logger.info(`[deferred-profile] ${message}`);
|
||||
} catch {
|
||||
// Do not call the failing logger from the handler — that would risk
|
||||
// an infinite loop if the failure mode is steady-state. Just count.
|
||||
droppedLogLines++;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture a monotonic timestamp when profiling is enabled; otherwise return null.
|
||||
* Pair with `endTimer` so the type system narrows correctly — using `null` instead
|
||||
* of a `0n` sentinel makes "profiling disabled" structurally distinct from
|
||||
* "zero elapsed time" and lets TypeScript catch missing guards.
|
||||
*/
|
||||
export const startTimer = (enabled: boolean): bigint | null =>
|
||||
enabled ? process.hrtime.bigint() : null;
|
||||
|
||||
/**
|
||||
* Emit a `[deferred-profile]` log line for a captured timer. No-op when the
|
||||
* timer is `null` (profiling was disabled at capture time). The formatter
|
||||
* receives elapsed ms so the call sites stay readable.
|
||||
*
|
||||
* The format callback runs inside a try/catch so a throwing formatter
|
||||
* (custom toString, JSON.stringify on a circular object) cannot abort the
|
||||
* deferred resolution band — observability code must never escalate to a
|
||||
* load-bearing failure. On catch we emit a single `formatter error: …`
|
||||
* line via logDeferredProfile and return; the caller's stage continues
|
||||
* as if profiling had no-op'd for this timer. DoD §2.8 ("no silent
|
||||
* catches that swallow diagnostics") is satisfied by surfacing the
|
||||
* failure message rather than dropping it.
|
||||
*/
|
||||
export const endTimer = (start: bigint | null, format: (elapsedMs: number) => string): void => {
|
||||
if (start === null) return;
|
||||
const elapsedMs = profileElapsedMs(start);
|
||||
let message: string;
|
||||
try {
|
||||
message = format(elapsedMs);
|
||||
} catch (err) {
|
||||
logDeferredProfile(`formatter error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
}
|
||||
logDeferredProfile(message);
|
||||
};
|
||||
|
|
@ -10,6 +10,24 @@
|
|||
/** Whether we're running in development mode (enables verbose console logging). */
|
||||
export const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Parse a narrow-form truthy env-var value. Accepts `'1'`, `'true'`, `'yes'`
|
||||
* (case-insensitive, whitespace-trimmed). Anything else — including
|
||||
* `undefined`, empty string, `'0'`, `'false'`, `'no'`, or unknown tokens —
|
||||
* returns `false`.
|
||||
*
|
||||
* This is the shared helper for narrow-form truthy parsing across the
|
||||
* ingestion module. `logger.ts` uses a broader negative-list form
|
||||
* (`isTruthyEnv`) that intentionally accepts anything except a small set of
|
||||
* falsy tokens — that lives separately because it follows pino-debug
|
||||
* conventions and serves a different purpose.
|
||||
*/
|
||||
export const parseTruthyEnv = (raw: string | undefined): boolean => {
|
||||
if (raw === undefined) return false;
|
||||
const value = raw.trim().toLowerCase();
|
||||
return value === '1' || value === 'true' || value === 'yes';
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether scope-resolution dev validators (e.g. `validateBindingsImmutability`)
|
||||
* should run AND emit warnings. Off by default in CLI runs to avoid silent
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
export const isVerboseIngestionEnabled = (): boolean => {
|
||||
const raw = process.env.GITNEXUS_VERBOSE;
|
||||
if (!raw) return false;
|
||||
const value = raw.toLowerCase();
|
||||
return value === '1' || value === 'true' || value === 'yes';
|
||||
};
|
||||
import { parseTruthyEnv } from './env.js';
|
||||
|
||||
export const isVerboseIngestionEnabled = (): boolean =>
|
||||
parseTruthyEnv(process.env.GITNEXUS_VERBOSE);
|
||||
|
|
|
|||
|
|
@ -1527,6 +1527,27 @@ export const flushWAL = async (): Promise<void> => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Issue a manual `CHECKPOINT` against the current connection and surface
|
||||
* any engine error to the caller. Unlike {@link flushWAL}, this variant
|
||||
* does NOT swallow Ladybug rename/remove IO failures — the manual
|
||||
* checkpoint driver (`wal-checkpoint-driver.ts`) relies on the rejection
|
||||
* to drive its bounded retry loop. Returns `false` when no connection is
|
||||
* open (the caller treats this as a no-op success — there is no WAL to
|
||||
* flush). Returns `true` after a successful CHECKPOINT + drain.
|
||||
*
|
||||
* The split from `flushWAL` is deliberate: every other CHECKPOINT site
|
||||
* (server flush, safeClose) is best-effort and prefers a silent skip;
|
||||
* the manual driver, by contrast, must observe failures to decide
|
||||
* whether to retry.
|
||||
*/
|
||||
export const tryFlushWAL = async (): Promise<boolean> => {
|
||||
if (!conn) return false;
|
||||
const checkpointResult = await conn.query('CHECKPOINT');
|
||||
await drainQueryResult(checkpointResult);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flush the WAL and close the connection and database handles.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from 'fs/promises';
|
|||
import os from 'os';
|
||||
import path from 'path';
|
||||
import type lbug from '@ladybugdb/core';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Shared configuration for `@ladybugdb/core` `Database` construction.
|
||||
|
|
@ -45,6 +46,44 @@ export const LBUG_MAX_DB_SIZE: number = (() => {
|
|||
return 16 * 1024 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
export const parseWalCheckpointThreshold = (raw: string | undefined): number | undefined => {
|
||||
if (raw === undefined) return undefined;
|
||||
const normalized = raw.trim();
|
||||
if (normalized.length === 0) return undefined;
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isInteger(parsed) || parsed < -1) return undefined;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default GitNexus WAL auto-checkpoint threshold in bytes (64 MiB).
|
||||
*
|
||||
* Larger than Ladybug's stock ~16 MiB to reduce checkpoint rename/remove
|
||||
* churn under heavy analyze write load — the original race that motivated
|
||||
* issue #1741 triggered at the stock threshold. README examples in
|
||||
* `README.md` and `gitnexus/README.md` and the recovery hint in
|
||||
* `analyze.ts` MUST stay in sync with this value.
|
||||
*/
|
||||
const DEFAULT_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;
|
||||
|
||||
const resolveCheckpointThreshold = (): number => {
|
||||
const raw = process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD;
|
||||
if (raw === undefined) return DEFAULT_WAL_CHECKPOINT_THRESHOLD;
|
||||
const parsed = parseWalCheckpointThreshold(raw);
|
||||
if (parsed !== undefined) return parsed;
|
||||
// Non-empty but unparseable input: warn the operator and fall back. Mirrors
|
||||
// the CLI's `--wal-checkpoint-threshold` validation (which hard-errors)
|
||||
// but the env-var path stays soft to preserve "set once in your shell"
|
||||
// ergonomics across mixed-version invocations.
|
||||
if (raw.trim().length > 0) {
|
||||
logger.warn(
|
||||
{ rawValue: raw, fallback: DEFAULT_WAL_CHECKPOINT_THRESHOLD },
|
||||
`Ignoring invalid GITNEXUS_WAL_CHECKPOINT_THRESHOLD=${raw}; expected integer >= -1; falling back to default (${DEFAULT_WAL_CHECKPOINT_THRESHOLD}).`,
|
||||
);
|
||||
}
|
||||
return DEFAULT_WAL_CHECKPOINT_THRESHOLD;
|
||||
};
|
||||
|
||||
/** Matches WAL corruption errors from the LadybugDB engine. */
|
||||
const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i;
|
||||
|
||||
|
|
@ -57,6 +96,50 @@ export function isWalCorruptionError(err: unknown): boolean {
|
|||
return WAL_CORRUPTION_RE.test(msg);
|
||||
}
|
||||
|
||||
// ─── Ladybug WAL checkpoint IO error matchers ───────────────────────────────
|
||||
//
|
||||
// Matched against LadybugDB v0.16.1 (see `gitnexus/package.json`
|
||||
// @ladybugdb/core). Strict regexes encode local_file_system.cpp wording
|
||||
// verified at that version. Two-tier strategy: strict matchers first so we
|
||||
// only fire on real checkpoint-rotation shapes; a permissive fallback
|
||||
// catches future Ladybug message drift so the recovery hint keeps surfacing
|
||||
// even if upstream wording changes.
|
||||
//
|
||||
// From Ladybug native LocalFileSystem exceptions (`local_file_system.cpp`),
|
||||
// surfaced in Node as:
|
||||
// "Runtime exception: IO exception: Error renaming file ..."
|
||||
// "Runtime exception: IO exception: Error removing directory or file ..."
|
||||
// We only match checkpoint-rotation shapes:
|
||||
// - "<db>.wal -> <db>.wal.checkpoint" rename failures
|
||||
// - "<db>.wal.checkpoint" remove failures
|
||||
// Example matches:
|
||||
// "Runtime exception: IO exception: Error renaming file /x/lbug.wal to /x/lbug.wal.checkpoint. ErrorMessage: Permission denied"
|
||||
// "Runtime exception: IO exception: Error removing directory or file /x/lbug.wal.checkpoint. Error Message: Permission denied"
|
||||
// Matching is case-insensitive to remain robust across wrappers/platforms.
|
||||
const LBUG_CHECKPOINT_RENAME_RE =
|
||||
/^runtime exception: io exception:\s*error renaming file\s+.+?\.wal\s+to\s+.+?\.wal\.checkpoint(?:\.|\s|$)/i;
|
||||
const LBUG_CHECKPOINT_REMOVE_RE =
|
||||
/^runtime exception: io exception:\s*error removing directory or file\s+.+?\.wal\.checkpoint(?:\.|\s|$)/i;
|
||||
/**
|
||||
* Permissive fallback: any IO-exception-shaped message that mentions a
|
||||
* `.wal.checkpoint` path. Catches future Ladybug message drift (different
|
||||
* verb, additional preamble, locale variation) so the recovery hint keeps
|
||||
* surfacing even if the strict regexes go stale.
|
||||
*/
|
||||
const LBUG_CHECKPOINT_PERMISSIVE_RE = /io exception.*\.wal\.checkpoint/i;
|
||||
|
||||
/**
|
||||
* True when `err` looks like a Ladybug WAL-checkpoint rotation/remove IO
|
||||
* failure. Tries strict matchers first (renames + removes), then falls
|
||||
* back to the permissive matcher.
|
||||
*/
|
||||
export const isLbugCheckpointIoError = (err: unknown): boolean => {
|
||||
if (!err) return false;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (LBUG_CHECKPOINT_RENAME_RE.test(msg) || LBUG_CHECKPOINT_REMOVE_RE.test(msg)) return true;
|
||||
return LBUG_CHECKPOINT_PERMISSIVE_RE.test(msg);
|
||||
};
|
||||
|
||||
type LbugModule = typeof lbug;
|
||||
|
||||
export interface LbugDatabaseOptions {
|
||||
|
|
@ -103,8 +186,8 @@ export function createLbugDatabase(
|
|||
false, // enableCompression (pinned for v0.16.0)
|
||||
options.readOnly ?? false,
|
||||
LBUG_MAX_DB_SIZE,
|
||||
true, // autoCheckpoint
|
||||
-1, // checkpointThreshold
|
||||
true, // autoCheckpoint (always on)
|
||||
resolveCheckpointThreshold(), // checkpointThreshold (default 64 MiB; override with GITNEXUS_WAL_CHECKPOINT_THRESHOLD; -1 keeps Ladybug stock ~16 MiB)
|
||||
options.throwOnWalReplayFailure ?? true,
|
||||
true, // enableChecksums
|
||||
) as lbug.Database;
|
||||
|
|
|
|||
232
gitnexus/src/core/lbug/wal-checkpoint-driver.ts
Normal file
232
gitnexus/src/core/lbug/wal-checkpoint-driver.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Manual WAL checkpoint driver with bounded retry (#1741 follow-up).
|
||||
*
|
||||
* Background
|
||||
* ----------
|
||||
* LadybugDB's native auto-checkpoint runs from inside the C++ engine on a
|
||||
* background path that has no JS-side hook for mid-write rotation. When
|
||||
* the rename of `<db>.wal` → `<db>.wal.checkpoint` races a transient file
|
||||
* lock (Windows Defender, AV scanner, NTFS shadow copy) the engine raises
|
||||
* a `Runtime exception: IO exception: Error renaming file …` that aborts
|
||||
* the in-flight write. There is no engine-level retry.
|
||||
*
|
||||
* The auto-checkpoint cannot be made retryable from JS, but a *manual*
|
||||
* `CHECKPOINT` query that the JS layer issues itself CAN be wrapped in a
|
||||
* bounded retry. By draining the WAL on a tight cadence — more often than
|
||||
* the native threshold — the auto-checkpoint almost never has work left
|
||||
* to do, so the un-retriable native rename race is moved into the
|
||||
* JS-controlled path where this module's retry absorbs it.
|
||||
*
|
||||
* Design contract
|
||||
* ---------------
|
||||
* - `autoCheckpoint` stays on (maintainer requirement). This driver is
|
||||
* additive: it preempts the native checkpoint, it does not replace it.
|
||||
* - The driver runs ONLY during analyze (callers opt-in explicitly). MCP
|
||||
* and other long-lived flows continue to rely on the close-time
|
||||
* CHECKPOINT in `safeClose`.
|
||||
* - Opt-out is via `GITNEXUS_WAL_MANUAL_CHECKPOINT=0`. Default is on.
|
||||
* - Retries only fire on `isLbugCheckpointIoError` — every other error
|
||||
* surfaces immediately. The retry budget is small (3 attempts) with
|
||||
* jittered backoff so a chronic rename failure escalates fast.
|
||||
* - Retry attempts log at `debug`; only the final, exhausted failure
|
||||
* surfaces to the caller (and is logged at `warn` here for operators).
|
||||
*/
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
import { tryFlushWAL } from './lbug-adapter.js';
|
||||
import { isLbugCheckpointIoError } from './lbug-config.js';
|
||||
|
||||
/**
|
||||
* Bounded retry budget. Total worst-case wall time is dominated by the
|
||||
* three sleeps below (~750 ms before jitter) plus three CHECKPOINT round
|
||||
* trips — small enough to stay invisible during a large analyze, large
|
||||
* enough to ride out a single AV scanner sweep on Windows.
|
||||
*/
|
||||
const CHECKPOINT_RETRY_ATTEMPTS = 3;
|
||||
|
||||
/**
|
||||
* Base back-off in ms. Each attempt waits `BASE_DELAYS[attempt-1]`
|
||||
* milliseconds before the next try, plus a small jitter to avoid
|
||||
* synchronized retries when multiple analyzers ever share a host.
|
||||
*/
|
||||
const BASE_DELAYS_MS: readonly number[] = [50, 200, 500];
|
||||
|
||||
/** Maximum jitter added on top of each base delay. */
|
||||
const JITTER_MAX_MS = 50;
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Run a single CHECKPOINT with bounded retry on
|
||||
* `isLbugCheckpointIoError`. Returns the number of attempts actually
|
||||
* spent (1-`CHECKPOINT_RETRY_ATTEMPTS`) on success, or rethrows the last
|
||||
* checkpoint error after exhausting the budget. Non-checkpoint errors
|
||||
* (e.g. WAL corruption, lock-busy) propagate immediately on the first
|
||||
* attempt — those are not what this retry is designed to absorb.
|
||||
*
|
||||
* The split from `flushWAL` is deliberate: `flushWAL` is the swallow-and-
|
||||
* log helper used by `safeClose` and the server's best-effort flush,
|
||||
* which by contract cannot fail the surrounding operation. The manual
|
||||
* driver MUST observe failures to decide whether to retry, and that is
|
||||
* the role of `tryFlushWAL`.
|
||||
*
|
||||
* Exported for direct unit testing — production callers use
|
||||
* {@link startWalCheckpointDriver} or {@link checkpointOnce}.
|
||||
*/
|
||||
export const runCheckpointWithRetry = async (
|
||||
options: {
|
||||
/** Override the sleep implementation for tests. */
|
||||
sleepFn?: (ms: number) => Promise<void>;
|
||||
/** Override the CHECKPOINT call for tests. */
|
||||
checkpointFn?: () => Promise<boolean>;
|
||||
/** Override the jitter source for tests. Returns a value in [0, 1). */
|
||||
randomFn?: () => number;
|
||||
} = {},
|
||||
): Promise<{ attempts: number; flushed: boolean }> => {
|
||||
const sleepImpl = options.sleepFn ?? sleep;
|
||||
const checkpointImpl = options.checkpointFn ?? tryFlushWAL;
|
||||
const randomImpl = options.randomFn ?? Math.random;
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= CHECKPOINT_RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const flushed = await checkpointImpl();
|
||||
return { attempts: attempt, flushed };
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (!isLbugCheckpointIoError(err)) {
|
||||
// Non-checkpoint error — propagate immediately. Examples:
|
||||
// WAL corruption, missing connection, query syntax failure.
|
||||
// Retrying these would only mask the real signal.
|
||||
throw err;
|
||||
}
|
||||
if (attempt === CHECKPOINT_RETRY_ATTEMPTS) break;
|
||||
const base = BASE_DELAYS_MS[Math.min(attempt - 1, BASE_DELAYS_MS.length - 1)] ?? 500;
|
||||
// randomImpl defaults to Math.random — non-cryptographic by design; jitter only avoids
|
||||
// synchronized retries between concurrent analyzers.
|
||||
const delayMs = base + Math.floor(randomImpl() * JITTER_MAX_MS);
|
||||
logger.debug(
|
||||
{ attempt, totalAttempts: CHECKPOINT_RETRY_ATTEMPTS, delayMs },
|
||||
'GitNexus: WAL checkpoint IO error — retrying',
|
||||
);
|
||||
await sleepImpl(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
{ attempts: CHECKPOINT_RETRY_ATTEMPTS },
|
||||
'GitNexus: manual WAL checkpoint exhausted retry budget — surfacing IO error to caller',
|
||||
);
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
/**
|
||||
* Single-shot manual checkpoint. Use this when the caller drives the
|
||||
* cadence itself (e.g. a phase boundary in `runFullAnalysis`).
|
||||
*
|
||||
* Honors the `GITNEXUS_WAL_MANUAL_CHECKPOINT=0` opt-out so operators can
|
||||
* disable the manual path if it ever interacts badly with a future
|
||||
* Ladybug release.
|
||||
*/
|
||||
export const checkpointOnce = async (): Promise<void> => {
|
||||
if (!isManualCheckpointEnabled()) return;
|
||||
await runCheckpointWithRetry();
|
||||
};
|
||||
|
||||
/** Default cadence (ms) for the periodic driver. */
|
||||
const DEFAULT_PERIOD_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Start a periodic manual checkpoint driver. The returned handle has a
|
||||
* `stop()` method that resolves once the in-flight checkpoint (if any)
|
||||
* settles, so callers can `await driver.stop()` before close-time
|
||||
* `safeClose` and avoid racing the final flush.
|
||||
*
|
||||
* The first checkpoint fires after `periodMs` (not immediately) so a
|
||||
* cold analyze does not pay a CHECKPOINT round trip before any writes
|
||||
* have happened.
|
||||
*/
|
||||
export interface WalCheckpointDriver {
|
||||
/** Stop the driver and await any in-flight checkpoint. Idempotent. */
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export const startWalCheckpointDriver = (
|
||||
options: { periodMs?: number } = {},
|
||||
): WalCheckpointDriver => {
|
||||
if (!isManualCheckpointEnabled()) {
|
||||
return { stop: async () => undefined };
|
||||
}
|
||||
|
||||
const periodMs = options.periodMs ?? DEFAULT_PERIOD_MS;
|
||||
let stopped = false;
|
||||
let inflight: Promise<void> | null = null;
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
inflight = runCheckpointWithRetry()
|
||||
.then(() => undefined)
|
||||
.catch((err) => {
|
||||
// The retry budget exhausted. The caller's surrounding write
|
||||
// will see the same engine error on its next operation and the
|
||||
// `analyzeCommand` catch block will emit the recovery hint.
|
||||
// Logging here keeps the operator-visible trail without
|
||||
// double-logging the user-facing message.
|
||||
logger.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
'GitNexus: manual WAL checkpoint failed after retries',
|
||||
);
|
||||
});
|
||||
try {
|
||||
await inflight;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handle = setInterval(() => {
|
||||
// Fire-and-forget: setInterval cannot await directly. The next tick
|
||||
// is guarded by `stopped` and the `inflight` reference.
|
||||
void tick();
|
||||
}, periodMs);
|
||||
// `setInterval` returned by Node is a `Timeout` object with `.unref()`
|
||||
// so a hung driver never prevents process exit.
|
||||
if (typeof (handle as NodeJS.Timeout).unref === 'function') {
|
||||
(handle as NodeJS.Timeout).unref();
|
||||
}
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
if (stopped) {
|
||||
if (inflight) await inflight;
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
clearInterval(handle);
|
||||
if (inflight) {
|
||||
try {
|
||||
await inflight;
|
||||
} catch {
|
||||
/* swallowed in tick() — surface path is the surrounding write */
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reading `GITNEXUS_WAL_MANUAL_CHECKPOINT` at every call site (rather
|
||||
* than caching at module load) keeps `analyzeCommand` env restoration
|
||||
* honest: tests that toggle the flag between invocations see the live
|
||||
* value, matching the `ANALYZE_CLI_ENV_KEYS` snapshot/restore contract
|
||||
* in `analyze.ts`.
|
||||
*
|
||||
* Accepted opt-out values: '0', 'false', 'off', 'no' (case-insensitive).
|
||||
* Anything else — including undefined — leaves the driver enabled.
|
||||
*/
|
||||
export const isManualCheckpointEnabled = (): boolean => {
|
||||
const raw = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT;
|
||||
if (raw === undefined) return true;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return !['0', 'false', 'off', 'no'].includes(normalized);
|
||||
};
|
||||
|
|
@ -26,6 +26,10 @@ import {
|
|||
queryImporters,
|
||||
} from './lbug/lbug-adapter.js';
|
||||
import { createSearchFTSIndexes, verifySearchFTSIndexes } from './search/fts-indexes.js';
|
||||
import {
|
||||
startWalCheckpointDriver,
|
||||
type WalCheckpointDriver,
|
||||
} from './lbug/wal-checkpoint-driver.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
saveMeta,
|
||||
|
|
@ -521,6 +525,16 @@ export async function runFullAnalysis(
|
|||
}
|
||||
|
||||
await initLbug(lbugPath);
|
||||
|
||||
// Manual WAL checkpoint driver (#1741): periodically drain the WAL
|
||||
// from JS so the un-retriable native auto-checkpoint almost never
|
||||
// has work left to do. Failures of the manual CHECKPOINT are absorbed
|
||||
// by the driver's bounded retry; the final un-recoverable error still
|
||||
// surfaces via the surrounding write that follows the failed flush.
|
||||
// Opt-out via `GITNEXUS_WAL_MANUAL_CHECKPOINT=0` (the driver itself
|
||||
// returns a no-op handle when disabled). Analyze-only: MCP and serve
|
||||
// paths continue to rely on the close-time CHECKPOINT in `safeClose`.
|
||||
const walCheckpointDriver: WalCheckpointDriver = startWalCheckpointDriver();
|
||||
try {
|
||||
// All work after initLbug is wrapped in try/finally to ensure closeLbug()
|
||||
// is called even if an error occurs — the module-level singleton DB handle
|
||||
|
|
@ -961,6 +975,9 @@ export async function runFullAnalysis(
|
|||
}
|
||||
|
||||
// ── Close LadybugDB ──────────────────────────────────────────────
|
||||
// Stop the manual checkpoint driver before closeLbug so its
|
||||
// in-flight CHECKPOINT cannot race the `safeClose` CHECKPOINT.
|
||||
await walCheckpointDriver.stop();
|
||||
await closeLbug();
|
||||
|
||||
progress('done', 100, 'Done');
|
||||
|
|
@ -972,7 +989,13 @@ export async function runFullAnalysis(
|
|||
pipelineResult,
|
||||
};
|
||||
} catch (err) {
|
||||
// Ensure LadybugDB is closed even on error
|
||||
// Ensure LadybugDB is closed even on error. Stop the driver first
|
||||
// so its retry loop cannot extend an already-failing analyze.
|
||||
try {
|
||||
await walCheckpointDriver.stop();
|
||||
} catch {
|
||||
/* swallow — surface path is the rethrow below */
|
||||
}
|
||||
try {
|
||||
await closeLbug();
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import { realpathSync } from 'fs';
|
|||
import {
|
||||
listRegisteredRepos,
|
||||
cleanupOldKuzuFiles,
|
||||
canonicalizePath,
|
||||
RegistryAmbiguousTargetError,
|
||||
type RegistryEntry,
|
||||
} from '../../storage/repo-manager.js';
|
||||
import { GroupService, type GroupToolPort } from '../../core/group/service.js';
|
||||
|
|
@ -295,6 +297,13 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string
|
|||
return repoPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the base64url path hash appended to a colliding repo id.
|
||||
* Exported so tests can pin the suffix shape without re-deriving the
|
||||
* literal; see `repoId()` and the hashed-id resolution tier (#1658).
|
||||
*/
|
||||
export const REPO_ID_HASH_LENGTH = 6;
|
||||
|
||||
export class LocalBackend {
|
||||
private repos: Map<string, RepoHandle> = new Map();
|
||||
private contextCache: Map<string, CodebaseContext> = new Map();
|
||||
|
|
@ -423,7 +432,13 @@ export class LocalBackend {
|
|||
for (const [id, handle] of this.repos) {
|
||||
if (id === base && handle.repoPath !== path.resolve(repoPath)) {
|
||||
// Collision — use path hash
|
||||
const hash = Buffer.from(repoPath).toString('base64url').slice(0, 6);
|
||||
// Lowercase the hash so it survives the `paramLower` lookup in
|
||||
// resolveRepoFromCache — base64url retains mixed case, but the id
|
||||
// tier compares against `repoParam.toLowerCase()` (#1658 follow-up).
|
||||
const hash = Buffer.from(repoPath)
|
||||
.toString('base64url')
|
||||
.slice(0, REPO_ID_HASH_LENGTH)
|
||||
.toLowerCase();
|
||||
return `${base}-${hash}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -442,7 +457,19 @@ export class LocalBackend {
|
|||
* while the MCP server was running.
|
||||
*/
|
||||
async resolveRepo(repoParam?: string): Promise<RepoHandle> {
|
||||
const result = this.resolveRepoFromCache(repoParam);
|
||||
let refreshedAfterAmbiguity = false;
|
||||
let result: RepoHandle | null;
|
||||
try {
|
||||
result = this.resolveRepoFromCache(repoParam);
|
||||
} catch (err) {
|
||||
if (!(err instanceof RegistryAmbiguousTargetError)) throw err;
|
||||
// Stale in-memory duplicate siblings can linger after unregister; refresh
|
||||
// once before re-throwing so a resolved registry can disambiguate (#1658).
|
||||
await this.refreshRepos();
|
||||
refreshedAfterAmbiguity = true;
|
||||
result = this.resolveRepoFromCache(repoParam);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
// Issue: silent graph drift across sibling clones.
|
||||
// If the caller's cwd lives in a *different* on-disk clone of
|
||||
|
|
@ -456,8 +483,10 @@ export class LocalBackend {
|
|||
return result;
|
||||
}
|
||||
|
||||
// Miss — refresh registry and try once more
|
||||
await this.refreshRepos();
|
||||
// Miss — refresh registry and try once more (skip if already refreshed above)
|
||||
if (!refreshedAfterAmbiguity) {
|
||||
await this.refreshRepos();
|
||||
}
|
||||
const retried = this.resolveRepoFromCache(repoParam);
|
||||
if (retried) {
|
||||
this.maybeWarnSiblingDrift(retried).catch(() => {});
|
||||
|
|
@ -492,27 +521,66 @@ export class LocalBackend {
|
|||
|
||||
/**
|
||||
* Try to resolve a repo from the in-memory cache. Returns null on miss.
|
||||
* Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches
|
||||
* multiple handles by name and cwd cannot disambiguate (#1658).
|
||||
*/
|
||||
private resolveRepoFromCache(repoParam?: string): RepoHandle | null {
|
||||
if (this.repos.size === 0) return null;
|
||||
|
||||
if (repoParam) {
|
||||
const paramLower = repoParam.toLowerCase();
|
||||
// Match by id
|
||||
const looksLikePath =
|
||||
path.isAbsolute(repoParam) || repoParam.includes(path.sep) || repoParam.includes('/');
|
||||
|
||||
const resolvePathMatch = (): RepoHandle | undefined => {
|
||||
const canonicalTarget = canonicalizePath(repoParam);
|
||||
return [...this.repos.values()].find((handle) => {
|
||||
const stored = canonicalizePath(handle.repoPath);
|
||||
return process.platform === 'win32'
|
||||
? stored.toLowerCase() === canonicalTarget.toLowerCase()
|
||||
: stored === canonicalTarget;
|
||||
});
|
||||
};
|
||||
|
||||
// Path-like params first (absolute or contains separators) — aligns with
|
||||
// resolveRegistryEntry (#829). Bare aliases such as ".tmp-repro-mini" must
|
||||
// not be resolved via path.resolve(cwd) before duplicate-name handling.
|
||||
if (looksLikePath) {
|
||||
const pathMatch = resolvePathMatch();
|
||||
if (pathMatch) return pathMatch;
|
||||
}
|
||||
|
||||
// Exact name before id — the first duplicate sibling keeps id === name
|
||||
// (e.g. id "shared"), so a name lookup must not be captured by the id tier.
|
||||
const nameMatches = [...this.repos.values()].filter(
|
||||
(handle) => handle.name.toLowerCase() === paramLower,
|
||||
);
|
||||
if (nameMatches.length === 1) return nameMatches[0];
|
||||
if (nameMatches.length > 1) {
|
||||
const cwdPick = this.pickRepoHandleForCwd(nameMatches);
|
||||
if (cwdPick) return cwdPick;
|
||||
throw new RegistryAmbiguousTargetError(
|
||||
repoParam,
|
||||
nameMatches.map((h) => this.handleToRegistryEntry(h)),
|
||||
);
|
||||
}
|
||||
|
||||
// Stable hashed id (e.g. "shared-abc123") from repoId() collision suffix
|
||||
if (this.repos.has(paramLower)) return this.repos.get(paramLower)!;
|
||||
// Match by name (case-insensitive)
|
||||
for (const handle of this.repos.values()) {
|
||||
if (handle.name.toLowerCase() === paramLower) return handle;
|
||||
}
|
||||
// Match by path (substring)
|
||||
const resolved = path.resolve(repoParam);
|
||||
for (const handle of this.repos.values()) {
|
||||
if (handle.repoPath === resolved) return handle;
|
||||
}
|
||||
// Match by partial name
|
||||
for (const handle of this.repos.values()) {
|
||||
if (handle.name.toLowerCase().includes(paramLower)) return handle;
|
||||
|
||||
// Bare name resolved as a cwd-relative path (e.g. "myrepo" against process.cwd()),
|
||||
// after name/id tiers. Path-like strings with separators were handled at the top.
|
||||
if (!looksLikePath) {
|
||||
const pathMatch = resolvePathMatch();
|
||||
if (pathMatch) return pathMatch;
|
||||
}
|
||||
|
||||
// Partial name — only when unambiguous
|
||||
const partialMatches = [...this.repos.values()].filter((handle) =>
|
||||
handle.name.toLowerCase().includes(paramLower),
|
||||
);
|
||||
if (partialMatches.length === 1) return partialMatches[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -523,6 +591,39 @@ export class LocalBackend {
|
|||
return null; // Multiple repos, no param — ambiguous
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the indexed repo whose path matches the git root of process.cwd().
|
||||
*
|
||||
* In MCP stdio server mode, `process.cwd()` is the server's launch directory,
|
||||
* not the agent client's cwd. If the server was started from an unrelated
|
||||
* directory, `getGitRoot` returns null and duplicate-name resolution throws
|
||||
* {@link RegistryAmbiguousTargetError} — callers should pass an absolute path.
|
||||
*/
|
||||
private pickRepoHandleForCwd(candidates: RepoHandle[]): RepoHandle | null {
|
||||
const cwdRoot = getGitRoot(process.cwd());
|
||||
if (!cwdRoot) return null;
|
||||
const canonicalCwd = canonicalizePath(cwdRoot);
|
||||
const cwdMatches = candidates.filter((handle) => {
|
||||
const stored = canonicalizePath(handle.repoPath);
|
||||
return process.platform === 'win32'
|
||||
? stored.toLowerCase() === canonicalCwd.toLowerCase()
|
||||
: stored === canonicalCwd;
|
||||
});
|
||||
return cwdMatches.length === 1 ? cwdMatches[0] : null;
|
||||
}
|
||||
|
||||
private handleToRegistryEntry(handle: RepoHandle): RegistryEntry {
|
||||
return {
|
||||
name: handle.name,
|
||||
path: handle.repoPath,
|
||||
storagePath: handle.storagePath,
|
||||
indexedAt: handle.indexedAt,
|
||||
lastCommit: handle.lastCommit,
|
||||
stats: handle.stats,
|
||||
remoteUrl: handle.remoteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Lazy LadybugDB Init ────────────────────────────────────────────
|
||||
|
||||
private async ensureInitialized(repoId: string): Promise<void> {
|
||||
|
|
|
|||
26
gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp
vendored
Normal file
26
gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#include "lib.h"
|
||||
|
||||
namespace std {
|
||||
ostream cout;
|
||||
}
|
||||
|
||||
std::ostream& operator<<(std::ostream& os, const Point& p) {
|
||||
return os;
|
||||
}
|
||||
|
||||
void runMember(Point a, Point b) {
|
||||
Point c = a + b;
|
||||
}
|
||||
|
||||
void runFree(Point p) {
|
||||
std::cout << p;
|
||||
}
|
||||
|
||||
void runBuiltin() {
|
||||
int x = 1 + 2;
|
||||
}
|
||||
|
||||
void runBuiltinVariables(int a, int b) {
|
||||
int x = a + b;
|
||||
int y = a << b;
|
||||
}
|
||||
23
gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h
vendored
Normal file
23
gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#pragma once
|
||||
|
||||
namespace std {
|
||||
class ostream {};
|
||||
extern ostream cout;
|
||||
}
|
||||
|
||||
struct Point {
|
||||
Point operator+(Point rhs) const {
|
||||
return rhs;
|
||||
}
|
||||
|
||||
Point& operator+=(Point rhs) {
|
||||
return *this;
|
||||
}
|
||||
};
|
||||
|
||||
std::ostream& operator<<(std::ostream& os, const Point& p);
|
||||
|
||||
void runMember(Point a, Point b);
|
||||
void runFree(Point p);
|
||||
void runBuiltin();
|
||||
void runBuiltinVariables(int a, int b);
|
||||
133
gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts
Normal file
133
gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* Integration test: WAL auto-checkpoint rename failure (#1741 / #1772).
|
||||
*
|
||||
* Drives the real `analyzeCommand` against a real LadybugDB instance and
|
||||
* provokes a genuine Ladybug-engine `IO exception: Error renaming file
|
||||
* <db>.wal to <db>.wal.checkpoint` by pre-planting a *directory* at the
|
||||
* `<db>.wal.checkpoint` rename target. `fs.rename` (which Ladybug's native
|
||||
* `LocalFileSystem` ultimately invokes) cannot overwrite a non-empty
|
||||
* directory with a file on either POSIX or Windows, and Ladybug's
|
||||
* `doInitLbug` orphan-cleanup uses `fs.unlink` which fails on a directory
|
||||
* — so the blocker survives initialization and the next auto-checkpoint
|
||||
* fires the natural rename failure that motivated PR #1772.
|
||||
*
|
||||
* No test-only hooks, no env-var fault toggles in production code: we use
|
||||
* the same `GITNEXUS_WAL_CHECKPOINT_THRESHOLD=1` knob that real users have
|
||||
* available to force checkpointing on every write, then arrange a real
|
||||
* filesystem state that makes the rename impossible.
|
||||
*
|
||||
* Verifies that:
|
||||
* 1. The CLI exits non-zero.
|
||||
* 2. stderr contains the actionable recovery hint pointing at
|
||||
* `--wal-checkpoint-threshold 67108864` (the
|
||||
* `RECOMMENDED_WAL_CHECKPOINT_THRESHOLD` constant in `analyze.ts`).
|
||||
* 3. The recovery message references the
|
||||
* `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` env var as a parallel route.
|
||||
*
|
||||
* Empirically confirmed portable on Windows; the same mechanism is
|
||||
* expected to work on POSIX (`rename(2)` fails with `EISDIR`/`ENOTEMPTY`
|
||||
* when the target is a non-empty directory). If a future Ladybug release
|
||||
* changes the rename ordering, the loose match on the recovery hint
|
||||
* (rather than the exact engine error wording) keeps this test stable.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { createRequire } from 'module';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import { cleanupTempDirSync } from '../helpers/test-db.js';
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(testDir, '../..');
|
||||
const cliEntry = path.join(repoRoot, 'src/cli/index.ts');
|
||||
const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo');
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const tsxPkgDir = path.dirname(_require.resolve('tsx/package.json'));
|
||||
const tsxImportUrl = pathToFileURL(path.join(tsxPkgDir, 'dist', 'loader.mjs')).href;
|
||||
|
||||
let tmpParent: string;
|
||||
let suiteGitnexusHome: string;
|
||||
let repoPath: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-wal-checkpoint-e2e-'));
|
||||
suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-wal-checkpoint-home-'));
|
||||
repoPath = path.join(tmpParent, 'mini-repo');
|
||||
fs.cpSync(FIXTURE_SRC, repoPath, { recursive: true });
|
||||
|
||||
spawnSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
|
||||
spawnSync('git', ['add', '-A'], { cwd: repoPath, stdio: 'pipe' });
|
||||
spawnSync('git', ['commit', '-m', 'initial commit'], {
|
||||
cwd: repoPath,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'test',
|
||||
GIT_AUTHOR_EMAIL: 'test@test',
|
||||
GIT_COMMITTER_NAME: 'test',
|
||||
GIT_COMMITTER_EMAIL: 'test@test',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (tmpParent) cleanupTempDirSync(tmpParent);
|
||||
if (suiteGitnexusHome) cleanupTempDirSync(suiteGitnexusHome);
|
||||
});
|
||||
|
||||
describe('analyze WAL auto-checkpoint rename failure (real lbug, no mocks)', () => {
|
||||
it('surfaces the --wal-checkpoint-threshold recovery hint when the rename target is blocked', () => {
|
||||
// Plant a non-empty directory at the path Ladybug's auto-checkpoint
|
||||
// will try to rename `<db>.wal` over. `fs.rename` cannot overwrite a
|
||||
// non-empty directory, and the adapter's orphan-sidecar cleanup uses
|
||||
// `fs.unlink` (which fails on directories) — so the blocker persists
|
||||
// through `doInitLbug` and trips the very first auto-checkpoint that
|
||||
// a `GITNEXUS_WAL_CHECKPOINT_THRESHOLD=1` setting forces.
|
||||
const storageDir = path.join(repoPath, '.gitnexus');
|
||||
fs.mkdirSync(storageDir, { recursive: true });
|
||||
const blockerDir = path.join(storageDir, 'lbug.wal.checkpoint');
|
||||
fs.mkdirSync(blockerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(blockerDir, 'blocker'), 'cannot-be-renamed-over');
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--import', tsxImportUrl, cliEntry, 'analyze', '--skip-skills'],
|
||||
{
|
||||
cwd: repoPath,
|
||||
encoding: 'utf8',
|
||||
// Generous timeout: the test does real CSV/COPY work before the
|
||||
// first failing checkpoint, and CI runners are slow.
|
||||
timeout: process.env.CI ? 120_000 : 60_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GITNEXUS_HOME: suiteGitnexusHome,
|
||||
// Skip ensureHeap re-exec (which drops the tsx loader).
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
// Tiny threshold forces auto-checkpoint on every write so the
|
||||
// first write into the WAL trips the planted rename blocker.
|
||||
GITNEXUS_WAL_CHECKPOINT_THRESHOLD: '1',
|
||||
CI: '1',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const combined = `${result.stderr}\n${result.stdout}`;
|
||||
|
||||
// The CLI must exit non-zero. status === null means the timeout fired
|
||||
// without a clean exit — also a failure for this assertion.
|
||||
expect(result.status === null ? 'timeout' : result.status).not.toBe(0);
|
||||
|
||||
// Recovery hint must reference the CLI flag and the recommended
|
||||
// 64 MiB threshold (67_108_864 bytes). Both come from the
|
||||
// RECOMMENDED_WAL_CHECKPOINT_THRESHOLD constant in analyze.ts; keep
|
||||
// those values in sync with this assertion if the constant changes.
|
||||
expect(combined).toContain('gitnexus analyze --wal-checkpoint-threshold');
|
||||
expect(combined).toContain('67108864');
|
||||
// The env-var route should be advertised alongside the flag.
|
||||
expect(combined).toContain('GITNEXUS_WAL_CHECKPOINT_THRESHOLD');
|
||||
}, 180_000);
|
||||
});
|
||||
|
|
@ -17,6 +17,65 @@ import {
|
|||
|
||||
const it = createResolverParityIt('cpp');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C++ overloaded operators (#1636)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C++ overloaded operator call resolution (#1636)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-overloaded-operators'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('resolves member operator+ for user-defined operands', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'runMember' && c.target === 'operator+',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.targetLabel).toBe('Method');
|
||||
expect(calls[0]?.targetFilePath).toBe('lib.h');
|
||||
});
|
||||
|
||||
it('resolves free operator<< for user-defined operands', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'runFree' && c.target === 'operator<<',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.targetLabel).toBe('Function');
|
||||
expect(calls[0]?.targetFilePath).toBe('lib.cpp');
|
||||
});
|
||||
|
||||
it('does not synthesize an operator edge for built-in int + int', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'runBuiltin' && c.target.startsWith('operator'),
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not synthesize operator edges for built-in int variables', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.source === 'runBuiltinVariables' && c.target.startsWith('operator'),
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('classifies reference-return inline operators as methods', () => {
|
||||
const methods = getNodesByLabelFull(result, 'Method').filter((m) => m.name === 'operator+=');
|
||||
const functions = getNodesByLabelFull(result, 'Function').filter(
|
||||
(f) => f.name === 'operator+=',
|
||||
);
|
||||
|
||||
expect(methods).toHaveLength(1);
|
||||
expect(methods[0]?.properties.filePath).toBe('lib.h');
|
||||
expect(functions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heritage: diamond inheritance + include-based imports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -626,21 +626,28 @@ Indexed as **Idem** (1 symbols, 2 relationships, 3 execution flows). Custom.
|
|||
}
|
||||
});
|
||||
|
||||
it('noStats + keep marker: stats line update is NOT corrupted by Always-Do tuple text (#1508 review F3)', async () => {
|
||||
// Regression guard: with the old fallback regex `\(([^)]+)\)`, when
|
||||
// noStats=true suppressed the canonical stats line from generated
|
||||
// content, the fallback matched the FIRST parenthesized text in the
|
||||
// template, which was `({target: "symbolName", direction: "upstream"})`
|
||||
// from the Always Do bullet — silently writing that as the stats line.
|
||||
it('noStats + keep marker: stats line drops the volatile counts (#1706)', async () => {
|
||||
// #1706: --no-stats must win in the keep-marker path too. A lean block
|
||||
// committed to git would otherwise churn the parenthetical counts on
|
||||
// every analyze, producing no-value merge conflicts between branches.
|
||||
// The parenthetical is stripped; the project name still refreshes.
|
||||
//
|
||||
// Also a regression guard (#1508 review F3): the rewritten stats line
|
||||
// MUST NOT pick up the `({target: "symbolName", direction: "upstream"})`
|
||||
// tuple from the Always Do bullet.
|
||||
//
|
||||
// Asserted for BOTH AGENTS.md and CLAUDE.md: generateAIContextFiles
|
||||
// updates them through separate upsertGitNexusSection call sites, so the
|
||||
// parity check guards against a future asymmetry between the two.
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-nostats-'));
|
||||
try {
|
||||
const claudePath = path.join(dir, 'CLAUDE.md');
|
||||
const seed = `<!-- gitnexus:start -->
|
||||
<!-- gitnexus:keep -->
|
||||
Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Custom.
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(claudePath, seed, 'utf-8');
|
||||
await fs.writeFile(path.join(dir, 'CLAUDE.md'), seed, 'utf-8');
|
||||
await fs.writeFile(path.join(dir, 'AGENTS.md'), seed, 'utf-8');
|
||||
|
||||
const stats = { nodes: 42, edges: 84, processes: 3 };
|
||||
await generateAIContextFiles(
|
||||
|
|
@ -649,16 +656,84 @@ Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Cust
|
|||
'NoStatsTest',
|
||||
stats,
|
||||
undefined,
|
||||
{ noStats: true },
|
||||
{
|
||||
noStats: true,
|
||||
},
|
||||
);
|
||||
|
||||
for (const f of ['CLAUDE.md', 'AGENTS.md']) {
|
||||
const result = await fs.readFile(path.join(dir, f), 'utf-8');
|
||||
// Stats line MUST NOT have been corrupted with the Always-Do tuple text
|
||||
expect(result, f).not.toMatch(/\(\{target:/);
|
||||
expect(result, f).not.toMatch(/direction:\s*"upstream"/);
|
||||
// The volatile counts MUST be gone — no parenthetical, no leaked numbers.
|
||||
expect(result, f).not.toContain('42 symbols');
|
||||
expect(result, f).not.toMatch(/\(\d+\s+symbols,/);
|
||||
// The count-free stats line is still present and the name refreshed.
|
||||
expect(result, f).toContain('Indexed as **NoStatsTest**');
|
||||
// Custom prose still preserved
|
||||
expect(result, f).toContain('Custom.');
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('noStats + keep marker: project name still refreshes when counts are stripped (#1706)', async () => {
|
||||
// Stripping the parenthetical must not freeze the whole line: a repo
|
||||
// rename should still propagate into the keep-section stats line, even
|
||||
// when the existing line has no parenthetical to match against.
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-nostats-rename-'));
|
||||
try {
|
||||
const claudePath = path.join(dir, 'CLAUDE.md');
|
||||
// Seed already in the count-free shape a prior --no-stats run produces.
|
||||
const seed = `<!-- gitnexus:start -->
|
||||
<!-- gitnexus:keep -->
|
||||
Indexed as **OldName**. Custom.
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(claudePath, seed, 'utf-8');
|
||||
|
||||
const stats = { nodes: 7, edges: 8, processes: 9 };
|
||||
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'NewName', stats, undefined, {
|
||||
noStats: true,
|
||||
});
|
||||
|
||||
const result = await fs.readFile(claudePath, 'utf-8');
|
||||
// Stats line MUST NOT have been corrupted with the Always-Do tuple text
|
||||
expect(result).not.toMatch(/\(\{target:/);
|
||||
expect(result).not.toMatch(/direction:\s*"upstream"/);
|
||||
// Stats line should reflect a sensible numeric update (passed stats)
|
||||
expect(result).toContain('42 symbols');
|
||||
// Custom prose still preserved
|
||||
expect(result).toContain('Indexed as **NewName**');
|
||||
expect(result).not.toContain('OldName');
|
||||
expect(result).not.toMatch(/\(\d+\s+symbols,/);
|
||||
expect(result).toContain('Custom.');
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('noStats + keep marker: counts return when --no-stats is dropped after a count-free run (#1706)', async () => {
|
||||
// --no-stats must not be sticky: once a prior run has left the
|
||||
// keep-section line count-free, a later run WITHOUT --no-stats must
|
||||
// restore the parenthetical. The optional parenthetical in statsPattern
|
||||
// is what keeps the count-free line re-matchable.
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-counts-return-'));
|
||||
try {
|
||||
const claudePath = path.join(dir, 'CLAUDE.md');
|
||||
// Seed already in the count-free shape a prior --no-stats run produces.
|
||||
const seed = `<!-- gitnexus:start -->
|
||||
<!-- gitnexus:keep -->
|
||||
Indexed as **FreezeTest**. Custom.
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(claudePath, seed, 'utf-8');
|
||||
|
||||
const stats = { nodes: 11, edges: 22, processes: 3 };
|
||||
// No noStats option — the counts must come back.
|
||||
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'FreezeTest', stats);
|
||||
|
||||
const result = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(result).toContain(
|
||||
'Indexed as **FreezeTest** (11 symbols, 22 relationships, 3 execution flows)',
|
||||
);
|
||||
// Suffix prose after the stats line is preserved.
|
||||
expect(result).toContain('Custom.');
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
|
|
|
|||
99
gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts
Normal file
99
gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const runFullAnalysisMock = vi.fn();
|
||||
|
||||
vi.mock('../../src/core/run-analyze.js', () => ({
|
||||
runFullAnalysis: runFullAnalysisMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })),
|
||||
getGlobalRegistryPath: vi.fn(() => 'registry.json'),
|
||||
RegistryNameCollisionError: class RegistryNameCollisionError extends Error {},
|
||||
AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {},
|
||||
assertAnalysisFinalized: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/git.js', () => ({
|
||||
getGitRoot: vi.fn(() => '/repo'),
|
||||
hasGitDir: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
|
||||
getMaxFileSizeBannerMessage: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe('analyzeCommand --wal-checkpoint-threshold parsing', () => {
|
||||
const ORIGINAL_NODE_OPTIONS = process.env.NODE_OPTIONS;
|
||||
const ORIGINAL_THRESHOLD = process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
runFullAnalysisMock.mockReset();
|
||||
process.exitCode = undefined;
|
||||
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_NODE_OPTIONS === undefined) {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
} else {
|
||||
process.env.NODE_OPTIONS = ORIGINAL_NODE_OPTIONS;
|
||||
}
|
||||
if (ORIGINAL_THRESHOLD === undefined) {
|
||||
delete process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD;
|
||||
} else {
|
||||
process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = ORIGINAL_THRESHOLD;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['maybe', '-2', '1.5', ''])(
|
||||
'rejects invalid --wal-checkpoint-threshold value %s before analysis starts',
|
||||
async (walCheckpointThreshold) => {
|
||||
const { _captureLogger } = await import('../../src/core/logger.js');
|
||||
const cap = _captureLogger();
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { walCheckpointThreshold });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(runFullAnalysisMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
cap
|
||||
.records()
|
||||
.some((r) => r.msg === ' --wal-checkpoint-threshold must be an integer >= -1.\n'),
|
||||
).toBe(true);
|
||||
cap.restore();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['-1', '-1'],
|
||||
['0', '0'],
|
||||
['1024', '1024'],
|
||||
])(
|
||||
'sets GITNEXUS_WAL_CHECKPOINT_THRESHOLD=%s during runFullAnalysis and restores afterwards',
|
||||
async (cliValue, expectedEnv) => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
let envAtCallTime: string | undefined;
|
||||
runFullAnalysisMock.mockImplementation(async () => {
|
||||
envAtCallTime = process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD;
|
||||
return {
|
||||
repoName: 'repo',
|
||||
repoPath: '/repo',
|
||||
stats: {},
|
||||
alreadyUpToDate: true,
|
||||
};
|
||||
});
|
||||
|
||||
await analyzeCommand(undefined, { walCheckpointThreshold: cliValue });
|
||||
|
||||
expect(envAtCallTime).toBe(expectedEnv);
|
||||
expect(process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD).toBe(ORIGINAL_THRESHOLD);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -134,4 +134,82 @@ describe('analyzeCommand WAL corruption error handling', () => {
|
|||
|
||||
cap.restore();
|
||||
});
|
||||
|
||||
it('recommends --wal-checkpoint-threshold on Ladybug checkpoint I/O failures', async () => {
|
||||
runFullAnalysisMock.mockRejectedValue(
|
||||
new Error(
|
||||
'Runtime exception: IO exception: Error renaming file /repo/.gitnexus/lbug.wal to /repo/.gitnexus/lbug.wal.checkpoint. ErrorMessage: Permission denied',
|
||||
),
|
||||
);
|
||||
|
||||
const { _captureLogger } = await import('../../src/core/logger.js');
|
||||
const cap = _captureLogger();
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, {});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
const records = cap.records();
|
||||
expect(
|
||||
records.some(
|
||||
(r) =>
|
||||
typeof r.msg === 'string' &&
|
||||
r.msg.includes('gitnexus analyze --wal-checkpoint-threshold'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
cap.restore();
|
||||
});
|
||||
|
||||
it('also recommends threshold on .wal.checkpoint remove failures', async () => {
|
||||
runFullAnalysisMock.mockRejectedValue(
|
||||
new Error(
|
||||
'Runtime exception: IO exception: Error removing directory or file /repo/.gitnexus/lbug.wal.checkpoint. Error Message: Permission denied',
|
||||
),
|
||||
);
|
||||
|
||||
const { _captureLogger } = await import('../../src/core/logger.js');
|
||||
const cap = _captureLogger();
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, {});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
const records = cap.records();
|
||||
expect(
|
||||
records.some(
|
||||
(r) =>
|
||||
typeof r.msg === 'string' &&
|
||||
r.msg.includes('gitnexus analyze --wal-checkpoint-threshold'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
cap.restore();
|
||||
});
|
||||
|
||||
it('does not recommend threshold for non-checkpoint IO exceptions', async () => {
|
||||
runFullAnalysisMock.mockRejectedValue(
|
||||
new Error(
|
||||
'Runtime exception: IO exception: Error renaming file /repo/.gitnexus/data.tmp to /repo/.gitnexus/data.tmp.bak. ErrorMessage: Permission denied',
|
||||
),
|
||||
);
|
||||
|
||||
const { _captureLogger } = await import('../../src/core/logger.js');
|
||||
const cap = _captureLogger();
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, {});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
const records = cap.records();
|
||||
expect(
|
||||
records.some(
|
||||
(r) =>
|
||||
typeof r.msg === 'string' &&
|
||||
r.msg.includes('gitnexus analyze --wal-checkpoint-threshold'),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
cap.restore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
* the dispatch and error handling logic in isolation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// We need to mock the LadybugDB adapter and repo-manager BEFORE importing LocalBackend.
|
||||
// local-backend.ts imports from core/lbug/pool-adapter.js; the mcp/core/lbug-adapter.js
|
||||
|
|
@ -37,11 +40,15 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
|
|||
return { ...actual, ...lbugMocks };
|
||||
});
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
|
||||
return {
|
||||
...actual,
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
// `core/git-staleness` is also imported by `local-backend.ts` (for
|
||||
// `checkStaleness` and `checkCwdMatch`). Stub it out here so unit
|
||||
|
|
@ -52,6 +59,14 @@ vi.mock('../../src/core/git-staleness.js', () => ({
|
|||
checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/git.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/storage/git.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getGitRoot: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/core/platform/capabilities.js')>();
|
||||
return {
|
||||
|
|
@ -70,8 +85,9 @@ vi.mock('../../src/mcp/core/embedder.js', () => ({
|
|||
getEmbeddingDims: vi.fn().mockReturnValue(384),
|
||||
}));
|
||||
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { LocalBackend, REPO_ID_HASH_LENGTH } from '../../src/mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos, cleanupOldKuzuFiles } from '../../src/storage/repo-manager.js';
|
||||
import { getGitRoot } from '../../src/storage/git.js';
|
||||
import { _captureLogger } from '../../src/core/logger.js';
|
||||
import {
|
||||
initLbug,
|
||||
|
|
@ -112,6 +128,56 @@ function setupNoRepos() {
|
|||
(listRegisteredRepos as any).mockResolvedValue([]);
|
||||
}
|
||||
|
||||
const duplicateFixtureDirs: string[] = [];
|
||||
|
||||
function makeDuplicateNameFixture() {
|
||||
const mainDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-shared-main-'));
|
||||
const wtDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-shared-wt-'));
|
||||
duplicateFixtureDirs.push(mainDir, wtDir);
|
||||
for (const dir of [mainDir, wtDir]) {
|
||||
const storagePath = path.join(dir, '.gitnexus');
|
||||
mkdirSync(path.join(storagePath, 'lbug'), { recursive: true });
|
||||
writeFileSync(path.join(storagePath, 'meta.json'), '{}');
|
||||
}
|
||||
return {
|
||||
mainDir,
|
||||
wtDir,
|
||||
entries: [
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'shared',
|
||||
path: mainDir,
|
||||
storagePath: path.join(mainDir, '.gitnexus'),
|
||||
},
|
||||
{
|
||||
...MOCK_REPO_ENTRY,
|
||||
name: 'shared',
|
||||
path: wtDir,
|
||||
storagePath: path.join(wtDir, '.gitnexus'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeSharedPrefixFixture(nameA: string, nameB: string) {
|
||||
const dirA = mkdtempSync(path.join(os.tmpdir(), `gnx-${nameA}-`));
|
||||
const dirB = mkdtempSync(path.join(os.tmpdir(), `gnx-${nameB}-`));
|
||||
duplicateFixtureDirs.push(dirA, dirB);
|
||||
for (const dir of [dirA, dirB]) {
|
||||
const storagePath = path.join(dir, '.gitnexus');
|
||||
mkdirSync(path.join(storagePath, 'lbug'), { recursive: true });
|
||||
writeFileSync(path.join(storagePath, 'meta.json'), '{}');
|
||||
}
|
||||
return {
|
||||
dirA,
|
||||
dirB,
|
||||
entries: [
|
||||
{ ...MOCK_REPO_ENTRY, name: nameA, path: dirA, storagePath: path.join(dirA, '.gitnexus') },
|
||||
{ ...MOCK_REPO_ENTRY, name: nameB, path: dirB, storagePath: path.join(dirB, '.gitnexus') },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── LocalBackend lifecycle ──────────────────────────────────────────
|
||||
|
||||
describe('LocalBackend.init', () => {
|
||||
|
|
@ -783,9 +849,16 @@ describe('LocalBackend.resolveRepo', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
(getGitRoot as any).mockReturnValue(null);
|
||||
backend = new LocalBackend();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of duplicateFixtureDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves single repo without param', async () => {
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
|
|
@ -829,6 +902,95 @@ describe('LocalBackend.resolveRepo', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('prefers duplicate-name repo matching process.cwd() git root (#1658)', async () => {
|
||||
const { wtDir, entries } = makeDuplicateNameFixture();
|
||||
(listRegisteredRepos as any).mockResolvedValue(entries);
|
||||
(getGitRoot as any).mockReturnValue(wtDir);
|
||||
await backend.init();
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
await backend.callTool('query', { query: 'test', repo: 'shared' });
|
||||
const resolved = await backend.resolveRepo('shared');
|
||||
expect(resolved.repoPath).toBe(wtDir);
|
||||
});
|
||||
|
||||
it('throws RegistryAmbiguousTargetError when duplicate name cannot be disambiguated (#1658)', async () => {
|
||||
const { entries } = makeDuplicateNameFixture();
|
||||
(listRegisteredRepos as any).mockResolvedValue(entries);
|
||||
(getGitRoot as any).mockReturnValue(null);
|
||||
await backend.init();
|
||||
await expect(backend.resolveRepo('shared')).rejects.toThrow(/Multiple registered repos match/);
|
||||
await expect(backend.resolveRepo('shared')).rejects.toThrow(/absolute path/i);
|
||||
});
|
||||
|
||||
it('resolves duplicate-name repos by absolute path before name (#1658)', async () => {
|
||||
const { mainDir, wtDir, entries } = makeDuplicateNameFixture();
|
||||
(listRegisteredRepos as any).mockResolvedValue(entries);
|
||||
(getGitRoot as any).mockReturnValue(mainDir);
|
||||
await backend.init();
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
const resolved = await backend.resolveRepo(wtDir);
|
||||
expect(resolved.repoPath).toBe(wtDir);
|
||||
});
|
||||
|
||||
it('does not treat a bare duplicate alias as a relative path (#1658)', async () => {
|
||||
const { entries } = makeDuplicateNameFixture();
|
||||
(listRegisteredRepos as any).mockResolvedValue(entries);
|
||||
(getGitRoot as any).mockReturnValue(null);
|
||||
await backend.init();
|
||||
await expect(backend.resolveRepo('shared')).rejects.toThrow(/Multiple registered repos match/);
|
||||
});
|
||||
|
||||
it('refreshes registry after ambiguity when duplicates are removed (#1658)', async () => {
|
||||
const { mainDir, entries } = makeDuplicateNameFixture();
|
||||
const singleEntry = [entries[0]];
|
||||
(listRegisteredRepos as any).mockResolvedValueOnce(entries).mockResolvedValueOnce(singleEntry);
|
||||
(getGitRoot as any).mockReturnValue(null);
|
||||
await backend.init();
|
||||
const resolved = await backend.resolveRepo('shared');
|
||||
expect(resolved.repoPath).toBe(mainDir);
|
||||
});
|
||||
|
||||
it('detect_changes surfaces RegistryAmbiguousTargetError on duplicate repo name (#1658)', async () => {
|
||||
const { entries } = makeDuplicateNameFixture();
|
||||
(listRegisteredRepos as any).mockResolvedValue(entries);
|
||||
(getGitRoot as any).mockReturnValue(null);
|
||||
await backend.init();
|
||||
await expect(
|
||||
backend.callTool('detect_changes', { scope: 'unstaged', repo: 'shared' }),
|
||||
).rejects.toThrow(/Multiple registered repos match/);
|
||||
});
|
||||
|
||||
it('resolves second duplicate-name repo by its stable hashed id (#1658)', async () => {
|
||||
const { wtDir, entries } = makeDuplicateNameFixture();
|
||||
(listRegisteredRepos as any).mockResolvedValue(entries);
|
||||
// Couples this test to repoId's suffix formula on purpose — if repoId changes
|
||||
// its suffix, this assertion should fail and force a re-review of the hashed-id
|
||||
// resolution tier. Mirrors LocalBackend.repoId: base64url(repoPath) sliced to
|
||||
// REPO_ID_HASH_LENGTH and lowercased so it survives the paramLower lookup in
|
||||
// resolveRepoFromCache.
|
||||
const wtId = `shared-${Buffer.from(wtDir)
|
||||
.toString('base64url')
|
||||
.slice(0, REPO_ID_HASH_LENGTH)
|
||||
.toLowerCase()}`;
|
||||
await backend.init();
|
||||
const resolved = await backend.resolveRepo(wtId);
|
||||
expect(resolved.repoPath).toBe(wtDir);
|
||||
});
|
||||
|
||||
it('does not silently return first partial match for ambiguous prefix (#1658)', async () => {
|
||||
const { dirA, entries } = makeSharedPrefixFixture('project-a', 'project-b');
|
||||
(listRegisteredRepos as any).mockResolvedValue(entries);
|
||||
(getGitRoot as any).mockReturnValue(null);
|
||||
await backend.init();
|
||||
|
||||
await expect(backend.resolveRepo('project')).rejects.toThrow(/Repository "project" not found/);
|
||||
|
||||
// Sanity: exact names still resolve unambiguously against the same fixture.
|
||||
const exact = await backend.resolveRepo('project-a');
|
||||
expect(exact.name).toBe('project-a');
|
||||
expect(exact.repoPath).toBe(dirA);
|
||||
});
|
||||
|
||||
it('resolves repo case-insensitively', async () => {
|
||||
setupSingleRepo();
|
||||
await backend.init();
|
||||
|
|
|
|||
230
gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts
Normal file
230
gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { _captureLogger } from '../../src/core/logger.js';
|
||||
import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js';
|
||||
import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js';
|
||||
import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import {
|
||||
getDeferredProfileDroppedCount,
|
||||
resetDeferredProfileDroppedCount,
|
||||
} from '../../src/core/ingestion/utils/deferred-resolution-profile.js';
|
||||
import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js';
|
||||
import type { ExtractedCall } from '../../src/core/ingestion/workers/parse-worker.js';
|
||||
|
||||
describe('deferred-resolution-profile wiring', () => {
|
||||
let cap: ReturnType<typeof _captureLogger>;
|
||||
let prevProfileDeferred: string | undefined;
|
||||
let prevVerbose: string | undefined;
|
||||
let prevRegistryTypeScript: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
cap = _captureLogger();
|
||||
prevProfileDeferred = process.env.GITNEXUS_PROFILE_DEFERRED;
|
||||
prevVerbose = process.env.GITNEXUS_VERBOSE;
|
||||
prevRegistryTypeScript = process.env.REGISTRY_PRIMARY_TYPESCRIPT;
|
||||
process.env.GITNEXUS_PROFILE_DEFERRED = '1';
|
||||
delete process.env.GITNEXUS_VERBOSE;
|
||||
process.env.REGISTRY_PRIMARY_TYPESCRIPT = 'false';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cap.restore();
|
||||
if (prevProfileDeferred === undefined) delete process.env.GITNEXUS_PROFILE_DEFERRED;
|
||||
else process.env.GITNEXUS_PROFILE_DEFERRED = prevProfileDeferred;
|
||||
if (prevVerbose === undefined) delete process.env.GITNEXUS_VERBOSE;
|
||||
else process.env.GITNEXUS_VERBOSE = prevVerbose;
|
||||
if (prevRegistryTypeScript === undefined) delete process.env.REGISTRY_PRIMARY_TYPESCRIPT;
|
||||
else process.env.REGISTRY_PRIMARY_TYPESCRIPT = prevRegistryTypeScript;
|
||||
resetDeferredProfileDroppedCount();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const deferredMsgs = (): string[] =>
|
||||
cap
|
||||
.records()
|
||||
.map((r) => String(r.msg ?? ''))
|
||||
.filter((m) => m.includes('[deferred-profile]'));
|
||||
|
||||
it('buildHeritageMap emits profile stats when GITNEXUS_PROFILE_DEFERRED=1', () => {
|
||||
const ctx = createResolutionContext();
|
||||
ctx.model.symbols.add('src/a.java', 'Foo', 'class:a:Foo', 'Class');
|
||||
ctx.model.symbols.add('src/b.java', 'Foo', 'class:b:Foo', 'Class');
|
||||
ctx.model.symbols.add('src/c.java', 'Bar', 'class:c:Bar', 'Class');
|
||||
ctx.model.symbols.add('src/d.java', 'Bar', 'class:d:Bar', 'Class');
|
||||
|
||||
const heritage: ExtractedHeritage[] = [
|
||||
{ filePath: 'src/a.java', className: 'Foo', parentName: 'Bar', kind: 'extends' },
|
||||
];
|
||||
|
||||
buildHeritageMap(heritage, ctx);
|
||||
|
||||
expect(
|
||||
deferredMsgs().some(
|
||||
(m) =>
|
||||
m.includes('buildHeritageMap:') &&
|
||||
m.includes('child×parent lookup product >1') &&
|
||||
m.includes('max product') &&
|
||||
m.includes('0 unresolved child lookups') &&
|
||||
m.includes('0 unresolved parent lookups'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('buildHeritageMap counts unresolved parent lookups (U7, JVM pathological case)', () => {
|
||||
const ctx = createResolutionContext();
|
||||
// Many same-named children all resolved.
|
||||
ctx.model.symbols.add('src/a.java', 'Foo', 'class:a:Foo', 'Class');
|
||||
ctx.model.symbols.add('src/b.java', 'Foo', 'class:b:Foo', 'Class');
|
||||
// Parent (e.g., external library) is NOT in the symbol index — lookup
|
||||
// returns []. The legacy counter would silently drop this record from
|
||||
// the metric. With U7, it shows up as an unresolved-parent lookup.
|
||||
|
||||
const heritage: ExtractedHeritage[] = [
|
||||
{ filePath: 'src/a.java', className: 'Foo', parentName: 'ExternalBase', kind: 'extends' },
|
||||
];
|
||||
|
||||
buildHeritageMap(heritage, ctx);
|
||||
|
||||
expect(deferredMsgs().some((m) => m.includes('1 unresolved parent lookups'))).toBe(true);
|
||||
expect(deferredMsgs().some((m) => m.includes('0 unresolved child lookups'))).toBe(true);
|
||||
});
|
||||
|
||||
it('buildHeritageMap counts unresolved child lookups (U7, inverse case)', () => {
|
||||
const ctx = createResolutionContext();
|
||||
// Parent resolved, child name not in symbol index.
|
||||
ctx.model.symbols.add('src/c.java', 'Bar', 'class:c:Bar', 'Class');
|
||||
|
||||
const heritage: ExtractedHeritage[] = [
|
||||
{ filePath: 'src/x.java', className: 'UnknownChild', parentName: 'Bar', kind: 'extends' },
|
||||
];
|
||||
|
||||
buildHeritageMap(heritage, ctx);
|
||||
|
||||
expect(deferredMsgs().some((m) => m.includes('1 unresolved child lookups'))).toBe(true);
|
||||
expect(deferredMsgs().some((m) => m.includes('0 unresolved parent lookups'))).toBe(true);
|
||||
});
|
||||
|
||||
it('processCallsFromExtracted emits done summary with skipped registry-primary count', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const ctx = createResolutionContext();
|
||||
ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function');
|
||||
|
||||
const calls: ExtractedCall[] = [
|
||||
{
|
||||
filePath: 'src/index.ts',
|
||||
calledName: 'helper',
|
||||
sourceId: 'Function:src/index.ts:main',
|
||||
},
|
||||
{
|
||||
filePath: 'src/main.py',
|
||||
calledName: 'run',
|
||||
sourceId: 'Function:src/main.py:main',
|
||||
},
|
||||
];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, ctx);
|
||||
|
||||
expect(
|
||||
deferredMsgs().some(
|
||||
(m) =>
|
||||
m.includes('processCallsFromExtracted done:') &&
|
||||
m.includes('skipped registry-primary files=1'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('processCallsFromExtracted logs the first non-skipped file as 1/1 even when a registry-primary file sorts first', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const ctx = createResolutionContext();
|
||||
ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function');
|
||||
|
||||
// Python sorts before TypeScript in byFile insertion order. Before the
|
||||
// fix for #4 the first per-file log was keyed on filesProcessed===1, which
|
||||
// was consumed by the Python skip and never emitted for the TS file.
|
||||
const calls: ExtractedCall[] = [
|
||||
{ filePath: 'src/early.py', calledName: 'run', sourceId: 'Function:src/early.py:main' },
|
||||
{ filePath: 'src/index.ts', calledName: 'helper', sourceId: 'Function:src/index.ts:main' },
|
||||
];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, ctx);
|
||||
|
||||
expect(deferredMsgs().some((m) => m.includes('calls 1/1 file=src/index.ts'))).toBe(true);
|
||||
expect(deferredMsgs().some((m) => m.includes('skipped registry-primary files=1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('processCallsFromExtracted denominator stays stable across mixed-language interleaving (A1 pre-pass)', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const ctx = createResolutionContext();
|
||||
ctx.model.symbols.add('src/a.ts', 'a', 'Function:src/a.ts:a', 'Function');
|
||||
ctx.model.symbols.add('src/b.ts', 'b', 'Function:src/b.ts:b', 'Function');
|
||||
ctx.model.symbols.add('src/c.ts', 'c', 'Function:src/c.ts:c', 'Function');
|
||||
ctx.model.symbols.add('src/d.ts', 'd', 'Function:src/d.ts:d', 'Function');
|
||||
|
||||
// Alternating TS / PY order: byFile = [ts, py, ts, py, ts, py, ts, py].
|
||||
// Before the U1 pre-pass, the first per-file log carried denominator 8
|
||||
// (totalFiles - 0 skips) and self-corrected only after every skip was
|
||||
// observed. With the pre-pass, the denominator is 4 from the first
|
||||
// emission onward — every entry uses the same resolvedTotal.
|
||||
const calls: ExtractedCall[] = [
|
||||
{ filePath: 'src/a.ts', calledName: 'a', sourceId: 'Function:src/a.ts:f' },
|
||||
{ filePath: 'src/p1.py', calledName: 'a', sourceId: 'Function:src/p1.py:f' },
|
||||
{ filePath: 'src/b.ts', calledName: 'b', sourceId: 'Function:src/b.ts:f' },
|
||||
{ filePath: 'src/p2.py', calledName: 'b', sourceId: 'Function:src/p2.py:f' },
|
||||
{ filePath: 'src/c.ts', calledName: 'c', sourceId: 'Function:src/c.ts:f' },
|
||||
{ filePath: 'src/p3.py', calledName: 'c', sourceId: 'Function:src/p3.py:f' },
|
||||
{ filePath: 'src/d.ts', calledName: 'd', sourceId: 'Function:src/d.ts:f' },
|
||||
{ filePath: 'src/p4.py', calledName: 'd', sourceId: 'Function:src/p4.py:f' },
|
||||
];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, ctx);
|
||||
|
||||
// Every per-file emission carries `/4` (the eventual resolved-file
|
||||
// total), not the in-flight `totalFiles - skippedSoFar`.
|
||||
expect(deferredMsgs().some((m) => m.includes('calls 1/4 file=src/a.ts'))).toBe(true);
|
||||
expect(deferredMsgs().some((m) => /calls \d+\/[^4]/.test(m))).toBe(false);
|
||||
expect(deferredMsgs().some((m) => m.includes('skipped registry-primary files=4'))).toBe(true);
|
||||
});
|
||||
|
||||
it('processCallsFromExtracted resets the dropped-line counter at entry (U4)', async () => {
|
||||
// logger is a Proxy that vi.spyOn can't override; we seed the counter by
|
||||
// directly mutating it via the public reset / observation surface. The
|
||||
// test then verifies processCallsFromExtracted brings the counter back to
|
||||
// zero at the start of its run.
|
||||
resetDeferredProfileDroppedCount();
|
||||
// Force-bump the counter by simulating a dropped line: there's no public
|
||||
// increment, but we can prove the reset happens by setting up a non-zero
|
||||
// counter state via processCallsFromExtracted's own reset path called
|
||||
// twice in a row — both invocations should leave the counter at zero.
|
||||
const graph = createKnowledgeGraph();
|
||||
const ctx = createResolutionContext();
|
||||
ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function');
|
||||
const calls: ExtractedCall[] = [
|
||||
{ filePath: 'src/index.ts', calledName: 'helper', sourceId: 'Function:src/index.ts:main' },
|
||||
];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, ctx);
|
||||
expect(getDeferredProfileDroppedCount()).toBe(0);
|
||||
|
||||
// Second run: counter is still zero (idempotent reset).
|
||||
await processCallsFromExtracted(graph, calls, ctx);
|
||||
expect(getDeferredProfileDroppedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('processCallsFromExtracted does not log per-file progress for registry-primary skips', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const ctx = createResolutionContext();
|
||||
|
||||
const calls: ExtractedCall[] = [
|
||||
{
|
||||
filePath: 'src/only.py',
|
||||
calledName: 'run',
|
||||
sourceId: 'Function:src/only.py:main',
|
||||
},
|
||||
];
|
||||
|
||||
await processCallsFromExtracted(graph, calls, ctx);
|
||||
|
||||
expect(deferredMsgs().some((m) => m.includes('calls 1/1 file=src/only.py'))).toBe(false);
|
||||
expect(deferredMsgs().some((m) => m.includes('skipped registry-primary files=1'))).toBe(true);
|
||||
});
|
||||
});
|
||||
193
gitnexus/test/unit/deferred-resolution-profile.test.ts
Normal file
193
gitnexus/test/unit/deferred-resolution-profile.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
deferredCallFileSlowMs,
|
||||
deferredCallLogEveryN,
|
||||
endTimer,
|
||||
getDeferredProfileDroppedCount,
|
||||
isDeferredResolutionProfileEnabled,
|
||||
logDeferredProfile,
|
||||
profileElapsedMs,
|
||||
profileNow,
|
||||
resetDeferredProfileDroppedCount,
|
||||
startTimer,
|
||||
} from '../../src/core/ingestion/utils/deferred-resolution-profile.js';
|
||||
import { _captureLogger } from '../../src/core/logger.js';
|
||||
|
||||
describe('deferred-resolution-profile', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.GITNEXUS_PROFILE_DEFERRED;
|
||||
delete process.env.GITNEXUS_PROFILE_DEFERRED_SLOW_MS;
|
||||
delete process.env.GITNEXUS_VERBOSE;
|
||||
resetDeferredProfileDroppedCount();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is off by default', () => {
|
||||
expect(isDeferredResolutionProfileEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('enables on GITNEXUS_VERBOSE=1', () => {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
expect(isDeferredResolutionProfileEnabled()).toBe(true);
|
||||
expect(deferredCallLogEveryN()).toBe(10);
|
||||
expect(deferredCallFileSlowMs()).toBe(3000);
|
||||
});
|
||||
|
||||
it('enables on GITNEXUS_PROFILE_DEFERRED=1', () => {
|
||||
process.env.GITNEXUS_PROFILE_DEFERRED = '1';
|
||||
expect(isDeferredResolutionProfileEnabled()).toBe(true);
|
||||
expect(deferredCallLogEveryN()).toBe(100);
|
||||
});
|
||||
|
||||
it('reads slow-file threshold from env', () => {
|
||||
process.env.GITNEXUS_PROFILE_DEFERRED_SLOW_MS = '250';
|
||||
expect(deferredCallFileSlowMs()).toBe(250);
|
||||
});
|
||||
|
||||
describe('logDeferredProfile dropped-line counter (U4)', () => {
|
||||
// Background: `logger` (gitnexus/src/core/logger.ts) is a Proxy with a lazy
|
||||
// `get` trap and no `set` trap, so vi.spyOn on `logger.info` fails with
|
||||
// "property is not defined on the object" — the inner pino method isn't a
|
||||
// stable own-property to wrap. These tests exercise the helper API and the
|
||||
// happy path; the catch arm is pinned by source-shape assertions below.
|
||||
|
||||
it('counter is zero at module entry (after reset in afterEach)', () => {
|
||||
expect(getDeferredProfileDroppedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not increment when logger.info succeeds', () => {
|
||||
const cap = _captureLogger();
|
||||
try {
|
||||
logDeferredProfile('normal message');
|
||||
expect(getDeferredProfileDroppedCount()).toBe(0);
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('multiple successful calls keep the counter at zero', () => {
|
||||
const cap = _captureLogger();
|
||||
try {
|
||||
logDeferredProfile('m1');
|
||||
logDeferredProfile('m2');
|
||||
logDeferredProfile('m3');
|
||||
expect(getDeferredProfileDroppedCount()).toBe(0);
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('resetDeferredProfileDroppedCount returns the counter to zero', () => {
|
||||
// Drive the counter via a stub since we can't spy on the Proxy.
|
||||
// Mutate the counter through the public API: simulate a dropped line
|
||||
// by calling logDeferredProfile inside a forced-throw context.
|
||||
// Without a way to force logger.info to throw, the most we can test
|
||||
// here is that reset() is idempotent on an already-zero counter and
|
||||
// that the getter reads what reset wrote.
|
||||
resetDeferredProfileDroppedCount();
|
||||
expect(getDeferredProfileDroppedCount()).toBe(0);
|
||||
resetDeferredProfileDroppedCount();
|
||||
expect(getDeferredProfileDroppedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('source defines a try/catch around the logger.info call', () => {
|
||||
// Pin the catch arm via source shape — see logger Proxy note above.
|
||||
const fs = require('node:fs') as typeof import('node:fs');
|
||||
const path = require('node:path') as typeof import('node:path');
|
||||
const url = require('node:url') as typeof import('node:url');
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
here,
|
||||
'../../src/core/ingestion/utils/deferred-resolution-profile.ts',
|
||||
);
|
||||
const source = fs.readFileSync(sourcePath, 'utf-8');
|
||||
|
||||
expect(
|
||||
/export const logDeferredProfile[\s\S]*?try \{\s*logger\.info\(`\[deferred-profile\] \$\{message\}`\);\s*\} catch[\s\S]*?droppedLogLines\+\+/.test(
|
||||
source,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endTimer (U3 formatter exception safety)', () => {
|
||||
it('emits the formatter output via [deferred-profile] when start is non-null', () => {
|
||||
const cap = _captureLogger();
|
||||
try {
|
||||
const start = startTimer(true);
|
||||
endTimer(start, (ms) => `stage A: ${ms.toFixed(0)}ms`);
|
||||
const messages = cap.records().map((r) => String(r.msg ?? ''));
|
||||
expect(messages.some((m) => /\[deferred-profile\] stage A: \d+ms/.test(m))).toBe(true);
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op when start is null (profiling disabled), even if formatter would throw', () => {
|
||||
const cap = _captureLogger();
|
||||
try {
|
||||
const formatter = vi.fn(() => {
|
||||
throw new Error('should never run');
|
||||
});
|
||||
endTimer(null, formatter);
|
||||
expect(formatter).not.toHaveBeenCalled();
|
||||
expect(cap.records()).toEqual([]);
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('catches a throwing formatter and emits one formatter-error line', () => {
|
||||
const cap = _captureLogger();
|
||||
try {
|
||||
const start = startTimer(true);
|
||||
expect(() =>
|
||||
endTimer(start, () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
const messages = cap.records().map((r) => String(r.msg ?? ''));
|
||||
const errLines = messages.filter((m) =>
|
||||
m.includes('[deferred-profile] formatter error: boom'),
|
||||
);
|
||||
expect(errLines.length).toBe(1);
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('coerces non-Error throws (string, plain object) via String() in the error message', () => {
|
||||
const cap = _captureLogger();
|
||||
try {
|
||||
const start = startTimer(true);
|
||||
endTimer(start, () => {
|
||||
throw 'plain string';
|
||||
});
|
||||
const messages = cap.records().map((r) => String(r.msg ?? ''));
|
||||
expect(
|
||||
messages.some((m) => m.includes('[deferred-profile] formatter error: plain string')),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('profileElapsedMs converts hrtime deltas to ms with exact arithmetic', () => {
|
||||
const spy = vi.spyOn(process.hrtime, 'bigint');
|
||||
try {
|
||||
spy.mockReturnValueOnce(1_000_000_000n);
|
||||
const start = profileNow();
|
||||
spy.mockReturnValueOnce(1_002_500_000n);
|
||||
expect(profileElapsedMs(start)).toBe(2.5);
|
||||
|
||||
spy.mockReturnValueOnce(5_000_000_000n);
|
||||
const startZero = profileNow();
|
||||
spy.mockReturnValueOnce(5_000_000_000n);
|
||||
expect(profileElapsedMs(startZero)).toBe(0);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
38
gitnexus/test/unit/env.test.ts
Normal file
38
gitnexus/test/unit/env.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { parseTruthyEnv } from '../../src/core/ingestion/utils/env.js';
|
||||
|
||||
describe('parseTruthyEnv', () => {
|
||||
it.each([
|
||||
['1', true],
|
||||
['true', true],
|
||||
['yes', true],
|
||||
['TRUE', true],
|
||||
['Yes', true],
|
||||
['YES', true],
|
||||
[' 1 ', true],
|
||||
[' true ', true],
|
||||
['\tyes\n', true],
|
||||
])('accepts %j as truthy', (raw, expected) => {
|
||||
expect(parseTruthyEnv(raw)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['0', false],
|
||||
['false', false],
|
||||
['no', false],
|
||||
['off', false],
|
||||
['', false],
|
||||
[' ', false],
|
||||
['maybe', false],
|
||||
['2', false],
|
||||
['truthy', false],
|
||||
['1.0', false],
|
||||
['yes please', false],
|
||||
])('rejects %j as falsy', (raw, expected) => {
|
||||
expect(parseTruthyEnv(raw)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(parseTruthyEnv(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -53,9 +53,19 @@ describe('flushWAL / safeClose — consolidation guard (#1376)', () => {
|
|||
expect(closeLbugBlock).not.toMatch(/db\.close\(\)/);
|
||||
});
|
||||
|
||||
it('flushWAL is the only place that issues conn.query(CHECKPOINT)', () => {
|
||||
it('CHECKPOINT is issued only by flushWAL (best-effort) and tryFlushWAL (rethrows for the retry driver)', () => {
|
||||
const matches = adapterSource.match(/conn\.query\('CHECKPOINT'\)/g) ?? [];
|
||||
expect(matches.length).toBe(1);
|
||||
// Two authorized sites: `flushWAL` (swallows errors — used by
|
||||
// `safeClose` and the server's best-effort flush) and `tryFlushWAL`
|
||||
// (rethrows so the manual checkpoint driver in `wal-checkpoint-driver.ts`
|
||||
// can apply its bounded retry). Any third occurrence is a regression —
|
||||
// a CHECKPOINT outside these two helpers will be invisible to the
|
||||
// retry/error policy.
|
||||
expect(matches.length).toBe(2);
|
||||
});
|
||||
|
||||
it('exports tryFlushWAL (CHECKPOINT-with-rethrow for the manual retry driver)', () => {
|
||||
expect(adapterSource).toMatch(/export const tryFlushWAL/);
|
||||
});
|
||||
|
||||
it('flushWAL drains and closes the CHECKPOINT result before returning', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createLbugDatabase, isWalCorruptionError } from '../../src/core/lbug/lbug-config.js';
|
||||
import {
|
||||
createLbugDatabase,
|
||||
isLbugCheckpointIoError,
|
||||
isWalCorruptionError,
|
||||
} from '../../src/core/lbug/lbug-config.js';
|
||||
import { _captureLogger } from '../../src/core/logger.js';
|
||||
|
||||
const DEFAULT_THRESHOLD = 64 * 1024 * 1024;
|
||||
|
||||
describe('isWalCorruptionError', () => {
|
||||
it.each([
|
||||
|
|
@ -35,6 +42,101 @@ describe('isWalCorruptionError', () => {
|
|||
});
|
||||
|
||||
describe('createLbugDatabase WAL replay option', () => {
|
||||
it('enables auto-checkpoint by default and uses default threshold (64 MiB)', () => {
|
||||
const Database = vi.fn(function (this: any) {});
|
||||
const lbugModule = { Database } as any;
|
||||
|
||||
createLbugDatabase(lbugModule, '/tmp/lbug-default');
|
||||
|
||||
expect(Database).toHaveBeenCalledWith(
|
||||
'/tmp/lbug-default',
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
expect.any(Number),
|
||||
true,
|
||||
DEFAULT_THRESHOLD,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['0', 0],
|
||||
['1024', 1024],
|
||||
['-1', -1],
|
||||
['invalid', DEFAULT_THRESHOLD],
|
||||
['', DEFAULT_THRESHOLD],
|
||||
])('respects GITNEXUS_WAL_CHECKPOINT_THRESHOLD=%s', (raw, expectedCheckpointThreshold) => {
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_WAL_CHECKPOINT_THRESHOLD', raw);
|
||||
const Database = vi.fn(function (this: any) {});
|
||||
const lbugModule = { Database } as any;
|
||||
|
||||
createLbugDatabase(lbugModule, '/tmp/lbug-env');
|
||||
|
||||
expect(Database).toHaveBeenCalledWith(
|
||||
'/tmp/lbug-env',
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
expect.any(Number),
|
||||
true,
|
||||
expectedCheckpointThreshold,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('warns and falls back to default when GITNEXUS_WAL_CHECKPOINT_THRESHOLD is invalid', () => {
|
||||
const cap = _captureLogger();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_WAL_CHECKPOINT_THRESHOLD', 'invalid');
|
||||
const Database = vi.fn(function (this: any) {});
|
||||
const lbugModule = { Database } as any;
|
||||
|
||||
createLbugDatabase(lbugModule, '/tmp/lbug-invalid');
|
||||
|
||||
const warn = cap
|
||||
.records()
|
||||
.find(
|
||||
(r) =>
|
||||
typeof r.msg === 'string' &&
|
||||
r.msg.includes('Ignoring invalid GITNEXUS_WAL_CHECKPOINT_THRESHOLD'),
|
||||
);
|
||||
expect(warn).toBeDefined();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT warn when GITNEXUS_WAL_CHECKPOINT_THRESHOLD is empty (treated as unset)', () => {
|
||||
const cap = _captureLogger();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_WAL_CHECKPOINT_THRESHOLD', '');
|
||||
const Database = vi.fn(function (this: any) {});
|
||||
const lbugModule = { Database } as any;
|
||||
|
||||
createLbugDatabase(lbugModule, '/tmp/lbug-empty');
|
||||
|
||||
const warn = cap
|
||||
.records()
|
||||
.find(
|
||||
(r) =>
|
||||
typeof r.msg === 'string' &&
|
||||
r.msg.includes('Ignoring invalid GITNEXUS_WAL_CHECKPOINT_THRESHOLD'),
|
||||
);
|
||||
expect(warn).toBeUndefined();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes throwOnWalReplayFailure and checksum constructor args explicitly', () => {
|
||||
const Database = vi.fn(function (this: any) {});
|
||||
const lbugModule = { Database } as any;
|
||||
|
|
@ -51,9 +153,43 @@ describe('createLbugDatabase WAL replay option', () => {
|
|||
true,
|
||||
expect.any(Number),
|
||||
true,
|
||||
-1,
|
||||
DEFAULT_THRESHOLD,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Finding 8: strict + permissive checkpoint IO matchers ─────────────────
|
||||
describe('isLbugCheckpointIoError', () => {
|
||||
it.each([
|
||||
[
|
||||
'native rename failure (v0.16.x exact)',
|
||||
'Runtime exception: IO exception: Error renaming file /repo/.gitnexus/lbug.wal to /repo/.gitnexus/lbug.wal.checkpoint. ErrorMessage: Permission denied',
|
||||
],
|
||||
[
|
||||
'native remove failure (v0.16.x exact)',
|
||||
'Runtime exception: IO exception: Error removing directory or file /repo/.gitnexus/lbug.wal.checkpoint. Error Message: Permission denied',
|
||||
],
|
||||
])('matches strict %s', (_label, msg) => {
|
||||
expect(isLbugCheckpointIoError(msg)).toBe(true);
|
||||
expect(isLbugCheckpointIoError(new Error(msg))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches permissive fallback for hypothetical message drift', () => {
|
||||
// Permissive matcher accepts any IO-exception-shaped message mentioning .wal.checkpoint.
|
||||
const drift =
|
||||
'Some new wrapper preamble :: IO exception when finalizing /repo/.gitnexus/lbug.wal.checkpoint';
|
||||
expect(isLbugCheckpointIoError(drift)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT match unrelated IO errors', () => {
|
||||
expect(
|
||||
isLbugCheckpointIoError(
|
||||
'Runtime exception: IO exception: Error renaming file /repo/data.tmp to /repo/data.tmp.bak',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isLbugCheckpointIoError('Some other error')).toBe(false);
|
||||
expect(isLbugCheckpointIoError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
63
gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts
Normal file
63
gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* U2 (A2 from PR #1773 review) — regression guard for the E1 enrichment
|
||||
* log dual-emission shape.
|
||||
*
|
||||
* The E1 line at the top of `runChunkedParseAndResolve`'s post-chunk band
|
||||
* has two independent emission targets:
|
||||
* - `logger.info('🔗 E1: Seeded …')` for the `isDev` path (dev-mode log
|
||||
* scrapers still match the original emoji marker).
|
||||
* - `logDeferredProfile('E1: seeded …')` for the `GITNEXUS_PROFILE_DEFERRED`
|
||||
* path (operators grepping the [deferred-profile] prefix see no gap
|
||||
* between wildcard-synth and heritage timings).
|
||||
*
|
||||
* When both flags are set, BOTH lines must fire. The original code used
|
||||
* `if (isDev) { ... } else if (deferredProfile) { ... }` which is mutually
|
||||
* exclusive and silently swallowed the [deferred-profile] line on combined-
|
||||
* flag runs. This pin guards against the regression returning.
|
||||
*
|
||||
* Driving the four-case truth table via the real pipeline requires the
|
||||
* worker path (`deferredWorkerCalls` only populates from chunk-worker
|
||||
* extraction), which is slow and harness-dependent. A source-shape pin is
|
||||
* the right test scope for a purely structural change — and is exactly
|
||||
* how downstream readers grep for the regression anyway.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PARSE_IMPL_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../../src/core/ingestion/pipeline-phases/parse-impl.ts',
|
||||
);
|
||||
|
||||
describe('parse-impl E1 dual-emission shape (U2)', () => {
|
||||
const source = fs.readFileSync(PARSE_IMPL_PATH, 'utf-8');
|
||||
|
||||
it('has a standalone `if (isDev)` branch emitting the original emoji line', () => {
|
||||
expect(/if \(isDev\) \{\s*logger\.info\(`🔗 E1: Seeded \$\{enrichedCount\}/.test(source)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('has a standalone `if (deferredProfile)` branch emitting the [deferred-profile] line', () => {
|
||||
expect(
|
||||
/if \(deferredProfile\) \{\s*logDeferredProfile\(`E1: seeded \$\{enrichedCount\}/.test(
|
||||
source,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not chain the E1 branches via `else if`', () => {
|
||||
// Tight regex anchored to the closing `}` of the isDev branch — confirms
|
||||
// the very next token is `if` (independent branch) not `else if` (mutually
|
||||
// exclusive). Unrelated `else if (deferredProfile)` later in the file
|
||||
// (e.g., the buildHeritageMap-skipped log) is outside this window.
|
||||
expect(
|
||||
/if \(isDev\) \{\s*logger\.info\(`🔗 E1: Seeded[^`]+`\);\s*\}\s*if \(deferredProfile\)/.test(
|
||||
source,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
159
gitnexus/test/unit/wal-checkpoint-driver.test.ts
Normal file
159
gitnexus/test/unit/wal-checkpoint-driver.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Unit tests for the manual WAL checkpoint driver (#1741 follow-up).
|
||||
*
|
||||
* The driver wraps a CHECKPOINT call in a bounded retry that fires only
|
||||
* on `isLbugCheckpointIoError` shapes. These tests inject a fake
|
||||
* `checkpointFn`, fake `sleepFn`, and fake `randomFn` to exercise the
|
||||
* retry policy deterministically without touching a real LadybugDB.
|
||||
*
|
||||
* Integration-level coverage that the driver actually runs against a
|
||||
* native engine lives in `test/integration/analyze-wal-checkpoint-failure.test.ts`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
isManualCheckpointEnabled,
|
||||
runCheckpointWithRetry,
|
||||
startWalCheckpointDriver,
|
||||
} from '../../src/core/lbug/wal-checkpoint-driver.js';
|
||||
|
||||
const makeCheckpointError = () =>
|
||||
// Matches the strict rename matcher in lbug-config.ts.
|
||||
new Error(
|
||||
'Runtime exception: IO exception: Error renaming file /tmp/lbug.wal to /tmp/lbug.wal.checkpoint. ErrorMessage: Permission denied',
|
||||
);
|
||||
|
||||
describe('runCheckpointWithRetry — retry policy', () => {
|
||||
it('returns on first success with attempts=1 and no sleeps', async () => {
|
||||
const checkpointFn = vi.fn().mockResolvedValue(true);
|
||||
const sleepFn = vi.fn().mockResolvedValue(undefined);
|
||||
const randomFn = vi.fn().mockReturnValue(0);
|
||||
|
||||
const result = await runCheckpointWithRetry({ checkpointFn, sleepFn, randomFn });
|
||||
|
||||
expect(result.attempts).toBe(1);
|
||||
expect(result.flushed).toBe(true);
|
||||
expect(checkpointFn).toHaveBeenCalledTimes(1);
|
||||
expect(sleepFn).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('retries up to 3 times on checkpoint IO errors and succeeds on the final attempt', async () => {
|
||||
const checkpointFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(makeCheckpointError())
|
||||
.mockRejectedValueOnce(makeCheckpointError())
|
||||
.mockResolvedValueOnce(true);
|
||||
const sleepFn = vi.fn().mockResolvedValue(undefined);
|
||||
// Fixed random returns 0, so jitter contributes 0 ms and we can
|
||||
// assert exact delays against BASE_DELAYS_MS = [50, 200, 500].
|
||||
const randomFn = vi.fn().mockReturnValue(0);
|
||||
|
||||
const result = await runCheckpointWithRetry({ checkpointFn, sleepFn, randomFn });
|
||||
|
||||
expect(result.attempts).toBe(3);
|
||||
expect(result.flushed).toBe(true);
|
||||
expect(checkpointFn).toHaveBeenCalledTimes(3);
|
||||
// Sleeps happen between attempts: after attempt 1 (50 ms) and after
|
||||
// attempt 2 (200 ms). No sleep after the final attempt.
|
||||
expect(sleepFn).toHaveBeenCalledTimes(2);
|
||||
expect(sleepFn).toHaveBeenNthCalledWith(1, 50);
|
||||
expect(sleepFn).toHaveBeenNthCalledWith(2, 200);
|
||||
});
|
||||
|
||||
it('rethrows the last error after exhausting all retries on persistent IO failures', async () => {
|
||||
const persistent = makeCheckpointError();
|
||||
const checkpointFn = vi.fn().mockRejectedValue(persistent);
|
||||
const sleepFn = vi.fn().mockResolvedValue(undefined);
|
||||
const randomFn = vi.fn().mockReturnValue(0);
|
||||
|
||||
await expect(runCheckpointWithRetry({ checkpointFn, sleepFn, randomFn })).rejects.toBe(
|
||||
persistent,
|
||||
);
|
||||
|
||||
expect(checkpointFn).toHaveBeenCalledTimes(3);
|
||||
// Two backoffs (50 ms, 200 ms) but no sleep after the final attempt.
|
||||
expect(sleepFn).toHaveBeenCalledTimes(2);
|
||||
expect(sleepFn).toHaveBeenNthCalledWith(1, 50);
|
||||
expect(sleepFn).toHaveBeenNthCalledWith(2, 200);
|
||||
});
|
||||
|
||||
it('does NOT retry non-checkpoint errors (e.g. WAL corruption surfaces immediately)', async () => {
|
||||
const corruption = new Error('Runtime exception: Corrupted wal file.');
|
||||
const checkpointFn = vi.fn().mockRejectedValue(corruption);
|
||||
const sleepFn = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(runCheckpointWithRetry({ checkpointFn, sleepFn })).rejects.toBe(corruption);
|
||||
|
||||
expect(checkpointFn).toHaveBeenCalledTimes(1);
|
||||
expect(sleepFn).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('jitter is bounded: 0 <= jitter < 50 ms regardless of random source', async () => {
|
||||
const checkpointFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(makeCheckpointError())
|
||||
.mockResolvedValueOnce(true);
|
||||
const sleepFn = vi.fn().mockResolvedValue(undefined);
|
||||
// Random returns 0.999... — jitter should still be <50 ms (floor).
|
||||
const randomFn = vi.fn().mockReturnValue(0.9999);
|
||||
|
||||
await runCheckpointWithRetry({ checkpointFn, sleepFn, randomFn });
|
||||
|
||||
expect(sleepFn).toHaveBeenCalledTimes(1);
|
||||
const delay = sleepFn.mock.calls[0][0] as number;
|
||||
expect(delay).toBe(50 + Math.floor(0.9999 * 50)); // == 99
|
||||
});
|
||||
});
|
||||
|
||||
describe('isManualCheckpointEnabled — env var parsing', () => {
|
||||
let originalEnv: string | undefined;
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) delete process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT;
|
||||
else process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = originalEnv;
|
||||
});
|
||||
|
||||
it('defaults to enabled when the env var is unset', () => {
|
||||
delete process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT;
|
||||
expect(isManualCheckpointEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['0', 'false', 'FALSE', 'off', 'no', ' 0 '])(
|
||||
'returns false for opt-out value %s',
|
||||
(value) => {
|
||||
process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = value;
|
||||
expect(isManualCheckpointEnabled()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['1', 'true', 'on', 'yes', ''])('returns true for non-opt-out value %s', (value) => {
|
||||
process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = value;
|
||||
expect(isManualCheckpointEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startWalCheckpointDriver — lifecycle', () => {
|
||||
let originalEnv: string | undefined;
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) delete process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT;
|
||||
else process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = originalEnv;
|
||||
});
|
||||
|
||||
it('returns a no-op handle when manual checkpoint is disabled', async () => {
|
||||
process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = '0';
|
||||
const driver = startWalCheckpointDriver({ periodMs: 10 });
|
||||
// stop() must resolve cleanly even when no interval was scheduled.
|
||||
await expect(driver.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('stop() is idempotent (second call resolves without throwing)', async () => {
|
||||
process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = '0';
|
||||
const driver = startWalCheckpointDriver({ periodMs: 10 });
|
||||
await driver.stop();
|
||||
await expect(driver.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue