diff --git a/README.md b/README.md index 9b14396a8..44f56edd0 100644 --- a/README.md +++ b/README.md @@ -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 # 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 `. | 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 `. | 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 ` × 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 `. `-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. | diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 47803c469..e718eb7f2 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -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", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 6a2bc0187..de51268d0 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -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" + } } } diff --git a/gitnexus-web/vercel.json b/gitnexus-web/vercel.json index 88fd4e83d..284b15735 100644 --- a/gitnexus-web/vercel.json +++ b/gitnexus-web/vercel.json @@ -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" } diff --git a/gitnexus/README.md b/gitnexus/README.md index 61ae131ac..9b18e1dbb 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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 diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index eb67809ed..288bc90bd 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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": { diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 42dcb7aa7..1eecc00b4 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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)'); diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 29253f969..32ceaca62 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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 { const nodeOpts = process.env.NODE_OPTIONS || ''; @@ -473,8 +487,12 @@ async function ensureHeap(): Promise { */ 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. diff --git a/gitnexus/src/cli/cli-message.ts b/gitnexus/src/cli/cli-message.ts index db5f51fab..1f1eb9521 100644 --- a/gitnexus/src/cli/cli-message.ts +++ b/gitnexus/src/cli/cli-message.ts @@ -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 { + 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): 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): 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): 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): 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): void { +export function cliError(msg: string, fields?: CliMessageFields): void { writeStderr(msg); logger.error(fields ?? {}, msg); } diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 31446cf6f..17fab2068 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -71,6 +71,11 @@ program '--worker-timeout ', 'Worker sub-batch idle timeout before retry/fallback. Default: 30.', ) + .option( + '--wal-checkpoint-threshold ', + 'LadybugDB WAL auto-checkpoint threshold in bytes during analyze ' + + '(integer >= -1; default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).', + ) .option( '--workers ', '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).', ) diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts index 02a6ed60f..7e7d4d852 100644 --- a/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts +++ b/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts @@ -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' + ); +} diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 1b5a234b4..0ab961a7a 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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); diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 453baca20..874c98b89 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -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; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 4e4cb4dcf..de8cd058e 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -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 }; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index 70d544e3d..d42b586ce 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -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()) ──────────────────────── (call_expression function: (template_function diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index be8b3503d..4864d7d38 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -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 = {}; @@ -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 + * ` is ` 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, ): CaptureMatch[] { const out: CaptureMatch[] = []; + const classMembers = collectKotlinClassMembers(rootNode); for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) { const localTypes = new Map(); 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>; + /** className → methodName → raw return type text */ + readonly methods: ReadonlyMap>; +} + +/** + * 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>(); + const methods = new Map>(); + 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(); + const mmap = methods.get(className) ?? new Map(); + + 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, @@ -258,6 +453,7 @@ function inferKotlinPropertyType( prop: SyntaxNode, localTypes: ReadonlyMap, returnTypes: ReadonlyMap, + 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, + 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, + 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, @@ -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 → ` + // 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 []; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts b/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts index 63b89b113..71d6a6b53 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts @@ -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, 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, + 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, 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, + 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, + 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( diff --git a/gitnexus/src/core/ingestion/languages/kotlin/owners.ts b/gitnexus/src/core/ingestion/languages/kotlin/owners.ts index db6ddf632..f034bafd7 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/owners.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/owners.ts @@ -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 { diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts index d8b0244b8..5a2968055 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -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" diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 2b20808ad..591e79bfc 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -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 { + const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + const defIdByGraphId = new Map(); + 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(); + 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[] { + const out: string[] = []; + const seen = new Set(); + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts index be07602eb..bbd34e160 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/model/heritage-map.ts b/gitnexus/src/core/ingestion/model/heritage-map.ts index ae89022b4..4cee244e3 100644 --- a/gitnexus/src/core/ingestion/model/heritage-map.ts +++ b/gitnexus/src/core/ingestion/model/heritage-map.ts @@ -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 (implementor lookup for interface dispatch) const implementorFiles = new Map>(); + 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, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 10e4557d2..bf954b5cf 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -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 | 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) { diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index e050c6553..94fc172dc 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -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 { // ─── Internal ─────────────────────────────────────────────────────────────── -/** Accepted truthy strings (case-insensitive, trimmed). */ -const TRUTHY_VALUES: ReadonlySet = 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); } diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 09080d2c6..a737214b3 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -1048,6 +1048,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@type-binding.type', '@reference.name', '@reference.receiver', + '@reference.operator', '@reference.arity', '@reference.parameter-types', '@reference.parameter-type-classes', diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index f02ae2cb2..f67823c8c 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts b/gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts new file mode 100644 index 000000000..697ca3bb6 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts @@ -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); +}; diff --git a/gitnexus/src/core/ingestion/utils/env.ts b/gitnexus/src/core/ingestion/utils/env.ts index 6cb9d549f..5beeb818f 100644 --- a/gitnexus/src/core/ingestion/utils/env.ts +++ b/gitnexus/src/core/ingestion/utils/env.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/utils/verbose.ts b/gitnexus/src/core/ingestion/utils/verbose.ts index 0e616dca9..c697a5aaa 100644 --- a/gitnexus/src/core/ingestion/utils/verbose.ts +++ b/gitnexus/src/core/ingestion/utils/verbose.ts @@ -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); diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index da9809dba..5e2a34601 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1527,6 +1527,27 @@ export const flushWAL = async (): Promise => { } }; +/** + * 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 => { + 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. * diff --git a/gitnexus/src/core/lbug/lbug-config.ts b/gitnexus/src/core/lbug/lbug-config.ts index 22f2d3b18..a71b25930 100644 --- a/gitnexus/src/core/lbug/lbug-config.ts +++ b/gitnexus/src/core/lbug/lbug-config.ts @@ -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: +// - ".wal -> .wal.checkpoint" rename failures +// - ".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; diff --git a/gitnexus/src/core/lbug/wal-checkpoint-driver.ts b/gitnexus/src/core/lbug/wal-checkpoint-driver.ts new file mode 100644 index 000000000..57dc6b2d6 --- /dev/null +++ b/gitnexus/src/core/lbug/wal-checkpoint-driver.ts @@ -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 `.wal` → `.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 => 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; + /** Override the CHECKPOINT call for tests. */ + checkpointFn?: () => Promise; + /** 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 => { + 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; +} + +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 | null = null; + + const tick = async (): Promise => { + 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); +}; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 79601a9a2..c28504c4c 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 { diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index d1297ea17..331cffb2c 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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 = new Map(); private contextCache: Map = 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 { - 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 { diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp new file mode 100644 index 000000000..46a0eef98 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.cpp @@ -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; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h new file mode 100644 index 000000000..6966a680c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overloaded-operators/lib.h @@ -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); diff --git a/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts b/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts new file mode 100644 index 000000000..c517263df --- /dev/null +++ b/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts @@ -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 + * .wal to .wal.checkpoint` by pre-planting a *directory* at the + * `.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 `.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); +}); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index e1376938e..90bc75117 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 68dee21dd..b0f17ae61 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -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 = ` Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Custom. `; - 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 = ` + +Indexed as **OldName**. Custom. + +`; + 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 = ` + +Indexed as **FreezeTest**. Custom. + +`; + 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 }); diff --git a/gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts b/gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts new file mode 100644 index 000000000..de55d4e71 --- /dev/null +++ b/gitnexus/test/unit/analyze-lbug-checkpoint-threshold.test.ts @@ -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); + }, + ); +}); diff --git a/gitnexus/test/unit/analyze-wal-error.test.ts b/gitnexus/test/unit/analyze-wal-error.test.ts index 1b4ed5101..5264dfd3a 100644 --- a/gitnexus/test/unit/analyze-wal-error.test.ts +++ b/gitnexus/test/unit/analyze-wal-error.test.ts @@ -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(); + }); }); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 8c69a23d5..9596d6b7a 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -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(); + 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(); + return { + ...actual, + getGitRoot: vi.fn().mockReturnValue(null), + }; +}); + vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => { const actual = await importOriginal(); 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(); diff --git a/gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts b/gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts new file mode 100644 index 000000000..0fe4ead86 --- /dev/null +++ b/gitnexus/test/unit/deferred-resolution-profile-wiring.test.ts @@ -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; + 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); + }); +}); diff --git a/gitnexus/test/unit/deferred-resolution-profile.test.ts b/gitnexus/test/unit/deferred-resolution-profile.test.ts new file mode 100644 index 000000000..21f4a8551 --- /dev/null +++ b/gitnexus/test/unit/deferred-resolution-profile.test.ts @@ -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(); + } + }); +}); diff --git a/gitnexus/test/unit/env.test.ts b/gitnexus/test/unit/env.test.ts new file mode 100644 index 000000000..6b5e767f3 --- /dev/null +++ b/gitnexus/test/unit/env.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/lbug-checkpoint.test.ts b/gitnexus/test/unit/lbug-checkpoint.test.ts index 5b9603997..6eac7e73a 100644 --- a/gitnexus/test/unit/lbug-checkpoint.test.ts +++ b/gitnexus/test/unit/lbug-checkpoint.test.ts @@ -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', () => { diff --git a/gitnexus/test/unit/lbug-config-wal.test.ts b/gitnexus/test/unit/lbug-config-wal.test.ts index 6baea3621..626e5d235 100644 --- a/gitnexus/test/unit/lbug-config-wal.test.ts +++ b/gitnexus/test/unit/lbug-config-wal.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts b/gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts new file mode 100644 index 000000000..55b3a19ca --- /dev/null +++ b/gitnexus/test/unit/parse-impl-e1-emission-shape.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/wal-checkpoint-driver.test.ts b/gitnexus/test/unit/wal-checkpoint-driver.test.ts new file mode 100644 index 000000000..2079c74dc --- /dev/null +++ b/gitnexus/test/unit/wal-checkpoint-driver.test.ts @@ -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(); + }); +});