Merge branch 'main' into fix/cursor-npx-fallback-host-budget

This commit is contained in:
Gergő Magyar 2026-09-17 22:19:57 +01:00 • committed by GitHub
commit 15e5af3b8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 3887 additions and 654 deletions

View file

@ -19,8 +19,8 @@ runs:
# Browsers are installed explicitly by e2e. Typecheck only needs types.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
# Compile shared with the web package's TypeScript 5. Do not npm-ci
# gitnexus-shared (TypeScript 7 optional-platform install, ~7 minutes).
# Compile shared with the web package's TypeScript 7. Do not npm-ci
# gitnexus-shared (a second optional-platform install, ~7 minutes).
- name: Build gitnexus-shared
# node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc
# (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell).

View file

@ -93,6 +93,9 @@ updates:
# The extension version is a separate upstream constant, not derivable
# from the core version (see vendor/lbug-fts/manifest.json).
- dependency-name: '@ladybugdb/core'
- dependency-name: typescript
update-types:
- version-update:semver-major
# gitnexus-web (thin frontend client).
- package-ecosystem: npm
@ -111,6 +114,10 @@ updates:
labels:
- dependencies
- frontend
ignore:
- dependency-name: typescript
update-types:
- version-update:semver-major
# Shared types package.
- package-ecosystem: npm
@ -128,3 +135,9 @@ updates:
include: scope
labels:
- dependencies
ignore:
# Keep shared on the same TypeScript major as CLI/web. A shared-only
# major bump reintroduces a compiler split this repo unified.
- dependency-name: typescript
update-types:
- version-update:semver-major

View file

@ -48,7 +48,7 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: ${{ matrix.language }}
queries: security-and-quality
@ -87,6 +87,6 @@ jobs:
- '.github/scripts/fetch-lbug-fts-artifacts.mjs'
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
category: '/language:${{ matrix.language }}'

View file

@ -330,24 +330,20 @@ jobs:
set -euo pipefail
# The benchmark's task bindings sandbox-copy node_modules from the
# monorepo root as well as gitnexus-shared and gitnexus (see the
# sandbox_copy entries in tasks.scenarios.yaml). The two steps below
# install the subpackage trees; the root tree needs its own install
# or capture_task_dependency_binding aborts at task binding on the
# missing root node_modules.
# sandbox_dependencies entries in tasks.scenarios.yaml). gitnexus
# npm ci plus build compiles shared through parent lib/tsc.js; do
# not npm ci gitnexus-shared (second TypeScript 7 optional install).
npm ci
- name: Build pinned shared runtime
run: |
set -euo pipefail
npm ci
npm run build
working-directory: gitnexus-shared
- name: Install and build pinned GitNexus runtime
run: |
set -euo pipefail
npm ci
npm run build
# Task YAML still mounts gitnexus-shared/node_modules. Keep an empty
# directory so capture_task_dependency_binding does not abort, without
# installing TypeScript 7 inside shared.
mkdir -p ../gitnexus-shared/node_modules
working-directory: gitnexus
- name: Point the benchmark task repo at the checkout

View file

@ -53,6 +53,6 @@ jobs:
retention-days: 5
- name: Upload to Security tab
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
sarif_file: results.sarif

View file

@ -76,7 +76,7 @@ jobs:
exit-code: '0'
- name: Upload to Security tab
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
sarif_file: trivy-${{ matrix.image.name }}.sarif
category: trivy-${{ matrix.image.name }}

View file

@ -76,7 +76,7 @@ jobs:
continue-on-error: true
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
sarif_file: zizmor.sarif
category: zizmor

View file

@ -16,14 +16,19 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
**Prerequisites:** Node.js — `gitnexus/` requires `^22.18.0 || >=24.11.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
1. Clone the repository.
2. **Shared package:** `cd gitnexus-shared && npm install && npm run build`
3. **CLI / MCP package:** `cd ../gitnexus && npm install && npm run build`
4. **Web UI (if needed):** `cd ../gitnexus-web && npm install`
5. Run tests as described in [TESTING.md](TESTING.md).
2. **CLI / MCP package:** `cd gitnexus && npm install && npm run build`
`prepare` / `scripts/build.js` compiles `gitnexus-shared` with
`node …/typescript/lib/tsc.js` from this package. Do not `npm install` or
`npm ci` inside `gitnexus-shared/` — that is a second TypeScript 7
optional-platform install and is not what `setup-gitnexus` does.
3. **Web UI (if needed):** `cd gitnexus-web && npm install`
If you skipped step 2, compile shared with the web compiler first:
`cd gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js`
4. Run tests as described in [TESTING.md](TESTING.md).
The CLI build imports `gitnexus-shared`, so a fresh clone must install and build
the shared package before running `npm install` in `gitnexus/`. This is the same
order used by the repository's `setup-gitnexus` CI action.
The CLI build imports `gitnexus-shared`, so `gitnexus-shared/dist` must exist
before `gitnexus` typecheck. That emit uses a parent package's TypeScript 7
`lib/tsc.js`, matching `setup-gitnexus`, `setup-gitnexus-web`, and Vercel.
### Containerized development (optional)
@ -70,7 +75,7 @@ Commits within a PR may use any style — only the **merged PR title** shows up
## Before you open a PR
- [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`).
- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`.
- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. Those commands use TypeScript 7. The web app tsconfig lists `lib` `DOM`/`DOM.Iterable` and `jsx: react-jsx` so React/JSX typecheck on 7; Vite/`vitest` keep `@vitejs/plugin-react` with the automatic JSX runtime. Build `gitnexus-shared/dist` first with a parent `lib/tsc.js` (a `gitnexus` install/`npm run build` does this). Repo ESLint stays syntax-only on a TypeScript 5.x peer until typescript-eslint supports 7.
- [ ] No secrets, tokens, or machine-specific paths committed.
- [ ] Documentation updated if behavior or public CLI/MCP contract changes.
- [ ] Every new `GITNEXUS_*` environment variable has a row in the **Environment variables** table in [README.md](README.md) — variable, default, effect, and when to tune it.

6
DoD.md
View file

@ -113,7 +113,7 @@ Run the commands relevant to the touched area. If something cannot be run in the
### 4.1 Build ordering
- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action for this — local runs must match).
- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action, which compiles shared with the parent TypeScript 7 `lib/tsc.js` — local runs must match).
### 4.2 If `gitnexus/` changed
@ -123,13 +123,13 @@ Run the commands relevant to the touched area. If something cannot be run in the
### 4.3 If `gitnexus-web/` changed
- [ ] `cd gitnexus-web && npx tsc -b --noEmit`
- [ ] `cd gitnexus-web && npx tsc -b --noEmit` (TypeScript 7 typechecks React/JSX: `jsx: react-jsx`, `lib` includes `DOM`)
- [ ] `cd gitnexus-web && npm test`
- [ ] `cd gitnexus-web && npm run test:e2e` when browser flows or user-facing UI behavior changed
### 4.4 If `gitnexus-shared/` changed
- [ ] Shared package builds cleanly (`npm run build` in `gitnexus-shared/`)
- [ ] Shared package builds cleanly from a parent TypeScript 7 shim after that parent is installed (`cd gitnexus-shared && node ../gitnexus/node_modules/typescript/lib/tsc.js`, or `node ../gitnexus-web/node_modules/typescript/lib/tsc.js` after a web install). Do not `npm install` / `npm ci` in `gitnexus-shared/` for this check.
- [ ] Dependent packages still typecheck and test after the shared change — verify both CLI and web consumers together
### 4.5 If CI workflows or release pipelines changed

View file

@ -508,6 +508,10 @@ analyze_timeout: 5m
projects:
- local_path: /absolute/path/to/clones
branches: [main, master]
# pdg: omit = preserve live index mode; true = keep PDG current;
# false = init default (warns, then strips PDG on the next successful rebuild).
# Do not paste pdg: false onto an existing watch file unless you intend to drop PDG.
pdg: false
overwrite_local_changes: false
remote_urls:
- git@github.com:owner/repo.git
@ -516,7 +520,8 @@ projects:
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`.
- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee.
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.
- Set per-project `pdg: true` to keep the full control-flow, control/data-dependence, and taint layers current. Untouched configs that omit `pdg` preserve an existing index's mode and cannot silently strip PDG data. Do not paste `pdg: false` from this example onto an existing watch file unless you intend to drop PDG; an explicit `false` opt-out logs a warning before removing existing PDG data. Auto-sync requests atomic incremental publication where supported, so readers keep using the previous graph until a successful update is ready and a failed staged analysis leaves it intact; unsupported paths retain the analyzer's existing in-place behavior.
- Analysis runs in an isolated worker; `analyze_timeout` defaults to half of `sync_interval_minutes`, but may be longer (for example, a `30m` analysis timeout with `5` minute polling) up to Node's timer limit. If a polling tick arrives while analysis is active, it is coalesced into one immediate follow-up run using the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.
- Add `group_name` only after creating that group with `gitnexus group create <name>`. Partial clone output is isolated and removed after 14 days.
See the [full auto-sync configuration and runtime reference](gitnexus/README.md#gitnexus-auto-sync) for concurrency, timeouts, failure thresholds, and runtime files.
@ -897,9 +902,10 @@ The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAs
```bash
git clone https://github.com/abhigyanpatwari/gitnexus.git
cd gitnexus/gitnexus-shared && npm install && npm run build
cd ../gitnexus-web && npm install
npm run dev
cd gitnexus/gitnexus-web && npm install
# Compile sibling gitnexus-shared with this package's TypeScript 7 (do not npm ci shared).
cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js
cd ../gitnexus-web && npm run dev
# Then in another terminal, start the backend the frontend connects to:
npx gitnexus@latest serve
```

View file

@ -39,6 +39,8 @@ From `gitnexus-web/`:
### Before opening a PR
```bash
# gitnexus-shared/dist must exist first. `npm install` / `npm run build` in
# gitnexus/ compiles it via parent `lib/tsc.js` (do not npm ci gitnexus-shared).
cd gitnexus && npx tsc --noEmit && npm test
cd ../gitnexus-web && npx tsc -b --noEmit && npm test
```

View file

@ -10,10 +10,10 @@
"dependencies": {
"@langchain/anthropic": "^1.5.8",
"@langchain/core": "^1.2.8",
"@langchain/google-genai": "^2.2.0",
"@langchain/google-genai": "^2.3.1",
"@langchain/langgraph": "^1.4.14",
"@langchain/ollama": "^1.3.0",
"@langchain/openai": "^1.5.3",
"@langchain/openai": "^1.5.13",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.3.3",
"axios": "^1.20.0",
@ -47,13 +47,13 @@
"zod": "^4.5.4"
},
"devDependencies": {
"@babel/types": "^8.0.4",
"@babel/types": "^8.0.5",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/dompurify": "^3.2.0",
"@types/node": "^26.0.1",
"@types/node": "^26.5.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
@ -62,7 +62,7 @@
"@vitest/coverage-v8": "^4.1.11",
"jsdom": "^30.0.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vitest": "^4.1.10",
"wait-on": "^9.1.0"
@ -237,9 +237,9 @@
}
},
"node_modules/@babel/types": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz",
"integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==",
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.5.tgz",
"integrity": "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1114,9 +1114,9 @@
}
},
"node_modules/@langchain/core": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.10.tgz",
"integrity": "sha512-skr9zkyidZSzhoqhWINdULRstPT5aMD+w3tWtE3puxffWH7wxAp8tx1gMqadjKZwlgo6exlK0nHncFJv6m+3Cg==",
"version": "1.2.11",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.11.tgz",
"integrity": "sha512-8yuWLLloTSYA453akm2JSadOVa8kGDY8v+kTzQ6kTY6aIETTEIxgysjZWyKrWQLo3UazctsSoGJ8JrdCGFL4/w==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
@ -1132,9 +1132,9 @@
}
},
"node_modules/@langchain/google-genai": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.2.0.tgz",
"integrity": "sha512-1mDqbmB6+iC6ZBQY15r5xJg9wPErnQ774inpKh6qi6BrrjadDwaPHoklJW5IXU94edKiDpm1akIzJCrQDWe6yA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.3.1.tgz",
"integrity": "sha512-xoJVzzPA2zgGgHCy4pqTAlXSBKncutXcfzjtYdl9g8biRKjjvczpdpVs2gB0OxsYVX15fgaIJaCmo64+72cj2w==",
"license": "MIT",
"dependencies": {
"@google/generative-ai": "^0.24.1"
@ -1143,7 +1143,7 @@
"node": ">=20"
},
"peerDependencies": {
"@langchain/core": "^1.2.0"
"@langchain/core": "^1.2.10"
}
},
"node_modules/@langchain/langgraph": {
@ -1252,20 +1252,20 @@
}
},
"node_modules/@langchain/openai": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.3.tgz",
"integrity": "sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==",
"version": "1.5.13",
"resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.13.tgz",
"integrity": "sha512-SA1QWNgM/p8xhdwU82CJ7OofhHEExpAaIwJ7j9ELIyVW795Mn0jDRQO/5yChawLcPhuLkeL+jMesi0FnXbImSg==",
"license": "MIT",
"dependencies": {
"js-tiktoken": "^1.0.12",
"openai": "^6.41.0",
"openai": "^7.10.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
"node": ">=22"
},
"peerDependencies": {
"@langchain/core": "^1.2.1"
"@langchain/core": "^1.2.11"
}
},
"node_modules/@langchain/protocol": {
@ -2493,13 +2493,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.0.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz",
"integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==",
"version": "26.5.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
"undici-types": "~8.9.0"
}
},
"node_modules/@types/prismjs": {
@ -2550,6 +2550,346 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@ -2659,6 +2999,20 @@
"undici-types": "~5.26.4"
}
},
"node_modules/@vercel/node/node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@vercel/node/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
@ -6845,15 +7199,19 @@
}
},
"node_modules/openai": {
"version": "6.45.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz",
"integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==",
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-7.15.0.tgz",
"integrity": "sha512-2DIwesnPSduw68MFx7nAdgvshrgvS8WOan5W8+tgUMlAy/tY+Q41g80Pa+w/v64rHA6rtn+dketa8QvMVxU96w==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"@aws-sdk/credential-provider-node": ">=3.972.0 <4",
"@smithy/hash-node": ">=4.3.0 <5",
"@smithy/signature-v4": ">=5.4.0 <6",
"ws": "^8.18.0",
"undici": ">=5 <9",
"ws": "^8.21.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
@ -6866,6 +7224,9 @@
"@smithy/signature-v4": {
"optional": true
},
"undici": {
"optional": true
},
"ws": {
"optional": true
},
@ -8031,17 +8392,38 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
"tsc": "bin/tsc"
},
"engines": {
"node": ">=14.17"
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/ufo": {
@ -8054,16 +8436,16 @@
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"devOptional": true,
"license": "MIT"
},

View file

@ -20,10 +20,10 @@
"dependencies": {
"@langchain/anthropic": "^1.5.8",
"@langchain/core": "^1.2.8",
"@langchain/google-genai": "^2.2.0",
"@langchain/google-genai": "^2.3.1",
"@langchain/langgraph": "^1.4.14",
"@langchain/ollama": "^1.3.0",
"@langchain/openai": "^1.5.3",
"@langchain/openai": "^1.5.13",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.3.3",
"axios": "^1.20.0",
@ -57,13 +57,13 @@
"zod": "^4.5.4"
},
"devDependencies": {
"@babel/types": "^8.0.4",
"@babel/types": "^8.0.5",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/dompurify": "^3.2.0",
"@types/node": "^26.0.1",
"@types/node": "^26.5.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
@ -72,7 +72,7 @@
"@vitest/coverage-v8": "^4.1.11",
"jsdom": "^30.0.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vitest": "^4.1.10",
"wait-on": "^9.1.0"

View file

@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
describe('TypeScript 7 + React frontend toolchain', () => {
it('keeps Vite and Vitest on the automatic JSX runtime that matches react-jsx', () => {
for (const configFile of ['vite.config.ts', 'vitest.config.ts'] as const) {
const source = readFileSync(path.join(webRoot, configFile), 'utf8');
expect(source).toContain('@vitejs/plugin-react');
expect(source).toContain("jsxRuntime: 'automatic'");
}
});
});

View file

@ -1,26 +1,29 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "react",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"baseUrl": "./",
"noUncheckedSideEffectImports": true,
"rootDir": "./src",
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["../shared/*"]
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src", "../shared"],
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View file

@ -2,11 +2,15 @@
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"target": "ESNext",
"lib": ["ESNext"],
"module": "ESNext",
"moduleResolution": "Bundler",
"moduleDetection": "force",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"noEmit": true,
"rootDir": ".",
"types": ["node"]
},
"include": ["vite.config.ts"]

View file

@ -1,3 +1,3 @@
{
"installCommand": "cd ../gitnexus-shared && npm install && npm run build && cd ../gitnexus-web && npm ci --include=dev"
"installCommand": "npm ci --include=dev && cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js"
}

View file

@ -8,7 +8,7 @@ const _require = createRequire(import.meta.url);
const gitnexusPkg = _require('../gitnexus/package.json');
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [react({ jsxRuntime: 'automatic' }), tailwindcss()],
define: {
__REQUIRED_NODE_VERSION__: JSON.stringify(gitnexusPkg.engines.node.replace(/[>=^~\s]/g, '')),
},

View file

@ -7,7 +7,7 @@ const _require = createRequire(import.meta.url);
const gitnexusPkg = _require('../gitnexus/package.json');
export default defineConfig({
plugins: [react()],
plugins: [react({ jsxRuntime: 'automatic' })],
define: {
__REQUIRED_NODE_VERSION__: JSON.stringify(gitnexusPkg.engines.node.replace(/[>=^~\s]/g, '')),
},

View file

@ -325,6 +325,10 @@ analyze_failure_threshold: 3
projects:
- local_path: /abs/path/to/repos
branches: [master, main]
# pdg: omit = preserve live index mode; true = keep PDG current;
# false = init default (warns, then strips PDG on the next successful rebuild).
# Do not paste pdg: false onto an existing watch file unless you intend to drop PDG.
pdg: false
overwrite_local_changes: false
remote_urls:
- git@github.com:owner/repo.git
@ -332,7 +336,9 @@ projects:
- git@gitee.com:owner/repo.git
```
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch and commit; a new commit or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker and defaults to half of `sync_interval_minutes`, but it is independent of polling and may be longer, up to Node's timer limit (`2147483647ms`). A `5` minute poll with `analyze_timeout: 30m` is valid. A tick that arrives while the previous loop is active never overlaps it: ticks coalesce into one immediate follow-up run, which pulls and analyzes the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows.
`pdg` is configured per project. `pdg: true` builds and maintains the full CFG, control-dependence, reaching-definition, and taint layers on both initial and incremental analyses. Auto-sync requests staged atomic incremental publication where the analyzer supports it: the old graph remains available to readers until the replacement succeeds, and analysis errors are recorded while the old graph remains intact. Unsupported paths retain the analyzer's existing in-place behavior. Untouched configs that omit `pdg` preserve the existing index mode and cannot silently strip PDG data. Do not paste `pdg: false` from this example onto an existing watch file unless you intend to drop PDG. An explicit `pdg: false` disables PDG and emits a warning before a successful rebuild removes those layers. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch, commit, and requested PDG mode; a new commit, a PDG mode change, or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
GraphQL contract matching is opt-in in the group's `group.yaml`:

View file

@ -68,7 +68,7 @@
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
"typescript": "^5.4.5",
"typescript": "^7.0.2",
"vitest": "^4.0.18"
},
"engines": {
@ -1331,6 +1331,346 @@
"@types/node": "*"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
@ -2908,9 +3248,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz",
"integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==",
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz",
"integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==",
"funding": [
{
"type": "github",
@ -4671,17 +5011,38 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
"tsc": "bin/tsc"
},
"engines": {
"node": ">=14.17"
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/typical": {

View file

@ -129,7 +129,7 @@
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
"typescript": "^5.4.5",
"typescript": "^7.0.2",
"vitest": "^4.0.18"
},
"overrides": {

View file

@ -54,8 +54,8 @@ if (!fs.existsSync(SHARED_ROOT)) {
// Launch tsc as `node typescript/lib/tsc.js` on every OS. The `.bin/tsc` /
// `tsc.cmd` shims are Windows-only wrappers; `execFileSync` cannot spawn a
// `.cmd` without a shell, and a separate `npm ci` in gitnexus-shared pulls
// TypeScript 7 optional platform packages (7+ minutes in CI).
// `.cmd` without a shell, and a separate `npm ci` in gitnexus-shared would
// pull a second TypeScript 7 optional-platform install (7+ minutes in CI).
const tscJs = path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js');
if (!fs.existsSync(tscJs)) {
console.error(

View file

@ -117,6 +117,10 @@ function defaultSyncConfig(localPath: string): string {
'projects:',
` - local_path: ${localPath}`,
' branches: [master, main]',
' # pdg: omit = preserve live index mode; true = keep PDG current;',
' # false = init default (warns, then strips PDG on the next successful rebuild).',
' # Do not paste pdg: false onto an existing watch file unless you intend to drop PDG.',
' pdg: false',
' overwrite_local_changes: false',
' remote_urls:',
' - git@github.com:owner/repo.git',

View file

@ -32,6 +32,27 @@ interface AnalysisWorker extends Pick<ChildProcess, 'send' | 'on'> {
*/
const AUTO_SYNC_CANCEL_GRACE_MS = 5_000;
export class AutoSyncAnalysisError extends Error {
readonly code?: 'index-lock-timeout';
readonly retryable?: boolean;
readonly abandonedWorker: boolean;
constructor(
message: string,
options: {
code?: 'index-lock-timeout';
retryable?: boolean;
abandonedWorker?: boolean;
} = {},
) {
super(message);
this.name = 'AutoSyncAnalysisError';
this.code = options.code;
this.retryable = options.retryable;
this.abandonedWorker = options.abandonedWorker === true;
}
}
export interface AutoSyncAnalysisLaunchDeps {
forkWorker: (workerPath: string, execArgv: string[]) => AnalysisWorker;
setTimeoutFn: typeof setTimeout;
@ -144,9 +165,10 @@ export function createAutoSyncAnalysisRunner(
if (settled) return;
releaseChild();
settle(
new Error(
new AutoSyncAnalysisError(
`${error.message} The analyze worker did not exit within ${deps.cancelGraceMs}ms; ` +
'it was left running so its native work is not interrupted.',
{ abandonedWorker: true, retryable: true },
),
);
}, deps.cancelGraceMs);
@ -186,7 +208,12 @@ export function createAutoSyncAnalysisRunner(
return;
}
if (terminalOutcome?.type === 'error') {
settle(new Error(terminalOutcome.message));
settle(
new AutoSyncAnalysisError(terminalOutcome.message, {
code: terminalOutcome.code,
retryable: terminalOutcome.retryable,
}),
);
return;
}
settle(

View file

@ -37,6 +37,8 @@ const REMOTE_PATH_SEGMENT_PATTERN = REMOTE_REPO_NAME_PATTERN;
export interface AutoSyncProjectConfig {
localPath: string;
groupName?: string;
/** Explicit opt-in/out. Omitted legacy configs preserve the index's current mode. */
pdg?: boolean;
overwriteLocalChanges: boolean;
branches: string[];
remoteUrls: string[];
@ -159,7 +161,7 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy
);
}
const maxAnalyzeTimeoutMs =
const defaultAnalyzeTimeoutMs =
Number.isInteger(interval) &&
interval >= MIN_SYNC_INTERVAL_MINUTES &&
interval <= MAX_SYNC_INTERVAL_MINUTES
@ -167,14 +169,12 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy
: undefined;
const analyzeTimeoutMs =
raw.analyze_timeout === undefined
? (maxAnalyzeTimeoutMs ?? 0)
? (defaultAnalyzeTimeoutMs ?? 0)
: parseDurationMs(raw.analyze_timeout);
if (!Number.isInteger(analyzeTimeoutMs) || analyzeTimeoutMs <= 0) {
errors.push('analyze_timeout must be a positive duration such as 30m');
} else if (maxAnalyzeTimeoutMs !== undefined && analyzeTimeoutMs > maxAnalyzeTimeoutMs) {
errors.push(
`analyze_timeout must not exceed half of sync_interval_minutes (${maxAnalyzeTimeoutMs / 60_000}m)`,
);
} else if (analyzeTimeoutMs > MAX_TIMER_DELAY_MS) {
errors.push(`analyze_timeout must not exceed ${MAX_TIMER_DELAY_MS}ms`);
}
const analyzeFailureThreshold =
@ -256,10 +256,15 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy
errors.push(`projects[${index}].overwrite_local_changes must be a boolean`);
}
if (project.pdg !== undefined && typeof project.pdg !== 'boolean') {
errors.push(`projects[${index}].pdg must be a boolean`);
}
if (localPath && remoteUrls.length > 0 && branches.length > 0) {
projects.push({
localPath,
groupName,
...(typeof project.pdg === 'boolean' ? { pdg: project.pdg } : {}),
overwriteLocalChanges: overwriteLocalChanges === true,
branches,
remoteUrls,

View file

@ -4,7 +4,13 @@ import { createRequire } from 'node:module';
import { loadGroupConfig } from '../group/config-parser.js';
import { getDefaultGitnexusDir, getGroupDir } from '../group/storage.js';
import { syncGroup } from '../group/sync.js';
import { registerRepo, resolveBranchPlacement, type RepoMeta } from '../../storage/repo-manager.js';
import {
getStoragePaths,
loadMeta,
registerRepo,
resolveBranchPlacement,
type RepoMeta,
} from '../../storage/repo-manager.js';
import { extractRepoNameFromRemoteUrl } from './repo.js';
import { cloneOrPull, runGit } from '../../server/git-clone.js';
import { resolveConfiguredCloneRoot } from './path-security.js';
@ -20,7 +26,11 @@ import {
} from './state.js';
import type { AutoSyncConfig, AutoSyncProjectConfig } from './config.js';
import { validateAutoSyncRemoteUrl } from './config.js';
import { runAutoSyncAnalysis, type AutoSyncAnalysisRunner } from './analysis-worker-launch.js';
import {
AutoSyncAnalysisError,
runAutoSyncAnalysis,
type AutoSyncAnalysisRunner,
} from './analysis-worker-launch.js';
export interface AutoSyncLogger {
info(message: string): void;
@ -32,6 +42,7 @@ export interface AutoSyncRunDeps {
cloneOrPull: typeof cloneOrPull;
getCurrentBranch: (repoPath: string, timeoutMs: number) => Promise<string | undefined>;
getCurrentCommit: (repoPath: string, timeoutMs: number) => Promise<string>;
getExistingPdgMode: (repoPath: string, branch: string) => Promise<boolean>;
runAnalysis: AutoSyncAnalysisRunner;
registerRepo: typeof registerRepo;
resolveBranchPlacement: typeof resolveBranchPlacement;
@ -49,6 +60,8 @@ export interface AutoSyncRunResult {
analyzed: number;
skippedAnalysis: number;
failed: number;
/** True when a timed-out analyze worker was left running and may still hold the index lock. */
abandonedAnalysisWorker?: boolean;
}
const _require = createRequire(import.meta.url);
@ -68,6 +81,23 @@ const DEFAULT_DEPS: AutoSyncRunDeps = {
},
getCurrentCommit: async (repoPath, timeoutMs) =>
(await runGit(['rev-parse', 'HEAD'], repoPath, { timeoutMs })).trim(),
getExistingPdgMode: async (repoPath, branch) => {
const placement = await resolveBranchPlacement(repoPath, branch);
const paths = getStoragePaths(repoPath, placement.branch);
const meta = await loadMeta(path.dirname(paths.metaPath));
if (meta) return meta.pdg !== undefined;
try {
await fs.stat(paths.lbugPath);
} catch (error: unknown) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') return false;
throw error;
}
throw new Error(
`Cannot determine whether the existing index at ${paths.lbugPath} contains PDG data because its metadata is missing or unreadable.`,
);
},
runAnalysis: runAutoSyncAnalysis,
registerRepo,
resolveBranchPlacement,
@ -99,6 +129,7 @@ export async function runAutoSyncOnce(
const groupsToSync = new Set<string>();
const groupStateKeys = new Map<string, string[]>();
const result: AutoSyncRunResult = { synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 };
let abandonedAnalysisWorker = false;
const commitInfoEntries: ProjectCommitInfoEntry[] = [];
const actualConcurrency = resolveActualConcurrency(
config.maxConcurrency,
@ -155,6 +186,23 @@ export async function runAutoSyncOnce(
const currentCommit = await deps.getCurrentCommit(targetDir, config.repoGitTimeoutMs);
const stateKey = buildStateKey(targetDir, currentBranch);
const previous = state[stateKey];
let existingPdg: boolean | undefined;
try {
existingPdg = await deps.getExistingPdgMode(targetDir, currentBranch);
} catch (error: unknown) {
if (item.project.pdg === undefined) {
throw new Error(
`${error instanceof Error ? error.message : String(error)} ` +
'Refusing to analyze so the live graph is preserved.',
);
}
logger.warn(
`[auto-sync] Existing PDG mode could not be determined for ${targetDir}; ` +
`applying explicit pdg=${item.project.pdg}.`,
);
}
const requestedPdg = item.project.pdg ?? existingPdg!;
const pdgModeChanged = existingPdg === undefined || requestedPdg !== existingPdg;
let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped';
let analyzedCommitId = previous?.analyzedCommitId;
let analyzeConsecutiveFailures = previous?.analyzeConsecutiveFailures ?? 0;
@ -162,17 +210,32 @@ export async function runAutoSyncOnce(
const groupSyncPending = previous?.groupSyncPending === true;
let stats: RepoMeta['stats'] | undefined;
if (previous && previous.codeCommitId !== currentCommit) {
const previousRequestedPdg = previous?.requestedPdg ?? existingPdg;
if (
previous &&
(previous.codeCommitId !== currentCommit || previousRequestedPdg !== requestedPdg)
) {
analyzeConsecutiveFailures = 0;
lastAnalyzeError = undefined;
}
if (item.project.pdg === undefined && existingPdg) {
logger.info(
`[auto-sync] Preserving existing PDG mode for ${targetDir}; project configuration does not set pdg.`,
);
} else if (item.project.pdg === false && existingPdg) {
logger.warn(
`[auto-sync] PDG is explicitly disabled for ${targetDir}; the next successful rebuild will remove existing PDG data.`,
);
}
if (analyzeConsecutiveFailures >= config.analyzeFailureThreshold) {
analyzeStatus = 'threshold_skipped';
logger.error(
`[auto-sync] Skip analysis for ${targetDir}; analyze consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold} reached threshold. Fix the repository or clear auto-sync state before retrying.`,
);
} else if (
pdgModeChanged ||
shouldAnalyzeCommit({
currentCommit,
previousAnalyzedCommit: previous?.analyzedCommitId,
@ -182,7 +245,15 @@ export async function runAutoSyncOnce(
try {
const analysis = await deps.runAnalysis(
targetDir,
{ branch: currentBranch, skipAgentsMd: true, skipSkills: true },
{
branch: currentBranch,
skipAgentsMd: true,
skipSkills: true,
...(item.project.pdg === undefined
? { preserveExistingPdg: true }
: { pdg: requestedPdg }),
atomicIncremental: true,
},
config.analyzeTimeoutMs,
options.signal,
options.onAnalysisCancellationRequested,
@ -197,14 +268,21 @@ export async function runAutoSyncOnce(
} catch (err: unknown) {
if (options.signal?.aborted) throw err;
analyzeStatus = 'failed';
analyzeConsecutiveFailures += 1;
lastAnalyzeError = shortErrorMessage(err);
logger.error(
`[auto-sync] Analysis failed for ${targetDir}; consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold}: ${lastAnalyzeError}`,
);
if (isAbandonedAnalyzeWorkerError(err)) abandonedAnalysisWorker = true;
if (isRetryableAnalyzeContention(err)) {
logger.error(
`[auto-sync] Analysis failed for ${targetDir}; retryable leftover-worker or index-lock wait (not counted toward threshold): ${lastAnalyzeError}`,
);
} else {
analyzeConsecutiveFailures += 1;
logger.error(
`[auto-sync] Analysis failed for ${targetDir}; consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold}: ${lastAnalyzeError}`,
);
}
}
} else {
logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit unchanged.`);
logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit and PDG mode unchanged.`);
}
throwIfAborted(options.signal);
@ -220,6 +298,7 @@ export async function runAutoSyncOnce(
analyzeStatus,
analyzeConsecutiveFailures,
lastAnalyzeError,
requestedPdg,
groupSyncPending,
stats,
stateKey,
@ -303,6 +382,7 @@ export async function runAutoSyncOnce(
codeCommitId: repoResult.currentCommit,
analyzedCommitId,
lastAnalyzeStatus: analyzeStatus,
requestedPdg: repoResult.requestedPdg,
analyzeConsecutiveFailures,
lastAnalyzeError,
groupSyncPending: repoResult.groupSyncPending,
@ -378,6 +458,7 @@ export async function runAutoSyncOnce(
}
}
if (groupStateChanged) await deps.saveState(state);
if (abandonedAnalysisWorker) result.abandonedAnalysisWorker = true;
return result;
}
@ -386,6 +467,22 @@ function shortErrorMessage(err: unknown): string {
return message.replace(/\s+/g, ' ').slice(0, 240);
}
const ABANDONED_WORKER_MARKER = 'it was left running so its native work is not interrupted';
function isAbandonedAnalyzeWorkerError(err: unknown): boolean {
if (err instanceof AutoSyncAnalysisError && err.abandonedWorker) return true;
return err instanceof Error && err.message.includes(ABANDONED_WORKER_MARKER);
}
function isRetryableAnalyzeContention(err: unknown): boolean {
if (isAbandonedAnalyzeWorkerError(err)) return true;
return (
err instanceof AutoSyncAnalysisError &&
err.code === 'index-lock-timeout' &&
err.retryable === true
);
}
export function getConfiguredRepoPath(
project: Pick<AutoSyncProjectConfig, 'localPath'>,
repoName: string,

View file

@ -131,6 +131,7 @@ export async function startAutoSyncWatch(
let activeRun: Promise<void> | undefined;
let activeAbortController: AbortController | undefined;
let stopping = false;
let rerunRequested = false;
let statusWrite = Promise.resolve();
const updateStatus = (state: WatchStatusState, message?: string) => {
const write = statusWrite.then(() =>
@ -152,10 +153,12 @@ export async function startAutoSyncWatch(
const runSafely = () => {
if (stopping) return;
if (activeRun) {
stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n');
rerunRequested = true;
stderr.write('[auto-sync] Previous run is still active; queued one immediate follow-up.\n');
return;
}
const startedAt = new Date();
let deferFollowUp = false;
stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`);
const abortController = new AbortController();
const run = runOnce(loaded.config, {
@ -170,6 +173,7 @@ export async function startAutoSyncWatch(
},
})
.then((result) => {
deferFollowUp = result.abandonedAnalysisWorker === true;
stderr.write(
`[auto-sync] Watch loop finished: synced=${result.synced} analyzed=${result.analyzed} skipped=${result.skippedAnalysis} failed=${result.failed}.\n`,
);
@ -179,6 +183,8 @@ export async function startAutoSyncWatch(
stderr.write('[auto-sync] Watch loop finished: failed.\n');
})
.finally(async () => {
const runAgain = rerunRequested;
rerunRequested = false;
if (activeRun === run) {
activeRun = undefined;
activeAbortController = undefined;
@ -186,6 +192,15 @@ export async function startAutoSyncWatch(
if (!stopping) {
await updateStatus('running').catch(reportStatusWriteFailure);
}
if (runAgain && !stopping && !activeRun) {
if (deferFollowUp) {
stderr.write(
'[auto-sync] Previous run left an analyze worker running; deferring the coalesced follow-up to the next interval.\n',
);
} else {
runSafely();
}
}
});
activeRun = run;
activeAbortController = abortController;
@ -195,6 +210,7 @@ export async function startAutoSyncWatch(
const stop = () =>
(stopPromise ??= (async () => {
stopping = true;
rerunRequested = false;
clearIntervalFn(timer);
clearIntervalFn(controlTimer);
activeAbortController?.abort();

View file

@ -9,6 +9,11 @@ export interface AutoSyncCommitStateEntry {
codeCommitId: string;
analyzedCommitId?: string;
lastAnalyzeStatus?: AutoSyncAnalyzeStatus;
/**
* PDG mode from the most recent successful sync result. Written even when
* analysis was threshold-skipped and `runAnalysis` did not run.
*/
requestedPdg?: boolean;
analyzeConsecutiveFailures?: number;
lastAnalyzeError?: string;
groupSyncPending?: boolean;
@ -105,6 +110,7 @@ function isAutoSyncCommitStateEntry(value: unknown): value is AutoSyncCommitStat
entry.lastAnalyzeStatus === 'failed' ||
entry.lastAnalyzeStatus === 'skipped' ||
entry.lastAnalyzeStatus === 'threshold_skipped') &&
(entry.requestedPdg === undefined || typeof entry.requestedPdg === 'boolean') &&
(entry.analyzeConsecutiveFailures === undefined ||
(typeof entry.analyzeConsecutiveFailures === 'number' &&
Number.isInteger(entry.analyzeConsecutiveFailures) &&

View file

@ -0,0 +1,483 @@
/**
* Disk-backed restore cache for CodeEmbedding rows (#3306).
*
* `loadCachedEmbeddings` used to `getAll()` the table and `map(Number)` every
* vector into a JS `number[]`. On a large already-indexed repo that single
* structure OOMs the V8 heap during "Caching embeddings..." even when the
* incremental diff is a handful of nodes.
*
* This module keeps metadata in RAM and writes vectors to a temp Float32
* spill. Restore materializes only the rows that Phase 3.5 will re-insert,
* in the existing 200-row batches.
*/
import { closeSync, openSync, readSync, unlinkSync, writeSync } from 'node:fs';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomBytes } from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import type { CachedEmbedding } from './types.js';
/** In-RAM vector copies stay below this row count; larger tables use the spill. */
export const DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT = 2048;
const SPILL_MAGIC = 'GNXE';
const SPILL_VERSION = 1;
const SPILL_HEADER_BYTES = 12;
export interface CachedEmbeddingMeta {
nodeId: string;
chunkIndex: number;
startLine: number;
endLine: number;
contentHash?: string;
/** Row order in the spill file (and in `embeddings` when in-memory). */
vectorIndex: number;
}
export interface EmbeddingVectorSpill {
path: string;
dims: number;
rowCount: number;
}
export interface CachedEmbeddingsSnapshot {
embeddingNodeIds: Set<string>;
/** Populated only when the table is at or under the in-memory row limit. */
embeddings: CachedEmbedding[];
rows: CachedEmbeddingMeta[];
spill?: EmbeddingVectorSpill;
}
export interface LoadCachedEmbeddingsOptions {
/**
* Keep full `number[]` vectors in RAM at or below this many rows.
* `0` always spills. Default {@link DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT}
* or `GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT`.
*/
inMemoryRowLimit?: number;
/** Directory for the spill file (default `os.tmpdir()`). */
spillDir?: string;
}
export interface CachedEmbeddingsBuilder {
embeddingNodeIds: Set<string>;
rows: CachedEmbeddingMeta[];
/** Float32 vectors kept in RAM until the in-memory row limit is exceeded. */
inMemory: Float32Array[] | null;
inMemoryRowLimit: number;
writer: EmbeddingSpillWriter;
}
export function emptyCachedEmbeddingsSnapshot(): CachedEmbeddingsSnapshot {
return { embeddingNodeIds: new Set(), embeddings: [], rows: [] };
}
export function resolveEmbeddingCacheInMemoryRowLimit(override?: number): number {
if (override !== undefined) {
if (!Number.isFinite(override) || override < 0) {
return DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT;
}
return Math.floor(override);
}
const raw = process.env.GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT;
if (raw === undefined || raw === '') return DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT;
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) && parsed >= 0
? parsed
: DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT;
}
export function normalizeCachedEmbeddings(raw: {
embeddingNodeIds?: Set<string>;
embeddings?: CachedEmbedding[];
rows?: CachedEmbeddingMeta[];
spill?: EmbeddingVectorSpill;
}): CachedEmbeddingsSnapshot {
const embeddings = raw.embeddings ?? [];
const embeddingNodeIds = raw.embeddingNodeIds ?? new Set(embeddings.map((row) => row.nodeId));
const rows =
raw.rows ??
embeddings.map((row, vectorIndex) => ({
nodeId: row.nodeId,
chunkIndex: row.chunkIndex,
startLine: row.startLine,
endLine: row.endLine,
contentHash: row.contentHash,
vectorIndex,
}));
return { embeddingNodeIds, embeddings, rows, spill: raw.spill };
}
export function cacheRowCount(snapshot: CachedEmbeddingsSnapshot): number {
return snapshot.rows.length > 0 ? snapshot.rows.length : snapshot.embeddings.length;
}
export function snapshotEmbeddingDims(snapshot: CachedEmbeddingsSnapshot): number | undefined {
if (snapshot.spill && snapshot.spill.dims > 0) return snapshot.spill.dims;
const dims = snapshot.embeddings[0]?.embedding.length;
return dims && dims > 0 ? dims : undefined;
}
export function coerceEmbeddingToFloat32(embedding: unknown): Float32Array | null {
if (embedding == null) return null;
if (embedding instanceof Float32Array) {
return embedding.length > 0 ? embedding : null;
}
if (ArrayBuffer.isView(embedding) && !(embedding instanceof DataView)) {
const view = embedding as Exclude<ArrayBufferView, DataView> & { length: number };
if (view.length === 0) return null;
return Float32Array.from({ length: view.length }, (_, i) => Number(view[i]));
}
if (
typeof embedding === 'object' &&
typeof (embedding as Iterable<unknown>)[Symbol.iterator] === 'function'
) {
const arr = Array.isArray(embedding)
? (embedding as unknown[])
: Array.from(embedding as Iterable<unknown>);
if (arr.length === 0) return null;
return Float32Array.from(arr, (value) => Number(value));
}
return null;
}
export function float32ToNumberArray(vec: Float32Array): number[] {
const out = new Array<number>(vec.length);
for (let i = 0; i < vec.length; i++) out[i] = vec[i]!;
return out;
}
/**
* `fs.writeSync` can return a short byte count. Loop until the whole buffer
* lands, matching `sync-csv-writer.ts`, so a partial write never advances
* `rowCount` on a truncated vector.
*/
function writeAllSync(fd: number, data: Uint8Array): void {
let offset = 0;
while (offset < data.length) {
const n = writeSync(fd, data, offset, data.length - offset);
if (n <= 0) {
throw new Error(`embedding spill short write: wrote ${n} of ${data.length - offset} bytes`);
}
offset += n;
}
}
function unlinkBestEffort(filePath: string): void {
try {
unlinkSync(filePath);
} catch {
/* ENOENT or already removed */
}
}
const liveSpillPaths = new Set<string>();
const spillScope = new AsyncLocalStorage<Set<string>>();
let spillExitHookInstalled = false;
function trackLiveSpillPath(filePath: string): void {
liveSpillPaths.add(filePath);
spillScope.getStore()?.add(filePath);
if (!spillExitHookInstalled) {
spillExitHookInstalled = true;
process.on('exit', () => {
for (const spillPath of liveSpillPaths) {
unlinkBestEffort(spillPath);
}
});
}
}
function untrackLiveSpillPath(filePath: string): void {
liveSpillPaths.delete(filePath);
}
/** Best-effort unlink of every tracked spill. Safe to call more than once. */
export function discardLiveEmbeddingSpills(): void {
for (const spillPath of [...liveSpillPaths]) {
unlinkBestEffort(spillPath);
liveSpillPaths.delete(spillPath);
}
}
/** Run `fn` so later {@link discardScopedEmbeddingSpills} only unlinks this run. */
export function withEmbeddingSpillScope<T>(fn: () => T): T {
return spillScope.run(new Set(), fn);
}
/** Unlink spills created inside the current {@link withEmbeddingSpillScope}. */
export function discardScopedEmbeddingSpills(): void {
const owned = spillScope.getStore();
if (!owned) return;
for (const spillPath of [...owned]) {
unlinkBestEffort(spillPath);
liveSpillPaths.delete(spillPath);
owned.delete(spillPath);
}
}
export class EmbeddingSpillWriter {
readonly path: string;
dims = 0;
rowCount = 0;
private fd: number | null = null;
private closed = false;
constructor(dir: string) {
this.path = path.join(
dir,
`gitnexus-embed-restore-${process.pid}-${randomBytes(8).toString('hex')}.bin`,
);
}
append(vec: Float32Array): void {
if (this.closed) {
throw new Error('embedding spill writer already closed');
}
if (this.fd === null) {
this.dims = vec.length;
this.fd = openSync(this.path, 'wx', 0o600);
trackLiveSpillPath(this.path);
const header = Buffer.alloc(SPILL_HEADER_BYTES);
header.write(SPILL_MAGIC, 0, 4, 'ascii');
header.writeUInt8(SPILL_VERSION, 4);
header.writeUInt32LE(this.dims, 5);
writeAllSync(this.fd, header);
} else if (vec.length !== this.dims) {
throw new Error(
`embedding dim mismatch while spilling: got ${vec.length}, expected ${this.dims}`,
);
}
writeAllSync(this.fd, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength));
this.rowCount++;
}
finish(): EmbeddingVectorSpill | undefined {
if (this.fd !== null) {
closeSync(this.fd);
this.fd = null;
}
this.closed = true;
if (this.rowCount === 0) {
this.unlinkQuiet();
return undefined;
}
return { path: this.path, dims: this.dims, rowCount: this.rowCount };
}
abort(): void {
const opened = this.fd !== null;
if (this.fd !== null) {
try {
closeSync(this.fd);
} catch {
/* already closed */
}
this.fd = null;
}
this.closed = true;
if (opened || this.rowCount > 0) {
this.unlinkQuiet();
}
}
private unlinkQuiet(): void {
unlinkBestEffort(this.path);
untrackLiveSpillPath(this.path);
}
}
/** Validates the spill header once and reads vectors without reopening the file. */
export class EmbeddingSpillReader {
private fd: number | null = null;
private readonly bytesPerVec: number;
readonly dims: number;
readonly rowCount: number;
constructor(spill: EmbeddingVectorSpill) {
this.rowCount = spill.rowCount;
const fd = openSync(spill.path, 'r');
try {
const header = Buffer.alloc(SPILL_HEADER_BYTES);
const headerRead = readSync(fd, header, 0, SPILL_HEADER_BYTES, 0);
if (headerRead !== SPILL_HEADER_BYTES || header.toString('ascii', 0, 4) !== SPILL_MAGIC) {
throw new Error(`invalid embedding spill header: ${spill.path}`);
}
if (header.readUInt8(4) !== SPILL_VERSION) {
throw new Error(`unsupported embedding spill version in ${spill.path}`);
}
const dims = header.readUInt32LE(5);
if (dims !== spill.dims) {
throw new Error(`embedding spill dim mismatch: file ${dims}, expected ${spill.dims}`);
}
this.dims = dims;
this.bytesPerVec = dims * 4;
this.fd = fd;
} catch (err) {
closeSync(fd);
throw err;
}
}
read(indices: readonly number[]): Float32Array[] {
if (this.fd === null) {
throw new Error('embedding spill reader already closed');
}
const out: Float32Array[] = [];
for (const index of indices) {
if (!Number.isInteger(index) || index < 0 || index >= this.rowCount) {
throw new Error(`embedding spill index out of range: ${index}`);
}
const offset = SPILL_HEADER_BYTES + index * this.bytesPerVec;
const copy = new Float32Array(this.dims);
const bytes = new Uint8Array(copy.buffer, copy.byteOffset, this.bytesPerVec);
const n = readSync(this.fd, bytes, 0, this.bytesPerVec, offset);
if (n !== this.bytesPerVec) {
throw new Error(`short embedding spill read at index ${index}`);
}
out.push(copy);
}
return out;
}
close(): void {
if (this.fd === null) return;
closeSync(this.fd);
this.fd = null;
}
}
export function readSpillVectors(
spill: EmbeddingVectorSpill,
indices: readonly number[],
): Float32Array[] {
const reader = new EmbeddingSpillReader(spill);
try {
return reader.read(indices);
} finally {
reader.close();
}
}
export function disposeEmbeddingSpill(spill?: EmbeddingVectorSpill): void {
if (!spill?.path) return;
unlinkBestEffort(spill.path);
untrackLiveSpillPath(spill.path);
}
export function createCachedEmbeddingsBuilder(
options?: LoadCachedEmbeddingsOptions,
): CachedEmbeddingsBuilder {
const inMemoryRowLimit = resolveEmbeddingCacheInMemoryRowLimit(options?.inMemoryRowLimit);
return {
embeddingNodeIds: new Set(),
rows: [],
inMemory: inMemoryRowLimit <= 0 ? null : [],
inMemoryRowLimit,
writer: new EmbeddingSpillWriter(options?.spillDir ?? os.tmpdir()),
};
}
export function ingestCachedEmbeddingRow(
builder: CachedEmbeddingsBuilder,
row: Record<string, unknown> | unknown[],
hasContentHash: boolean,
): void {
const rec = row as Record<string, unknown> & unknown[];
const nodeId = String(rec.nodeId ?? rec[0] ?? '');
if (!nodeId) return;
const embedding = rec.embedding ?? rec[4];
const f32 = coerceEmbeddingToFloat32(embedding);
if (!f32) return;
builder.embeddingNodeIds.add(nodeId);
const meta: CachedEmbeddingMeta = {
nodeId,
chunkIndex: Number(rec.chunkIndex ?? rec[1] ?? 0),
startLine: Number(rec.startLine ?? rec[2] ?? 0),
endLine: Number(rec.endLine ?? rec[3] ?? 0),
contentHash: hasContentHash
? ((rec.contentHash ?? rec[5] ?? undefined) as string | undefined)
: undefined,
vectorIndex: builder.rows.length,
};
builder.rows.push(meta);
if (builder.inMemory && builder.rows.length <= builder.inMemoryRowLimit) {
builder.inMemory.push(f32);
return;
}
if (builder.inMemory) {
for (const prior of builder.inMemory) {
builder.writer.append(prior);
}
builder.inMemory = null;
}
builder.writer.append(f32);
}
export function finalizeCachedEmbeddingsSnapshot(
builder: CachedEmbeddingsBuilder,
): CachedEmbeddingsSnapshot {
const inMemory = builder.inMemory;
if (inMemory) {
builder.writer.abort();
return {
embeddingNodeIds: builder.embeddingNodeIds,
embeddings: builder.rows.map((meta, i) => ({
nodeId: meta.nodeId,
chunkIndex: meta.chunkIndex,
startLine: meta.startLine,
endLine: meta.endLine,
contentHash: meta.contentHash,
embedding: float32ToNumberArray(inMemory[i]!),
})),
rows: builder.rows,
};
}
return {
embeddingNodeIds: builder.embeddingNodeIds,
embeddings: [],
rows: builder.rows,
spill: builder.writer.finish(),
};
}
export function abortCachedEmbeddingsBuilder(builder: CachedEmbeddingsBuilder): void {
builder.writer.abort();
}
export function materializeCachedEmbeddings(
snapshot: CachedEmbeddingsSnapshot,
metas: readonly CachedEmbeddingMeta[],
spillReader?: EmbeddingSpillReader,
): CachedEmbedding[] {
if (metas.length === 0) return [];
if (snapshot.spill && snapshot.embeddings.length === 0) {
const indices = metas.map((meta) => meta.vectorIndex);
const vectors = spillReader
? spillReader.read(indices)
: readSpillVectors(snapshot.spill, indices);
return metas.map((meta, i) => ({
nodeId: meta.nodeId,
chunkIndex: meta.chunkIndex,
startLine: meta.startLine,
endLine: meta.endLine,
contentHash: meta.contentHash,
embedding: float32ToNumberArray(vectors[i]!),
}));
}
if (snapshot.embeddings.length === 0) return [];
const byKey = new Map(
snapshot.embeddings.map((row) => [`${row.nodeId}:${row.chunkIndex}`, row] as const),
);
return metas.map((meta) => {
const hit =
byKey.get(`${meta.nodeId}:${meta.chunkIndex}`) ?? snapshot.embeddings[meta.vectorIndex];
if (!hit) {
throw new Error(`missing cached embedding ${meta.nodeId}:${meta.chunkIndex}`);
}
return hit;
});
}

View file

@ -34,7 +34,16 @@ import type { GraphEmitManifest } from './graph-emit-sink.js';
import type { PdgEmitManifest } from './pdg-emit-sink.js';
import { PDG_EDGE_TYPES } from './pdg-emit-sink.js';
import { getNodeLabel as deriveNodeLabel, type WriteStreamFactory } from './rel-pair-routing.js';
import { EMBEDDABLE_LABELS, type CachedEmbedding } from '../embeddings/types.js';
import { EMBEDDABLE_LABELS } from '../embeddings/types.js';
import {
abortCachedEmbeddingsBuilder,
createCachedEmbeddingsBuilder,
emptyCachedEmbeddingsSnapshot,
finalizeCachedEmbeddingsSnapshot,
ingestCachedEmbeddingRow,
type CachedEmbeddingsSnapshot,
type LoadCachedEmbeddingsOptions,
} from '../embeddings/embedding-restore-spill.js';
import {
extensionManager,
getFtsCapability,
@ -2069,28 +2078,31 @@ export const getLbugStats = async (): Promise<{
/**
* Load cached embeddings from LadybugDB before a rebuild.
* Returns all embedding vectors so they can be re-inserted after the graph is reloaded,
* avoiding expensive re-embedding of unchanged nodes.
*
* Streams `CodeEmbedding` rows with `hasNext`/`getNext` under `withConnLock`
* (#2264, #3306). Vectors are spilled to a temp Float32 file once the table
* exceeds the in-memory row limit so incremental analyze cannot OOM the V8
* heap by materializing every `number[]` up front. Small tables still return
* in-RAM `embeddings` for existing callers/tests.
*
* Detects old schema (no chunkIndex column) and returns empty cache to trigger rebuild.
*/
export const loadCachedEmbeddings = async (): Promise<{
embeddingNodeIds: Set<string>;
embeddings: CachedEmbedding[];
}> => {
export const loadCachedEmbeddings = async (
options?: LoadCachedEmbeddingsOptions,
): Promise<CachedEmbeddingsSnapshot> => {
const c = conn;
if (!c) {
return { embeddingNodeIds: new Set(), embeddings: [] };
return emptyCachedEmbeddingsSnapshot();
}
// The whole read runs inside the connection lock (#2264 review P2). It's safe
// today only by call-ordering (loadCachedEmbeddings runs before the WAL driver
// starts), but the lock makes it robust to future reordering — a concurrent
// CHECKPOINT on the singleton connection is the documented corruption trigger.
// Leaf read: no nested withConnLock-wrapped helpers inside.
// Leaf read: no nested withConnLock-wrapped helpers inside. Do NOT call
// `streamQuery` here — that path is unlocked and would race a CHECKPOINT.
return withConnLock(async () => {
const embeddingNodeIds = new Set<string>();
const embeddings: CachedEmbedding[] = [];
const builder = createCachedEmbeddingsBuilder(options);
try {
// Schema migration detection: query with new columns to verify schema version.
// Old schema only had (nodeId, embedding); new schema adds (id, chunkIndex, startLine, endLine, contentHash).
@ -2104,51 +2116,46 @@ export const loadCachedEmbeddings = async (): Promise<{
);
await readQueryRows(check);
} catch {
return { embeddingNodeIds: new Set(), embeddings: [] };
abortCachedEmbeddingsBuilder(builder);
return emptyCachedEmbeddingsSnapshot();
}
// Try to read contentHash alongside chunk columns
let rows: any;
let queryResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
let hasContentHash = true;
try {
rows = await c.query(
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash`,
);
} catch (err: any) {
// Fallback for legacy DBs without contentHash column
const msg = err?.message ?? '';
if (isMissingColumnOrTableError(msg)) {
hasContentHash = false;
rows = await c.query(
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding`,
try {
queryResult = await c.query(
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash`,
);
} else {
throw err;
} catch (err: any) {
// Fallback for legacy DBs without contentHash column
const msg = err?.message ?? '';
if (isMissingColumnOrTableError(msg)) {
hasContentHash = false;
queryResult = await c.query(
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding`,
);
} else {
throw err;
}
}
}
for (const row of await readQueryRows(rows)) {
const nodeId = String(row.nodeId ?? row[0] ?? '');
if (!nodeId) continue;
embeddingNodeIds.add(nodeId);
const embedding = row.embedding ?? row[4];
if (embedding) {
embeddings.push({
nodeId,
chunkIndex: Number(row.chunkIndex ?? row[1] ?? 0),
startLine: Number(row.startLine ?? row[2] ?? 0),
endLine: Number(row.endLine ?? row[3] ?? 0),
embedding: Array.isArray(embedding)
? embedding.map(Number)
: Array.from(embedding as any).map(Number),
contentHash: hasContentHash ? (row.contentHash ?? row[5] ?? undefined) : undefined,
});
const results = Array.isArray(queryResult) ? queryResult : [queryResult];
const result = results[0];
while (await result.hasNext()) {
const row = await result.getNext();
ingestCachedEmbeddingRow(builder, row, hasContentHash);
}
return finalizeCachedEmbeddingsSnapshot(builder);
} catch (err) {
abortCachedEmbeddingsBuilder(builder);
throw err;
} finally {
if (queryResult) await closeQueryResults(queryResult);
}
} catch {
/* embedding table may not exist */
} catch (err) {
abortCachedEmbeddingsBuilder(builder);
throw err;
}
return { embeddingNodeIds, embeddings };
});
};

View file

@ -221,7 +221,18 @@ import {
} from '../storage/git.js';
import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js';
import { getMaxFileSizeBytes } from './ingestion/utils/max-file-size.js';
import type { CachedEmbedding } from './embeddings/types.js';
import {
cacheRowCount,
discardScopedEmbeddingSpills,
disposeEmbeddingSpill,
withEmbeddingSpillScope,
emptyCachedEmbeddingsSnapshot,
EmbeddingSpillReader,
materializeCachedEmbeddings,
normalizeCachedEmbeddings,
snapshotEmbeddingDims,
type CachedEmbeddingsSnapshot,
} from './embeddings/embedding-restore-spill.js';
import { generateAIContextFiles } from '../cli/ai-context.js';
import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
import {
@ -427,6 +438,11 @@ export interface AnalyzeOptions {
* scope-resolution (BasicBlock/CFG emit gate). Off by default.
*/
pdg?: boolean;
/**
* Internal auto-sync mode: resolve `pdg` from the live index metadata only
* after acquiring its writer lock. An explicit `pdg` value always wins.
*/
preserveExistingPdg?: boolean;
/** Per-function source-line cap for worker-side CFG construction (#2081 M1).
* Forwarded to `PipelineOptions.pdgMaxFunctionLines`. No CLI flag in M1 —
* programmatic / server analyze-worker path only; the worker applies
@ -1161,59 +1177,64 @@ export async function runFullAnalysis(
let writeTarget = await resolveWriteTarget(repoPath, options);
let lock = await acquireIndexLock(writeTarget.metaDir, acquireOpts);
try {
requireExclusiveIndexLock(
lock,
`Cannot acquire the index lock at ${writeTarget.metaDir}; refusing an unlocked analysis.`,
);
// #2658 review H2: acquireIndexLock can wait up to the timeout ceiling,
// during which git HEAD/branch — and thus the resolved write slot — may
// change (a commit lands, a branch is switched, or another writer adopts the
// flat slot). The pre-wait snapshot must NOT be reused: re-resolve UNDER the
// lock so the freshness check (`existingMeta.lastCommit === currentCommit`)
// and the meta stamps see current git state, honoring the module's "re-check
// freshness after acquiring" contract. If the slot itself moved we hold the
// WRONG lock — release and re-acquire the correct one. Bounded so a
// pathologically churning checkout can't loop forever; after the cap we
// proceed on the current lock. The loop is INSIDE the try so a re-resolve
// that throws (e.g. a `--branch` that stopped matching the now-switched
// checkout) still releases the held lock via `finally` (no leak).
const MAX_RELOCK = 3;
for (let attempt = 0; attempt < MAX_RELOCK; attempt++) {
// Never pass the pre-lock storagePath as already-validated: requireStoragePath
// must run again under the lock so a now-foreign slot aborts (and finally
// still releases the lock).
const fresh = await resolveWriteTarget(repoPath, options);
if (fresh.metaDir === writeTarget.metaDir) {
writeTarget = fresh; // same slot — adopt the freshly-read commit/branch/placement
break;
}
log(
`Index write target moved while waiting for the lock ` +
`(${writeTarget.metaDir} → ${fresh.metaDir}); re-acquiring the correct slot.`,
);
lock.release();
writeTarget = fresh;
lock = await acquireIndexLock(fresh.metaDir, acquireOpts);
return withEmbeddingSpillScope(async () => {
try {
requireExclusiveIndexLock(
lock,
`Cannot acquire the index lock at ${fresh.metaDir}; refusing an unlocked analysis.`,
`Cannot acquire the index lock at ${writeTarget.metaDir}; refusing an unlocked analysis.`,
);
if (attempt === MAX_RELOCK - 1) {
log('Index write target still moving after repeated re-acquire; proceeding on this lock.');
// #2658 review H2: acquireIndexLock can wait up to the timeout ceiling,
// during which git HEAD/branch — and thus the resolved write slot — may
// change (a commit lands, a branch is switched, or another writer adopts the
// flat slot). The pre-wait snapshot must NOT be reused: re-resolve UNDER the
// lock so the freshness check (`existingMeta.lastCommit === currentCommit`)
// and the meta stamps see current git state, honoring the module's "re-check
// freshness after acquiring" contract. If the slot itself moved we hold the
// WRONG lock — release and re-acquire the correct one. Bounded so a
// pathologically churning checkout can't loop forever; after the cap we
// proceed on the current lock. The loop is INSIDE the try so a re-resolve
// that throws (e.g. a `--branch` that stopped matching the now-switched
// checkout) still releases the held lock via `finally` (no leak).
const MAX_RELOCK = 3;
for (let attempt = 0; attempt < MAX_RELOCK; attempt++) {
// Never pass the pre-lock storagePath as already-validated: requireStoragePath
// must run again under the lock so a now-foreign slot aborts (and finally
// still releases the lock).
const fresh = await resolveWriteTarget(repoPath, options);
if (fresh.metaDir === writeTarget.metaDir) {
writeTarget = fresh; // same slot — adopt the freshly-read commit/branch/placement
break;
}
log(
`Index write target moved while waiting for the lock ` +
`(${writeTarget.metaDir} → ${fresh.metaDir}); re-acquiring the correct slot.`,
);
lock.release();
writeTarget = fresh;
lock = await acquireIndexLock(fresh.metaDir, acquireOpts);
requireExclusiveIndexLock(
lock,
`Cannot acquire the index lock at ${fresh.metaDir}; refusing an unlocked analysis.`,
);
if (attempt === MAX_RELOCK - 1) {
log(
'Index write target still moving after repeated re-acquire; proceeding on this lock.',
);
}
}
return await runFullAnalysisInner(
repoPath,
options,
callbacks,
writeTarget,
contentRetention,
runnerIdentityAtBootstrap,
);
} finally {
discardScopedEmbeddingSpills();
lock.release();
}
return await runFullAnalysisInner(
repoPath,
options,
callbacks,
writeTarget,
contentRetention,
runnerIdentityAtBootstrap,
);
} finally {
lock.release();
}
});
}
async function runFullAnalysisInner(
@ -1275,6 +1296,28 @@ async function runFullAnalysisInner(
}
const loadedMeta = await loadMeta(metaDir);
if (options.preserveExistingPdg && options.pdg === undefined) {
if (loadedMeta) {
options = { ...options, pdg: loadedMeta.pdg !== undefined };
} else {
try {
await fs.stat(lbugPath);
} catch (error: unknown) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
options = { ...options, pdg: false };
} else {
throw error;
}
}
if (options.pdg === undefined) {
throw new Error(
`Cannot determine whether the existing index at ${lbugPath} contains PDG data; ` +
'refusing to analyze so the live graph is preserved.',
);
}
}
}
const previousFtsDisabledReason = getFtsDisabledReason(loadedMeta?.capabilities?.fts);
// Flag and env are equivalent disablements. Only a true enable↔disable flip
// needs a write plan; a discriminator-only change restamps on the
@ -2174,8 +2217,18 @@ async function runFullAnalysisInner(
// The default-preserve branch is what makes a routine `analyze` (e.g. a
// post-commit hook) safe: a multi-minute embedding pass is no longer
// silently dropped just because the caller omitted `--embeddings`.
let cachedEmbeddingNodeIds = new Set<string>();
let cachedEmbeddings: CachedEmbedding[] = [];
let cachedSnapshot: CachedEmbeddingsSnapshot = emptyCachedEmbeddingsSnapshot();
const adoptCachedEmbeddings = (raw: CachedEmbeddingsSnapshot): void => {
cachedSnapshot = normalizeCachedEmbeddings(raw);
};
const discardCachedEmbeddings = (): void => {
disposeEmbeddingSpill(cachedSnapshot.spill);
cachedSnapshot = emptyCachedEmbeddingsSnapshot();
};
const discardCachedEmbeddingSpill = (): void => {
disposeEmbeddingSpill(cachedSnapshot.spill);
cachedSnapshot = { ...cachedSnapshot, spill: undefined };
};
const existingEmbeddingCount = existingMeta?.stats?.embeddings ?? 0;
const {
@ -2210,7 +2263,7 @@ async function runFullAnalysisInner(
// of the predicted `willTryIncremental`). The post-pipeline branch may
// disagree with the prediction (e.g. when the pipeline produces zero
// File nodes, `isIncremental` flips false and the full-rebuild path
// wipes the DB) — loading unconditionally is cheap insurance against
// wipes the DB) — loading unconditionally is insurance against
// silently dropping embeddings on a mispredicted run. The re-insert
// step gates itself on the actual `isIncremental` value to avoid
// PK-conflicts when the incremental writeback path keeps the rows.
@ -2224,9 +2277,7 @@ async function runFullAnalysisInner(
try {
progress('embeddings', 0, 'Caching embeddings...');
await initAnalysisLbug(lbugPath);
const cached = await loadCachedEmbeddings();
cachedEmbeddingNodeIds = cached.embeddingNodeIds;
cachedEmbeddings = cached.embeddings;
adoptCachedEmbeddings(await loadCachedEmbeddings());
await closeLbug();
} catch (err: any) {
// Surface cache-load failures explicitly: silently swallowing here would
@ -2237,8 +2288,7 @@ async function runFullAnalysisInner(
`(${err?.message ?? String(err)}). ` +
`Embeddings will not be preserved on this run.`,
);
cachedEmbeddingNodeIds = new Set<string>();
cachedEmbeddings = [];
discardCachedEmbeddings();
try {
await closeLbug();
} catch {
@ -2351,6 +2401,7 @@ async function runFullAnalysisInner(
},
);
} catch (err) {
discardCachedEmbeddingSpill();
await removeColdParseRebuildDir(coldParseRebuildDir, true);
throw err;
}
@ -2587,6 +2638,7 @@ async function runFullAnalysisInner(
try {
await wipeLbugDbFiles(buildPath);
} catch (error) {
discardCachedEmbeddingSpill();
if (liveIndexMutationStarted) recordLiveIndexMutationRisk(error);
throw error;
}
@ -2615,6 +2667,7 @@ async function runFullAnalysisInner(
try {
await initAnalysisLbug(buildPath);
} catch (error) {
discardCachedEmbeddingSpill();
if (liveIndexMutationStarted) recordLiveIndexMutationRisk(error);
throw error;
}
@ -2936,7 +2989,7 @@ async function runFullAnalysisInner(
const extensionForcedRebuild = !embeddingRowDmlSafe || !ftsRowDmlSafe;
// `!options.dropEmbeddings` (H1): this rescue reads the rows back OUT of
// the DB, so it must never fire on the one path whose entire purpose is to
// destroy them. `--drop-embeddings` deliberately leaves `cachedEmbeddings`
// destroy them. `--drop-embeddings` deliberately leaves `cachedSnapshot`
// empty (`deriveEmbeddingMode` returns `shouldLoadCache: false` for it by
// construction — see the four-mode comment at the cache-load site), and its
// `options.force = true` conversion sits INSIDE
@ -2951,12 +3004,16 @@ async function runFullAnalysisInner(
// while rows survive ⇒ `hasExisting` false ⇒ `shouldLoadCache` false), i.e.
// it would fix the wipe by deleting the safeguard. Covers
// `--drop-embeddings --embeddings` too — the rescue repopulates
// `cachedEmbeddingNodeIds`, which Phase 4 hands `runEmbeddingPipeline` as
// `cachedSnapshot.embeddingNodeIds`, which Phase 4 hands `runEmbeddingPipeline` as
// the already-embedded set, so the very nodes the user asked to REGENERATE
// would be skipped.
if (extensionForcedRebuild && !options.dropEmbeddings && cachedEmbeddings.length === 0) {
if (
extensionForcedRebuild &&
!options.dropEmbeddings &&
cacheRowCount(cachedSnapshot) === 0
) {
// The escalation below WIPES the DB files, and Phase 3.5 restores
// embedding rows from `cachedEmbeddings` — which is only populated when
// embedding rows from `cachedSnapshot` — which is only populated when
// `deriveEmbeddingMode` saw `meta.stats.embeddings > 0`. A DB whose meta
// under-reports its embeddings (meta restored from an older run, or a
// count that never got stamped) would therefore have every vector
@ -2964,14 +3021,21 @@ async function runFullAnalysisInner(
// while the DB is still intact — a plain MATCH, which needs no VECTOR
// extension. Rows whose owning node is gone are dropped by Phase 3.5's
// live-graph filter, exactly as on any other wiped path.
const rescued = await loadCachedEmbeddings();
if (rescued.embeddings.length > 0) {
cachedEmbeddings = rescued.embeddings;
cachedEmbeddingNodeIds = rescued.embeddingNodeIds;
try {
adoptCachedEmbeddings(await loadCachedEmbeddings());
if (cacheRowCount(cachedSnapshot) > 0) {
log(
`Preserving ${cacheRowCount(cachedSnapshot)} embedding row(s) across the forced rebuild ` +
`(the index metadata did not account for them).`,
);
}
} catch (err: any) {
log(
`Preserving ${rescued.embeddings.length} embedding row(s) across the forced rebuild ` +
`(the index metadata did not account for them).`,
`Warning: could not load cached embeddings ` +
`(${err?.message ?? String(err)}). ` +
`Embeddings will not be preserved on this run.`,
);
discardCachedEmbeddings();
}
}
// Hoisted out of the `||` below (§5.D): the size verdict has to be KNOWN
@ -3597,26 +3661,27 @@ async function runFullAnalysisInner(
// propagates errors (a completed writeback means a deterministic
// delete outcome) and this process holds the exclusive DB lock (no
// concurrent writer).
// The per-batch try/catch stays as a last-resort guard only — it no
// longer fires on the happy path.
// Materialize runs outside the insert catch so a spill I/O failure is not
// treated as a benign PK conflict. Any node with a failed restore batch is
// marked stale in the Phase 4 map so leftover chunks are deleted and rembedded.
let restoredEmbeddingCount = 0;
if (cachedEmbeddings.length > 0) {
const cachedDims = cachedEmbeddings[0].embedding.length;
const restoreFailedNodeIds = new Set<string>();
if (cacheRowCount(cachedSnapshot) > 0) {
const cachedDims = snapshotEmbeddingDims(cachedSnapshot);
const { EMBEDDING_DIMS } = await import('./lbug/schema.js');
if (cachedDims !== EMBEDDING_DIMS) {
if (cachedDims !== undefined && cachedDims !== EMBEDDING_DIMS) {
// Dimensions changed (e.g. switched embedding model) — discard cache and re-embed all
log(
`Embedding dimensions changed (${cachedDims}d -> ${EMBEDDING_DIMS}d), discarding cache`,
);
cachedEmbeddings = [];
cachedEmbeddingNodeIds = new Set();
discardCachedEmbeddings();
} else {
const { batchInsertEmbeddings: batchInsert } =
await import('./embeddings/embedding-pipeline.js');
// (1) Live-graph filter — the FULL pipeline graph (always produced),
// NOT the incremental subgraph, or unchanged files' rows would be
// dropped from the restore set.
const liveEmbeddings = cachedEmbeddings.filter(
const liveEmbeddings = cachedSnapshot.rows.filter(
(e) => pipelineResult.graph.getNode(e.nodeId) !== undefined,
);
// (2) Restore-scope filter (see the discipline note above).
@ -3629,13 +3694,36 @@ async function runFullAnalysisInner(
});
progress('embeddings', 88, `Restoring ${rowsToRestore.length} cached embeddings...`);
const EMBED_BATCH = 200;
for (const batch of chunk(rowsToRestore, EMBED_BATCH)) {
try {
await batchInsert(executeWithReusedStatement, batch);
restoredEmbeddingCount += batch.length;
} catch {
/* last-resort guard — conflict-free by construction above */
let spillReader: EmbeddingSpillReader | undefined;
try {
for (const batch of chunk(rowsToRestore, EMBED_BATCH)) {
let materialized;
try {
if (!spillReader && cachedSnapshot.spill && cachedSnapshot.embeddings.length === 0) {
spillReader = new EmbeddingSpillReader(cachedSnapshot.spill);
}
materialized = materializeCachedEmbeddings(cachedSnapshot, batch, spillReader);
} catch (err) {
for (const row of batch) restoreFailedNodeIds.add(row.nodeId);
log(
`Warning: could not materialize ${batch.length} cached embedding(s) for restore ` +
`(${(err as Error).message}); those nodes will be re-embedded if this run generates embeddings.`,
);
continue;
}
try {
await batchInsert(executeWithReusedStatement, materialized);
restoredEmbeddingCount += batch.length;
} catch (err) {
for (const row of batch) restoreFailedNodeIds.add(row.nodeId);
log(
`Warning: could not restore ${batch.length} cached embedding(s) ` +
`(${(err as Error).message}); those nodes will be re-embedded if this run generates embeddings.`,
);
}
}
} finally {
spillReader?.close();
}
// Legacy-orphan sweep (FIX 3, finder B): the live-graph filter's
@ -3652,7 +3740,7 @@ async function runFullAnalysisInner(
// sweep failure must never fail a completed writeback, so the whole
// sweep warns-and-continues.
if (deletedFilePathsForRestore !== null) {
const orphanRowIds = cachedEmbeddings
const orphanRowIds = cachedSnapshot.rows
.filter((e) => pipelineResult.graph.getNode(e.nodeId) === undefined)
.map((e) => `${e.nodeId}:${e.chunkIndex}`);
if (orphanRowIds.length > 0) {
@ -3681,6 +3769,9 @@ async function runFullAnalysisInner(
}
}
}
// Vectors are on disk only to survive the wipe/delete. After restore,
// drop the spill so Phase 4 does not keep a multi-GB temp file open.
discardCachedEmbeddingSpill();
// ── Phase 4: Embeddings (90–98%) ──────────────────────────────────
const stats = await getLbugStats();
@ -3930,9 +4021,16 @@ async function runFullAnalysisInner(
const embeddingIdentity = embeddingIdentityForRun;
// Build a Map<nodeId, contentHash> from cached embeddings for incremental mode
let existingEmbeddings: Map<string, string> | undefined;
if (cachedEmbeddingNodeIds.size > 0) {
if (cachedSnapshot.embeddingNodeIds.size > 0) {
existingEmbeddings = new Map<string, string>();
for (const e of cachedEmbeddings) {
for (const e of cachedSnapshot.rows) {
if (restoreFailedNodeIds.has(e.nodeId)) {
// Any failed batch for this node: mark stale so Phase 4 DELETEs
// leftover chunks and re-embeds. Omitting the id would treat the
// node as new and PK-conflict on rows that already restored.
existingEmbeddings.set(e.nodeId, STALE_HASH_SENTINEL);
continue;
}
existingEmbeddings.set(e.nodeId, e.contentHash ?? STALE_HASH_SENTINEL);
}
}
@ -4009,7 +4107,7 @@ async function runFullAnalysisInner(
progress('embeddings', scaled, label);
},
{},
cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined,
cachedSnapshot.embeddingNodeIds.size > 0 ? cachedSnapshot.embeddingNodeIds : undefined,
existingEmbeddings,
{
forceReembedNodeIds: pendingEmbeddingNodeIds,
@ -4691,6 +4789,7 @@ async function runFullAnalysisInner(
}
}
await removeColdParseRebuildDir(coldParseRebuildDir, true);
discardCachedEmbeddingSpill();
if (liveIndexMutationStarted) {
// Preserve the original error identity/prototype: callers distinguish
// IndexLockTimeoutError and other domain failures with `instanceof`.

View file

@ -4,18 +4,17 @@
* Those sets are module-private, and exporting them purely to be testable would
* widen a production surface to satisfy a test — the call
* `receiver-twin-list-drift.test.ts` documents. So the guards read the source
* instead, through the TypeScript parser the repo already vendors and already
* uses this way (`literal-collectors.ts`, `query-determinism-guard.test.ts`,
* `cli-index-help.test.ts`).
* instead, through `@babel/parser` (`parse-typescript-source.ts`).
*
* Using the real parser is what makes the guards trustworthy. A text scanner has
* to decide whether a delimiter opens a comment or sits inside a string, and it
* gets that wrong in both directions here: the ignore-list comments quote paths
* and carry an apostrophe (`Next.js's`), while a glob string such as `'** / *'`
* contains a comment-open sequence. It also has to guess which bracket belongs
* to the declaration rather than to a type annotation. Each of those is a way to
* silently read fewer members — and a guard that quietly stops seeing members is
* the exact defect these guards exist to catch.
* Using an AST parser for TypeScript syntax is what makes the guards
* trustworthy. A text scanner has to decide whether a delimiter opens a comment
* or sits inside a string, and it gets that wrong in both directions here: the
* ignore-list comments quote paths and carry an apostrophe (`Next.js's`), while
* a glob string such as `'** / *'` contains a comment-open sequence. It also
* has to guess which bracket belongs to the declaration rather than to a type
* annotation. Each of those is a way to silently read fewer members — and a
* guard that quietly stops seeing members is the exact defect these guards
* exist to catch.
*
* `setEntries` therefore refuses anything that is not a plain list of string
* literals, rather than skipping the members it cannot resolve.
@ -23,7 +22,8 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import * as t from '@babel/types';
import { forEachChild, nodeText, parseTypeScript } from './parse-typescript-source.js';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
@ -55,46 +55,44 @@ export const readSource = (file: string): string => readFileSync(file, 'utf8');
* concatenation, a computed value).
*/
export const setEntries = (source: string, setName: string): string[] => {
const sourceFile = ts.createSourceFile(
'ignore-set-source.ts',
source,
ts.ScriptTarget.Latest,
true,
);
const { ast } = parseTypeScript('ignore-set-source.ts', source);
let elements: ts.NodeArray<ts.Expression> | undefined;
const visit = (node: ts.Node): void => {
let elements: t.ArrayExpression['elements'] | undefined;
const visit = (node: t.Node): void => {
if (
elements === undefined &&
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === setName &&
node.initializer !== undefined &&
ts.isNewExpression(node.initializer) &&
node.initializer.arguments?.length === 1 &&
ts.isArrayLiteralExpression(node.initializer.arguments[0])
t.isVariableDeclarator(node) &&
t.isIdentifier(node.id) &&
node.id.name === setName &&
node.init !== undefined &&
node.init !== null &&
t.isNewExpression(node.init) &&
node.init.arguments.length === 1 &&
t.isArrayExpression(node.init.arguments[0])
) {
elements = node.initializer.arguments[0].elements;
elements = node.init.arguments[0].elements;
return;
}
ts.forEachChild(node, visit);
forEachChild(node, visit);
};
visit(sourceFile);
visit(ast);
if (elements === undefined) {
throw new Error(`${setName} is not declared as \`new Set([...])\` — update this test`);
}
const unresolvable = elements.filter((element) => !ts.isStringLiteral(element));
const unresolvable = elements.filter((element) => !t.isStringLiteral(element));
if (unresolvable.length > 0) {
const first = unresolvable[0];
const excerpt = first && typeof first === 'object' ? nodeText(source, first) : String(first);
throw new Error(
`${setName} holds ${unresolvable.length} member(s) that are not plain string literals ` +
`(first: \`${unresolvable[0].getText(sourceFile)}\`). A source-reading guard cannot resolve ` +
`(first: \`${excerpt}\`). A source-reading guard cannot resolve ` +
`those, so switch this set to a runtime assertion rather than letting the guard see fewer members.`,
);
}
return elements.map((element) => (element as ts.StringLiteral).text);
return elements.map((element) => (element as t.StringLiteral).value);
};
/**

View file

@ -9,7 +9,7 @@
* FOUR modes (plan KTD3):
* 1. Config reflection — import `*-extractors/configs/*.ts`, read each
* config-shaped export's node-type-array keys. Exact `config.language`.
* 2. AST scan (`typescript` parser, no type-checker) over the EXTRACTION
* 2. AST scan (TypeScript 7 program SourceFile; no Checker gating) over the EXTRACTION
* surface — `*-extractors/**`, every `languages/<lang>/captures.ts`, and
* `export-detection.ts`. Collected BY CONSUMPTION SITE: `<n>.type === '..'`,
* `childForFieldName('..')` (capturing the receiver node type when an
@ -26,7 +26,7 @@
* `ingestion/` (e.g. `type-env.ts`). These files MIX SyntaxNode `.type` with
* resolved-symbol `.type` (kinds like 'Class'), so a literal is collected
* ONLY when its `.type` / `childForFieldName` receiver resolves to a
* tree-sitter SyntaxNode (via the TS TypeChecker). Per-`languages/<lang>/`
* tree-sitter SyntaxNode (via the TypeScript 7 Checker). Per-`languages/<lang>/`
* files tag to that one grammar; shared (non-`languages/<lang>/`) files tag
* to the full gated set (valid-if-any).
*
@ -35,8 +35,8 @@
*
* Test-only file: allowed to name languages.
*/
import ts from 'typescript';
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import * as ts from './ts7-ast.js';
import { readdirSync, existsSync, statSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
@ -449,11 +449,16 @@ function receiverNodeTypeOf(call: ts.CallExpression, sf: ts.SourceFile): string
return receiverMutatedIn(recvText, scope) ? undefined : found;
}
function scanFile(file: string): ScanResult {
function scanFile(
file: string,
built: { program: ts.Program; checker: ts.Checker } | null,
): ScanResult {
const relPath = rel(file);
const langs = fileLanguages(relPath);
const src = readFileSync(file, 'utf8');
const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true);
const empty: ScanResult = { nodeTypes: [], fields: [] };
if (!built) return empty;
const sf = programSourceFile(built.program, file);
if (!sf) return empty;
const nodeTypes: CollectedNodeType[] = [];
const fields: CollectedField[] = [];
@ -558,8 +563,9 @@ function scanFile(file: string): ScanResult {
function collectInCodeLiterals(): ScanResult {
const nodeTypes: CollectedNodeType[] = [];
const fields: CollectedField[] = [];
const built = buildProgram();
for (const file of mode2Files()) {
const r = scanFile(file);
const r = scanFile(file, built);
nodeTypes.push(...r.nodeTypes);
fields.push(...r.fields);
}
@ -571,7 +577,7 @@ function collectInCodeLiterals(): ScanResult {
// binding/interpret/arity/import-decomposer/...), the production path for
// migrated languages. These files mix SyntaxNode `.type` (grammar nodes) with
// resolved-symbol `.type` (kinds like 'Class'); a naive scan would false-
// positive on the latter. So this mode uses the TS TypeChecker to collect a
// positive on the latter. So this mode uses the TypeScript 7 Checker to collect a
// literal ONLY when its `.type` receiver / childForFieldName target resolves to
// a tree-sitter SyntaxNode. Per-language dir => grammar (no cross-lang ambiguity).
// ---------------------------------------------------------------------------
@ -613,25 +619,35 @@ function resolutionLayerFiles(): { file: string; langs: SupportedLanguages[] }[]
}
let _program: ts.Program | null = null;
let _checker: ts.TypeChecker | null = null;
function buildProgram(
rootFiles: string[],
): { program: ts.Program; checker: ts.TypeChecker } | null {
let _checker: ts.Checker | null = null;
function buildProgram(): { program: ts.Program; checker: ts.Checker } | null {
if (_program && _checker) return { program: _program, checker: _checker };
try {
const cfg = ts.readConfigFile(join(REPO_ROOT, 'tsconfig.json'), ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, REPO_ROOT);
const options: ts.CompilerOptions = { ...parsed.options, noEmit: true, skipLibCheck: true };
_program = ts.createProgram(rootFiles, options);
_checker = _program.getTypeChecker();
const api = new ts.API({ cwd: REPO_ROOT });
const snapshot = api.updateSnapshot({
openProjects: [join(REPO_ROOT, 'tsconfig.json')],
});
const project = snapshot.getProjects()[0];
if (!project) return null;
_program = project.program;
_checker = project.checker;
return { program: _program, checker: _checker };
} catch {
return null;
}
}
const sourceFileByPath = new Map<string, ts.SourceFile | undefined>();
function programSourceFile(program: ts.Program, file: string): ts.SourceFile | undefined {
if (sourceFileByPath.has(file)) return sourceFileByPath.get(file);
const sf = program.getSourceFile(file) ?? program.getSourceFile(realpathSync(file));
sourceFileByPath.set(file, sf);
return sf;
}
/** True when `node`'s resolved type is (or includes) a tree-sitter SyntaxNode. */
function isSyntaxNodeReceiver(checker: ts.TypeChecker, node: ts.Node): boolean {
function isSyntaxNodeReceiver(checker: ts.Checker, node: ts.Node): boolean {
try {
const s = checker.typeToString(checker.getTypeAtLocation(node));
return /\bSyntaxNode\b/.test(s);
@ -647,7 +663,7 @@ function collectResolutionLayerLiterals(): ScanResult {
const nodeTypes: CollectedNodeType[] = [];
const fields: CollectedField[] = [];
const entries = resolutionLayerFiles();
const built = buildProgram(entries.map((e) => e.file));
const built = buildProgram();
if (!built) {
resolutionLayerProgramOk = false;
return { nodeTypes, fields };
@ -655,7 +671,7 @@ function collectResolutionLayerLiterals(): ScanResult {
const { program, checker } = built;
for (const { file, langs } of entries) {
const sf = program.getSourceFile(file);
const sf = programSourceFile(program, file);
if (!sf) continue;
const relPath = rel(file);
const constMembers = new Map<string, string[]>();

View file

@ -0,0 +1,162 @@
/**
* Parse-only TypeScript AST walks for tests.
*
* TypeScript 7.0 does not ship the classic Compiler API (`createSourceFile`).
* These guards only need syntax, so they parse with `@babel/parser` (already a
* CLI dependency) rather than spawning the native TypeScript 7 program API.
*/
import { parse } from '@babel/parser';
import {
VISITOR_KEYS,
isBinaryExpression,
isNode,
isStringLiteral,
isTemplateLiteral,
type Comment,
type File,
type MemberExpression,
type Node,
type OptionalMemberExpression,
type TemplateLiteral,
} from '@babel/types';
export type AstNode = Node & { parent?: AstNode };
export interface ParsedSource {
ast: File & { parent?: AstNode };
source: string;
fileName: string;
}
const PARSE_PLUGINS: NonNullable<Parameters<typeof parse>[1]>['plugins'] = [
'typescript',
'explicitResourceManagement',
'importAttributes',
'decoratorAutoAccessors',
['decorators', { decoratorsBeforeExport: true }],
];
export function forEachChild(node: Node, visit: (child: AstNode) => void): void {
const keys = VISITOR_KEYS[node.type] ?? [];
for (const key of keys) {
const value = (node as unknown as Record<string, unknown>)[key];
if (Array.isArray(value)) {
for (const item of value) {
if (isNode(item)) visit(item);
}
} else if (isNode(value)) {
visit(value);
}
}
}
/** Direct descendants in source order (does not include `node` itself). */
export function collectDescendants(node: Node): AstNode[] {
const out: AstNode[] = [];
const visit = (child: AstNode): void => {
out.push(child);
forEachChild(child, visit);
};
forEachChild(node, visit);
return out;
}
export function staticMemberName(
node: MemberExpression | OptionalMemberExpression,
): string | undefined {
return node.computed || node.property.type !== 'Identifier' ? undefined : node.property.name;
}
function templateLiteralText(node: TemplateLiteral): string | undefined {
if (node.expressions.length > 0) return undefined;
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join('');
}
/** Compile-time string from a literal, template without holes, or `+` chain. */
export function staticStringValue(node: Node | undefined | null): string | undefined {
if (!node) return undefined;
if (isStringLiteral(node)) return node.value;
if (isTemplateLiteral(node)) return templateLiteralText(node);
if (isBinaryExpression(node) && node.operator === '+') {
const left = staticStringValue(node.left);
const right = staticStringValue(node.right);
if (left !== undefined && right !== undefined) return `${left}${right}`;
}
return undefined;
}
function attachParents(node: AstNode, parent?: AstNode): void {
node.parent = parent;
forEachChild(node, (child) => attachParents(child, node));
}
export function parseTypeScript(fileName: string, source: string): ParsedSource {
const ast = parse(source, {
sourceFilename: fileName,
sourceType: 'unambiguous',
plugins: PARSE_PLUGINS,
errorRecovery: true,
attachComment: true,
ranges: true,
}) as File & { parent?: AstNode };
attachParents(ast);
return { ast, source, fileName };
}
export function nodeStart(node: Node): number {
return node.start ?? 0;
}
export function nodeEnd(node: Node): number {
return node.end ?? 0;
}
export function nodeText(source: string, node: Node): string {
return source.slice(nodeStart(node), nodeEnd(node));
}
let lineAtSource = '';
let lineAtStarts: number[] = [0];
function lineStarts(source: string): number[] {
if (source === lineAtSource) return lineAtStarts;
const starts = [0];
for (let i = 0; i < source.length; i++) {
if (source[i] === '\n') starts.push(i + 1);
}
lineAtSource = source;
lineAtStarts = starts;
return starts;
}
export function lineAt(source: string, position: number): number {
if (position <= 0) return 1;
const starts = lineStarts(source);
const pos = Math.min(position, source.length);
let lo = 0;
let hi = starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (starts[mid] <= pos) lo = mid;
else hi = mid - 1;
}
return lo + 1;
}
export interface CommentRange {
pos: number;
end: number;
}
function toRange(comment: Comment): CommentRange | undefined {
if (comment.start == null || comment.end == null) return undefined;
return { pos: comment.start, end: comment.end };
}
export function leadingCommentRanges(node: Node): CommentRange[] {
return (node.leadingComments ?? []).map(toRange).filter((range) => range !== undefined);
}
export function trailingCommentRanges(node: Node): CommentRange[] {
return (node.trailingComments ?? []).map(toRange).filter((range) => range !== undefined);
}

View file

@ -0,0 +1,48 @@
/**
* TypeScript 7 AST + Checker surface for test-only walks that need types.
*
* TypeScript 7.0 dropped the classic Compiler API (`createProgram` /
* `getTypeChecker`). The native `typescript/unstable/sync` client exposes the
* same Checker methods against the Go compiler, and `typescript/unstable/ast`
* is the matching syntax tree.
*/
export { SyntaxKind } from 'typescript/unstable/ast';
export type {
CallExpression,
Expression,
Node,
PropertyAccessExpression,
SourceFile,
StringLiteral,
} from 'typescript/unstable/ast';
export {
isArrayLiteralExpression,
isArrowFunction,
isAsExpression,
isBinaryExpression,
isCallExpression,
isCaseClause,
isConstructorDeclaration,
isFunctionDeclaration,
isFunctionExpression,
isGetAccessorDeclaration,
isIdentifier,
isIfStatement,
isMethodDeclaration,
isNewExpression,
isPostfixUnaryExpression,
isPrefixUnaryExpression,
isPropertyAccessExpression,
isSetAccessorDeclaration,
isStringLiteral,
isStringLiteralLikeNode as isStringLiteralLike,
isSwitchStatement,
isVariableDeclaration,
} from 'typescript/unstable/ast/is';
export { API, type Checker, type Program } from 'typescript/unstable/sync';
import type { Node } from 'typescript/unstable/ast';
export function forEachChild(node: Node, visitor: (child: Node) => void): void {
node.forEachChild(visitor);
}

View file

@ -108,15 +108,17 @@ async function writeWithRetry(
}
// Bounded timeout so a genuine deadlock fails the test instead of hanging CI
// (mirrors the convention in parse-impl-large-fixture.test.ts). 60s is far
// above the ~2.5s this run takes locally on Linux — deliberately generous
// margin since native LadybugDB operations are slower on Windows CI and this
// test is registered into the Windows-inclusive LBUG_NATIVE group. A timeout
// here is a genuine deadlock regression signal, not routine flake — if
// Windows CI shows this margin is too tight (or too loose to catch a real
// regression promptly), tighten/loosen this constant based on observed
// LBUG_NATIVE run times rather than guessing again.
const DEADLOCK_TIMEOUT_MS = 60_000;
// (mirrors the convention in parse-impl-large-fixture.test.ts). Linux local
// and ubuntu/macOS CI finish in ~3–8s, so 60s stays a hang detector there.
// Windows CI typical is ~14–16s, but the native checkpoint-vs-reader stress
// has a long tail: same-day windows-latest 3/3 jobs completed in 13803ms /
// 15488ms / 15658ms typical and 56518ms on the slow tail — 4s under the
// previous 60s budget. That budget therefore false-positives under Windows
// runner load (PR #3290 job 104765024772: both vitest retries timed out at
// 60s while Ubuntu/macOS and this PR's prior Windows 3/3 at 15.7s passed).
// win32 uses 180s (~3× the observed tail) and still fails a hang that never
// completes. Do not skip Windows; this test is in LBUG_NATIVE for a reason.
const DEADLOCK_TIMEOUT_MS = process.platform === 'win32' ? 180_000 : 60_000;
// Unlike lbug-core-adapter.test.ts / lbug-close-handle-release.test.ts /
// lbug-orphan-sidecar-recovery.test.ts, this test never closes and reopens

View file

@ -0,0 +1,113 @@
/**
* Real-DB coverage for #3306: loadCachedEmbeddings must stream CodeEmbedding
* rows instead of getAll()+map(Number) of the whole table, and must be able
* to spill vectors so incremental analyze does not keep every embedding in
* the V8 heap.
*/
import fs from 'node:fs';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
import { EMBEDDING_DIMS } from '../../src/core/lbug/schema.js';
import { batchInsertEmbeddings } from '../../src/core/embeddings/embedding-pipeline.js';
import {
disposeEmbeddingSpill,
materializeCachedEmbeddings,
} from '../../src/core/embeddings/embedding-restore-spill.js';
describe('loadCachedEmbeddings streaming (#3306)', () => {
let tmp: TestDBHandle | undefined;
afterEach(async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
try {
await adapter.closeLbug();
} catch {
/* already closed */
}
await tmp?.cleanup();
tmp = undefined;
});
async function seedDb(rowCount: number) {
tmp = await createTempDir('gitnexus-lbug-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
const rows = Array.from({ length: rowCount }, (_, i) => ({
nodeId: `Function:src/f${i}.ts:fn${i}:1`,
chunkIndex: 0,
startLine: 1,
endLine: 3,
embedding: Array.from({ length: EMBEDDING_DIMS }, (__, d) => (d === 0 ? i + 1 : 0)),
contentHash: `hash-${i}`,
}));
await batchInsertEmbeddings(adapter.executeWithReusedStatement, rows);
return { adapter, rows };
}
it('materializes a small table in RAM (skip-fts / mock-compatible shape)', async () => {
const { adapter, rows } = await seedDb(3);
const cached = await adapter.loadCachedEmbeddings();
expect(cached.spill).toBeUndefined();
expect(cached.embeddings).toHaveLength(3);
expect(cached.rows).toHaveLength(3);
expect(cached.embeddingNodeIds.size).toBe(3);
expect(cached.embeddings.map((e) => e.nodeId).sort()).toEqual(rows.map((r) => r.nodeId).sort());
expect(cached.embeddings.find((e) => e.nodeId === rows[1]!.nodeId)?.embedding[0]).toBe(2);
});
it('streams into a spill file when the in-memory limit is 0 and restores a subset', async () => {
const { adapter, rows } = await seedDb(12);
const cached = await adapter.loadCachedEmbeddings({ inMemoryRowLimit: 0 });
try {
expect(cached.embeddings).toEqual([]);
expect(cached.spill?.rowCount).toBe(12);
expect(cached.rows).toHaveLength(12);
const wanted = new Set([rows[0]!.nodeId, rows[5]!.nodeId, rows[10]!.nodeId]);
const subset = materializeCachedEmbeddings(
cached,
cached.rows.filter((meta) => wanted.has(meta.nodeId)),
);
expect(subset).toHaveLength(3);
const byId = new Map(subset.map((row) => [row.nodeId, row]));
expect(byId.get(rows[0]!.nodeId)?.embedding[0]).toBe(1);
expect(byId.get(rows[5]!.nodeId)?.embedding[0]).toBe(6);
expect(byId.get(rows[10]!.nodeId)?.embedding[0]).toBe(11);
expect(byId.get(rows[0]!.nodeId)?.contentHash).toBe(rows[0]!.contentHash);
} finally {
disposeEmbeddingSpill(cached.spill);
}
});
it('flips from RAM to spill once a non-zero in-memory limit is crossed', async () => {
const { adapter, rows } = await seedDb(8);
const cached = await adapter.loadCachedEmbeddings({ inMemoryRowLimit: 4 });
try {
expect(cached.embeddings).toEqual([]);
expect(cached.spill?.rowCount).toBe(8);
expect(cached.rows).toHaveLength(8);
expect(fs.statSync(cached.spill!.path).size).toBe(12 + 8 * EMBEDDING_DIMS * 4);
const wanted = new Set([rows[2]!.nodeId, rows[3]!.nodeId]);
const subset = materializeCachedEmbeddings(
cached,
cached.rows.filter((meta) => wanted.has(meta.nodeId)),
);
expect(subset).toHaveLength(2);
const byId = new Map(subset.map((row) => [row.nodeId, row]));
expect(byId.get(rows[2]!.nodeId)?.embedding[0]).toBe(3);
expect(byId.get(rows[3]!.nodeId)?.embedding[0]).toBe(4);
} finally {
disposeEmbeddingSpill(cached.spill);
}
});
it('surfaces a spill write failure instead of adopting an empty snapshot', async () => {
const { adapter } = await seedDb(3);
const spillDir = path.join(tmp!.dbPath, 'not-a-directory');
fs.writeFileSync(spillDir, 'x');
await expect(adapter.loadCachedEmbeddings({ inMemoryRowLimit: 0, spillDir })).rejects.toThrow(
/ENOTDIR|not a directory|ENOSPC|EACCES/i,
);
});
});

View file

@ -6,7 +6,10 @@ vi.mock('../../src/core/ingestion/utils/effective-ram.js', () => ({
autoHeapCapMb: autoHeapCapMbMock,
}));
import { createAutoSyncAnalysisRunner } from '../../src/core/auto-sync/analysis-worker-launch.js';
import {
AutoSyncAnalysisError,
createAutoSyncAnalysisRunner,
} from '../../src/core/auto-sync/analysis-worker-launch.js';
function createChild() {
return Object.assign(new EventEmitter(), {
@ -74,6 +77,29 @@ describe('auto-sync analysis worker', () => {
await expect(result).rejects.toThrow('parser crashed');
});
it('preserves worker index-lock-timeout code and retryable on the parent error', async () => {
const child = createChild();
const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) });
const result = run('/tmp/repo', { branch: 'main' }, 50);
child.emit('message', {
type: 'error',
message: 'waited for the index lock',
code: 'index-lock-timeout',
retryable: true,
});
child.emit('exit', 1, null);
await expect(result).rejects.toMatchObject({
name: 'AutoSyncAnalysisError',
message: 'waited for the index lock',
code: 'index-lock-timeout',
retryable: true,
abandonedWorker: false,
});
await expect(result).rejects.toBeInstanceOf(AutoSyncAnalysisError);
});
it('requests cancellation after timeout, reports it, and waits for exit', async () => {
const child = createChild();
const timers: Array<() => void> = [];
@ -211,6 +237,11 @@ describe('auto-sync analysis worker', () => {
timers[1]!();
await expect(result).rejects.toThrow('did not exit within');
await expect(result).rejects.toMatchObject({
name: 'AutoSyncAnalysisError',
abandonedWorker: true,
retryable: true,
});
// The parent stops waiting; the child is released, never killed.
expect(child.channel.unref).toHaveBeenCalled();
expect(child.unref).toHaveBeenCalled();

View file

@ -19,6 +19,7 @@ import type {
AutoSyncRunDeps,
AutoSyncWatchPaths,
} from '../../src/core/auto-sync/index.js';
import { AutoSyncAnalysisError } from '../../src/core/auto-sync/analysis-worker-launch.js';
const config: AutoSyncConfig = {
configPath: '/tmp/.gitnexus/watch_config.yml',
@ -31,6 +32,7 @@ const config: AutoSyncConfig = {
{
localPath: '/tmp/repos',
groupName: 'back_end',
pdg: false,
overwriteLocalChanges: false,
branches: ['master'],
remoteUrls: ['git@gitee.com:qts_server/qts_account.git'],
@ -49,10 +51,69 @@ const verifiedProcessStartTime = 'Tue Aug 4 12:00:00 2026';
function withCloneRoot(deps: Partial<AutoSyncRunDeps>): Partial<AutoSyncRunDeps> {
return {
resolveCloneRoot: vi.fn(async () => cloneRoot),
getExistingPdgMode: vi.fn(async () => false),
...deps,
};
}
async function runWithOnDiskPdgState(options: {
metadata?: Record<string, unknown>;
createLbug?: boolean;
pdg?: boolean;
}) {
const base = path.join(process.cwd(), '.tmp-test');
await fs.mkdir(base, { recursive: true });
const localPath = await fs.realpath(await fs.mkdtemp(path.join(base, 'auto-sync-pdg-mode-')));
const targetDir = path.join(localPath, 'github.com', 'team', 'repo');
const indexDir = path.join(targetDir, '.gitnexus');
await fs.mkdir(indexDir, { recursive: true });
if (options.metadata) {
await fs.writeFile(path.join(indexDir, 'gitnexus.json'), JSON.stringify(options.metadata));
}
if (options.createLbug) await fs.mkdir(path.join(indexDir, 'lbug'));
const runAnalysis = vi.fn(async () => ({ stats: { files: 1 } }) as any);
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const localConfig: AutoSyncConfig = {
...config,
projects: [
{
...config.projects[0],
localPath,
groupName: undefined,
pdg: options.pdg,
branches: ['main'],
remoteUrls: ['git@github.com:team/repo.git'],
},
],
};
const deps: Partial<AutoSyncRunDeps> = {
resolveCloneRoot: vi.fn(async () => ({
root: localPath,
quarantineRoot: path.join(localPath, 'quarantine'),
quarantineRetentionDays: 14,
})),
cloneOrPull: vi.fn(async () => targetDir),
getCurrentBranch: vi.fn(async () => 'main'),
getCurrentCommit: vi.fn(async () => 'commit-1'),
runAnalysis,
registerRepo: vi.fn(async () => 'repo'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
};
try {
const result = await runAutoSyncOnce(localConfig, { deps, logger });
return { result, runAnalysis, logger, targetDir };
} finally {
await fs.rm(localPath, { recursive: true, force: true });
}
}
async function writeWatchOwner(
paths: AutoSyncWatchPaths,
pid: number,
@ -130,7 +191,13 @@ describe('auto-sync runner', () => {
);
expect(deps.runAnalysis).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
{ branch: 'master', skipAgentsMd: true, skipSkills: true },
{
branch: 'master',
skipAgentsMd: true,
skipSkills: true,
pdg: false,
atomicIncremental: true,
},
1_800_000,
undefined,
undefined,
@ -156,6 +223,188 @@ describe('auto-sync runner', () => {
]);
});
it('enables PDG atomically at an unchanged commit when project configuration opts in', async () => {
const pdgConfig: AutoSyncConfig = {
...config,
projects: [{ ...config.projects[0], pdg: true }],
};
const stateKey = '/tmp/repos/gitee.com/qts_server/qts_account|master';
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
getExistingPdgMode: vi.fn(async () => false),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({
[stateKey]: {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'failed',
analyzeConsecutiveFailures: 3,
lastAnalyzeError: 'old non-PDG failure',
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(pdgConfig, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
expect(deps.runAnalysis).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
expect.objectContaining({ pdg: true, atomicIncremental: true }),
1_800_000,
undefined,
undefined,
1,
);
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
[stateKey]: expect.objectContaining({ requestedPdg: true }),
}),
);
});
it('preserves an existing PDG index when legacy project configuration omits pdg', async () => {
const legacyConfig: AutoSyncConfig = {
...config,
projects: [{ ...config.projects[0], pdg: undefined }],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
getExistingPdgMode: vi.fn(async () => true),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(legacyConfig, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(deps.runAnalysis).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ preserveExistingPdg: true, atomicIncremental: true }),
expect.any(Number),
undefined,
undefined,
1,
);
});
it.each([
['PDG metadata', { branch: 'main', pdg: { version: 5 } }, true],
['non-PDG metadata', { branch: 'main' }, false],
['no prior index', undefined, false],
] as const)(
'derives the omitted PDG setting from %s on disk',
async (_label, metadata, expectedPdg) => {
const { result, runAnalysis, logger, targetDir } = await runWithOnDiskPdgState({ metadata });
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
expect(runAnalysis).toHaveBeenCalledWith(
targetDir,
expect.objectContaining({ preserveExistingPdg: true, atomicIncremental: true }),
expect.any(Number),
undefined,
undefined,
1,
);
if (expectedPdg) {
expect(logger.info).toHaveBeenCalledWith(
`[auto-sync] Preserving existing PDG mode for ${targetDir}; project configuration does not set pdg.`,
);
}
},
);
it('refuses to overwrite an index when its on-disk PDG mode is ambiguous', async () => {
const { result, runAnalysis, logger, targetDir } = await runWithOnDiskPdgState({
createLbug: true,
});
expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(runAnalysis).not.toHaveBeenCalled();
const error = String(logger.error.mock.calls[0]?.[0]);
expect(error).toContain(
`Cannot determine whether the existing index at ${path.join(targetDir, '.gitnexus', 'lbug')} contains PDG data`,
);
expect(error).toContain('Refusing to analyze so the live graph is preserved.');
});
it('uses an explicit PDG mode to recover an index whose metadata is ambiguous', async () => {
const { result, runAnalysis, logger, targetDir } = await runWithOnDiskPdgState({
createLbug: true,
pdg: true,
});
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
expect(runAnalysis).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ pdg: true, atomicIncremental: true }),
expect.any(Number),
undefined,
undefined,
1,
);
const warning = String(logger.warn.mock.calls[0]?.[0]);
expect(warning).toBe(
`[auto-sync] Existing PDG mode could not be determined for ${targetDir}; applying explicit pdg=true.`,
);
});
it('warns before an explicit PDG opt-out removes existing PDG data', async () => {
const warn = vi.fn();
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
getExistingPdgMode: vi.fn(async () => true),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn, error: vi.fn() },
});
expect(warn).toHaveBeenCalledWith(
'[auto-sync] PDG is explicitly disabled for /tmp/repos/gitee.com/qts_server/qts_account; the next successful rebuild will remove existing PDG data.',
);
expect(deps.runAnalysis).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ pdg: false, atomicIncremental: true }),
expect.any(Number),
undefined,
undefined,
1,
);
});
it('registers into the branch slot the analyze worker placed the index in', async () => {
// Without this the parent always takes the primary/flat arm and relabels a
// pinned branch entry with whatever this tick happened to sync.
@ -476,7 +725,13 @@ describe('auto-sync runner', () => {
expect(runAnalysis).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
{ branch: 'master', skipAgentsMd: true, skipSkills: true },
{
branch: 'master',
skipAgentsMd: true,
skipSkills: true,
pdg: false,
atomicIncremental: true,
},
1_800_000,
controller.signal,
onAnalysisCancellationRequested,
@ -530,7 +785,13 @@ describe('auto-sync runner', () => {
);
expect(deps.runAnalysis).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
{ branch: 'develop', skipAgentsMd: true, skipSkills: true },
{
branch: 'develop',
skipAgentsMd: true,
skipSkills: true,
pdg: false,
atomicIncremental: true,
},
1_800_000,
undefined,
undefined,
@ -1058,6 +1319,137 @@ describe('auto-sync runner', () => {
);
});
it('does not count leftover-worker timeout toward the analyze failure threshold', async () => {
const errorLogger = vi.fn();
const leftover = new AutoSyncAnalysisError(
'Analysis timed out after 50ms. The analyze worker did not exit within 5000ms; it was left running so its native work is not interrupted.',
{ abandonedWorker: true, retryable: true },
);
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
runAnalysis: vi.fn(async () => {
throw leftover;
}),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'failed',
analyzeConsecutiveFailures: 1,
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result).toEqual({
synced: 1,
analyzed: 0,
skippedAnalysis: 0,
failed: 1,
abandonedAnalysisWorker: true,
});
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
analyzeConsecutiveFailures: 1,
lastAnalyzeStatus: 'failed',
}),
}),
);
expect(errorLogger).toHaveBeenCalledWith(
expect.stringContaining(
'retryable leftover-worker or index-lock wait (not counted toward threshold)',
),
);
});
it('does not count a retryable live-holder index-lock timeout toward the analyze failure threshold', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
runAnalysis: vi.fn(async () => {
throw new AutoSyncAnalysisError('waited for the index lock', {
code: 'index-lock-timeout',
retryable: true,
});
}),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
analyzeConsecutiveFailures: 0,
lastAnalyzeStatus: 'failed',
}),
}),
);
});
it('still counts a non-retryable index-lock guard timeout toward the analyze failure threshold', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
runAnalysis: vi.fn(async () => {
throw new AutoSyncAnalysisError('index lock guard timeout', {
code: 'index-lock-timeout',
retryable: false,
});
}),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
analyzeConsecutiveFailures: 1,
lastAnalyzeStatus: 'failed',
}),
}),
);
});
it('records a null analysis failure without masking it with a TypeError', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
@ -1277,7 +1669,7 @@ describe('auto-sync starter', () => {
}
});
it('skips overlapping scheduled runs while a previous run is active', async () => {
it('coalesces overlapping scheduled ticks into one immediate follow-up run', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
const timer = { unref: vi.fn() };
@ -1314,18 +1706,80 @@ describe('auto-sync starter', () => {
handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr });
scheduled?.();
scheduled?.();
expect(runOnce).toHaveBeenCalledTimes(1);
expect(stderr.write).toHaveBeenCalledWith(
'[auto-sync] Previous run is still active; skipping overlapping run.\n',
'[auto-sync] Previous run is still active; queued one immediate follow-up.\n',
);
releaseRuns.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 0));
await vi.waitFor(() => expect(runOnce).toHaveBeenCalledTimes(2));
releaseRuns.shift()?.();
await handle?.stop();
handle = undefined;
} finally {
releaseRuns.splice(0).forEach((release) => release());
await handle?.stop();
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('defers a coalesced follow-up when the finished run left an analyze worker running', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
const timer = { unref: vi.fn() };
let scheduled: (() => void) | undefined;
const setIntervalFn = vi.fn((fn: () => void) => {
scheduled = fn;
return timer;
}) as unknown as typeof setInterval;
const stderr = { write: vi.fn() };
const releaseRuns: Array<() => void> = [];
const runOnce = vi.fn(
() =>
new Promise<any>((resolve) => {
releaseRuns.push(() =>
resolve({
synced: 1,
analyzed: 0,
skippedAnalysis: 0,
failed: 1,
abandonedAnalysisWorker: true,
}),
);
}),
);
let handle: Awaited<ReturnType<typeof startAutoSyncWatch>> | undefined;
try {
process.env.GITNEXUS_HOME = tempDir;
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr });
scheduled?.();
scheduled?.();
expect(runOnce).toHaveBeenCalledTimes(2);
expect(runOnce).toHaveBeenCalledTimes(1);
releaseRuns.shift()?.();
await vi.waitFor(() => {
expect(stderr.write).toHaveBeenCalledWith(
'[auto-sync] Previous run left an analyze worker running; deferring the coalesced follow-up to the next interval.\n',
);
});
expect(runOnce).toHaveBeenCalledTimes(1);
await handle?.stop();
handle = undefined;
} finally {
@ -1372,6 +1826,7 @@ describe('auto-sync starter', () => {
handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr });
expect(runOnce).toHaveBeenCalledTimes(1);
scheduled?.();
const stopping = handle!.stop();
releaseRun?.();

View file

@ -104,6 +104,7 @@ describe('auto-sync', () => {
'projects:',
' - local_path: /tmp/repos',
' group_name: back_end',
' pdg: true',
' overwrite_local_changes: true',
' branches: [test, master, test]',
' remote_urls:',
@ -124,6 +125,7 @@ describe('auto-sync', () => {
expect(loaded.config.projects[0]).toMatchObject({
localPath: '/tmp/repos',
groupName: 'back_end',
pdg: true,
overwriteLocalChanges: true,
branches: ['test', 'master'],
remoteUrls: ['git@gitee.com:qts_server/qts_account.git'],
@ -153,6 +155,7 @@ describe('auto-sync', () => {
expect(loaded.config.maxConcurrency).toBe(1);
expect(loaded.config.analyzeFailureThreshold).toBe(3);
expect(loaded.config.projects[0].groupName).toBeUndefined();
expect(loaded.config.projects[0].pdg).toBeUndefined();
expect(loaded.config.projects[0].overwriteLocalChanges).toBe(false);
});
@ -209,12 +212,12 @@ describe('auto-sync', () => {
expect(() => parseAutoSyncConfig(config('600000ms'), '/tmp/watch_config.yml')).not.toThrow();
});
it('rejects analyze_timeout values above half the sync interval', async () => {
it('allows a 30 minute analysis timeout with 5 minute polling', async () => {
await fs.writeFile(
path.join(gitnexusHome, 'watch_config.yml'),
[
'sync_interval_minutes: 10',
'analyze_timeout: 6m',
'sync_interval_minutes: 5',
'analyze_timeout: 30m',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
@ -225,11 +228,43 @@ describe('auto-sync', () => {
const loaded = await loadAutoSyncConfig();
expect(loaded.ok).toBe(false);
if (loaded.ok) throw new Error('expected invalid config');
expect(loaded.message).toContain(
'analyze_timeout must not exceed half of sync_interval_minutes (5m)',
);
expect(loaded.ok).toBe(true);
if (!loaded.ok) throw new Error('expected valid config');
expect(loaded.config.analyzeTimeoutMs).toBe(1_800_000);
});
it('rejects analyze_timeout values above the Node timer limit', () => {
expect(() =>
parseAutoSyncConfig(
[
'sync_interval_minutes: 5',
'analyze_timeout: 2147483648ms',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:owner/repo.git',
].join('\n'),
'/tmp/watch_config.yml',
),
).toThrow('analyze_timeout must not exceed 2147483647ms');
});
it('rejects non-boolean per-project pdg configuration', () => {
expect(() =>
parseAutoSyncConfig(
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' pdg: yes',
' remote_urls:',
' - git@github.com:owner/repo.git',
].join('\n'),
'/tmp/watch_config.yml',
),
).toThrow('projects[0].pdg must be a boolean');
});
it('rejects invalid analyze_failure_threshold values', async () => {
@ -608,6 +643,7 @@ describe('auto-sync', () => {
codeCommitId: 'abc',
analyzedCommitId: 'abc',
lastAnalyzeStatus: 'success',
requestedPdg: true,
analyzeConsecutiveFailures: 2,
lastAnalyzeError: 'old error',
lastSyncTime: '2026-06-30T00:00:00.000Z',
@ -624,6 +660,7 @@ describe('auto-sync', () => {
codeCommitId: 'abc',
analyzedCommitId: 'abc',
lastAnalyzeStatus: 'success',
requestedPdg: true,
analyzeConsecutiveFailures: 2,
lastAnalyzeError: 'old error',
lastSyncTime: '2026-06-30T00:00:00.000Z',
@ -670,6 +707,11 @@ describe('auto-sync', () => {
analyzeConsecutiveFailures: -1,
lastSyncTime: null,
},
'/tmp/repos/invalid-pdg|main': {
codeCommitId: 'abc',
requestedPdg: 'true',
lastSyncTime: '2026-06-30T00:00:00.000Z',
},
}),
);

View file

@ -8,6 +8,8 @@ import { runWebBuild, shouldBuildWeb, shouldPreserveWebOutput } from '../../scri
/** Default prepare/build stay CLI-only; the web UI ships only via prepack --web. */
const REPO_ROOT = path.resolve(__dirname, '../../..');
const CLI_TSC_JS = 'node ../gitnexus/node_modules/typescript/lib/tsc.js';
const WEB_TSC_JS = 'node ../gitnexus-web/node_modules/typescript/lib/tsc.js';
const PACKAGE_JSON = JSON.parse(
readFileSync(path.join(REPO_ROOT, 'gitnexus/package.json'), 'utf8'),
) as { scripts?: Record<string, string> };
@ -283,7 +285,7 @@ describe('workflows that need the web UI install it themselves', () => {
describe('setup-gitnexus job budget', () => {
it('does not npm-ci gitnexus-shared (TypeScript 7 optional-platform install stalls CI)', () => {
const shared = setupGitnexus.runs?.steps?.find((step) => step.name === 'Build gitnexus-shared');
expect(String(shared?.run)).toBe('node ../gitnexus/node_modules/typescript/lib/tsc.js');
expect(String(shared?.run)).toBe(CLI_TSC_JS);
expect(String(shared?.run)).not.toContain('.bin');
expect(shared?.if).toContain("lifecycle-scripts == 'false'");
expect(
@ -314,12 +316,28 @@ describe('setup-gitnexus job budget', () => {
expect(String(setupNode?.with?.['cache-dependency-path'])).toBe(
'gitnexus-web/package-lock.json',
);
expect(String(shared?.run)).toBe('node ../gitnexus-web/node_modules/typescript/lib/tsc.js');
expect(String(shared?.run)).toBe(WEB_TSC_JS);
expect(String(shared?.run)).not.toContain('.bin');
expect(String(shared?.run)).not.toContain('npm ci');
expect(webInstall?.env?.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD).toBe('1');
});
it('Vercel installs web first and compiles shared with the web TypeScript', () => {
const vercel = JSON.parse(
readFileSync(path.join(REPO_ROOT, 'gitnexus-web/vercel.json'), 'utf8'),
) as { installCommand?: string };
const install = String(vercel.installCommand);
// Vercel runs installCommand with NODE_ENV=production, so npm ci drops
// typescript unless --include=dev is on that install (not a later step).
expect(install).toContain('npm ci --include=dev');
expect(install).toContain('gitnexus-shared');
expect(install.indexOf('npm ci --include=dev')).toBeLessThan(
install.indexOf('gitnexus-shared'),
);
expect(install).toContain(WEB_TSC_JS);
expect(install).not.toMatch(/gitnexus-shared[^&]*npm (?:ci|install)/);
});
it('quality typecheck skips prepare/postinstall so tsc --noEmit fits in 10 minutes', () => {
const job = qualityJobs.typecheck;
const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus');
@ -327,6 +345,28 @@ describe('setup-gitnexus job budget', () => {
expect(setup?.with?.['lifecycle-scripts']).toBe('false');
});
it('web app tsconfig typechecks React JSX on TypeScript 7 without baseUrl', () => {
const tsconfig = JSON.parse(
readFileSync(path.join(REPO_ROOT, 'gitnexus-web/tsconfig.app.json'), 'utf8'),
) as {
compilerOptions?: {
baseUrl?: string;
jsx?: string;
jsxImportSource?: string;
lib?: string[];
rootDir?: string;
types?: string[];
};
};
const options = tsconfig.compilerOptions ?? {};
expect(options.baseUrl).toBeUndefined();
expect(options.jsx).toBe('react-jsx');
expect(options.jsxImportSource).toBe('react');
expect(options.lib).toEqual(expect.arrayContaining(['ESNext', 'DOM', 'DOM.Iterable']));
expect(options.rootDir).toBe('./src');
expect(options.types).toEqual(['vite/client']);
});
it('quality typecheck-web can finish a cold web install instead of canceling before cache save', () => {
expect(qualityJobs['typecheck-web']?.['timeout-minutes']).toBe(15);
});

View file

@ -4,8 +4,14 @@ import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command, Option } from 'commander';
import * as ts from 'typescript';
import * as t from '@babel/types';
import { afterEach, describe, expect, it } from 'vitest';
import {
forEachChild,
parseTypeScript,
staticMemberName,
staticStringValue,
} from '../helpers/parse-typescript-source.js';
import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js';
import { localizeCliHelp } from '../../src/cli/help-i18n.js';
import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js';
@ -69,17 +75,6 @@ const allHelpCommands = [
['group', 'contracts'],
];
function staticStringValue(node: ts.Node | undefined): string | undefined {
if (!node) return undefined;
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
const left = staticStringValue(node.left);
const right = staticStringValue(node.right);
if (left !== undefined && right !== undefined) return `${left}${right}`;
}
return undefined;
}
function extractRegisteredHelpDescriptions(): string[] {
const descriptions = new Set<string>();
const sourceFiles = ['src/cli/index.ts', 'src/cli/group.ts'];
@ -87,27 +82,30 @@ function extractRegisteredHelpDescriptions(): string[] {
for (const relativePath of sourceFiles) {
const filePath = path.join(repoRoot, relativePath);
const source = fs.readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
const { ast } = parseTypeScript(filePath, source);
function visit(node: ts.Node): void {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const method = node.expression.name.text;
const description =
method === 'description'
? staticStringValue(node.arguments[0])
: method === 'option' || method === 'requiredOption'
? staticStringValue(node.arguments[1])
: undefined;
function visit(node: t.Node): void {
if (
t.isCallExpression(node) &&
(t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee))
) {
const method = staticMemberName(node.callee);
let description: string | undefined;
if (method === 'description') {
description = staticStringValue(node.arguments[0]);
} else if (method === 'option' || method === 'requiredOption') {
description = staticStringValue(node.arguments[1]);
}
if (description && /[A-Za-z]/.test(description)) {
descriptions.add(description.replace(/\s+/g, ' ').trim());
}
}
ts.forEachChild(node, visit);
forEachChild(node, visit);
}
visit(sourceFile);
visit(ast);
}
return [...descriptions].filter((description) => description.length > 0).sort();
@ -310,6 +308,8 @@ describe('CLI help surface', () => {
expect(config).toContain('sync_interval_minutes: 10');
expect(config).toContain('analyze_failure_threshold: 3');
expect(config).toContain('analyze_timeout: 5m');
expect(config).toContain('pdg: false');
expect(config).toContain('omit = preserve live index mode');
expect(config).toContain('overwrite_local_changes: false');
expect(config).toContain(`local_path: ${path.join(home, 'repos')}`);
expect(config).not.toContain('/abs/path/to/repos');

View file

@ -0,0 +1,274 @@
import { existsSync, statSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
abortCachedEmbeddingsBuilder,
cacheRowCount,
createCachedEmbeddingsBuilder,
DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT,
discardLiveEmbeddingSpills,
discardScopedEmbeddingSpills,
disposeEmbeddingSpill,
EmbeddingSpillReader,
finalizeCachedEmbeddingsSnapshot,
ingestCachedEmbeddingRow,
materializeCachedEmbeddings,
normalizeCachedEmbeddings,
readSpillVectors,
resolveEmbeddingCacheInMemoryRowLimit,
snapshotEmbeddingDims,
withEmbeddingSpillScope,
} from '../../src/core/embeddings/embedding-restore-spill.js';
const DIMS = 8;
function vector(fill: number): number[] {
return Array.from({ length: DIMS }, () => fill);
}
function row(id: string, fill: number, hash = `hash-${id}`) {
return {
nodeId: id,
chunkIndex: 0,
startLine: 1,
endLine: 2,
embedding: vector(fill),
contentHash: hash,
};
}
describe('embedding-restore-spill (#3306)', () => {
const spills: Array<{ path: string }> = [];
afterEach(() => {
for (const spill of spills) disposeEmbeddingSpill(spill);
spills.length = 0;
});
it('keeps small tables in RAM and does not leave a spill file', () => {
const builder = createCachedEmbeddingsBuilder({
inMemoryRowLimit: 4,
spillDir: os.tmpdir(),
});
ingestCachedEmbeddingRow(builder, row('n1', 0.25), true);
ingestCachedEmbeddingRow(builder, row('n2', 0.5), true);
const snapshot = finalizeCachedEmbeddingsSnapshot(builder);
expect(snapshot.spill).toBeUndefined();
expect(existsSync(builder.writer.path)).toBe(false);
expect(snapshot.embeddings).toHaveLength(2);
expect(snapshot.rows).toHaveLength(2);
expect(snapshot.embeddings[0]?.embedding[0]).toBeCloseTo(0.25);
expect(cacheRowCount(snapshot)).toBe(2);
expect(snapshotEmbeddingDims(snapshot)).toBe(DIMS);
});
it('spills vectors once the in-memory limit is exceeded and materializes a subset', () => {
const builder = createCachedEmbeddingsBuilder({
inMemoryRowLimit: 2,
spillDir: os.tmpdir(),
});
for (let i = 0; i < 5; i++) {
ingestCachedEmbeddingRow(builder, row(`n${i}`, i + 1), true);
}
const snapshot = finalizeCachedEmbeddingsSnapshot(builder);
if (snapshot.spill) spills.push(snapshot.spill);
expect(snapshot.embeddings).toEqual([]);
expect(snapshot.spill?.rowCount).toBe(5);
expect(existsSync(snapshot.spill!.path)).toBe(true);
expect(snapshot.embeddingNodeIds.size).toBe(5);
const subset = materializeCachedEmbeddings(snapshot, snapshot.rows.slice(1, 3));
expect(subset).toHaveLength(2);
expect(subset[0]?.nodeId).toBe('n1');
expect(subset[0]?.embedding[0]).toBeCloseTo(2);
expect(subset[1]?.embedding[0]).toBeCloseTo(3);
});
it('always spills when the in-memory limit is 0 (no Number[] table in RAM)', () => {
const builder = createCachedEmbeddingsBuilder({
inMemoryRowLimit: 0,
spillDir: os.tmpdir(),
});
ingestCachedEmbeddingRow(builder, row('only', 0.75), true);
const snapshot = finalizeCachedEmbeddingsSnapshot(builder);
if (snapshot.spill) spills.push(snapshot.spill);
expect(snapshot.embeddings).toEqual([]);
expect(snapshot.rows).toHaveLength(1);
expect(materializeCachedEmbeddings(snapshot, snapshot.rows)[0]?.embedding[0]).toBeCloseTo(0.75);
});
it('aborts an unfinished builder without leaking a spill file', () => {
const builder = createCachedEmbeddingsBuilder({
inMemoryRowLimit: 0,
spillDir: os.tmpdir(),
});
ingestCachedEmbeddingRow(builder, row('n1', 1), true);
expect(existsSync(builder.writer.path)).toBe(true);
abortCachedEmbeddingsBuilder(builder);
expect(existsSync(builder.writer.path)).toBe(false);
});
it('normalizes mock {embeddings} payloads so Phase 3.5 can restore without a spill', () => {
const snapshot = normalizeCachedEmbeddings({
embeddingNodeIds: new Set(['Function:a:foo']),
embeddings: [
{
nodeId: 'Function:a:foo',
chunkIndex: 0,
startLine: 0,
endLine: 3,
embedding: vector(0.1),
contentHash: 'stub',
},
],
});
expect(snapshot.rows).toHaveLength(1);
expect(snapshot.rows[0]?.vectorIndex).toBe(0);
const restored = materializeCachedEmbeddings(snapshot, snapshot.rows);
expect(restored[0]?.contentHash).toBe('stub');
expect(restored[0]?.embedding).toHaveLength(DIMS);
});
it('defaults the in-memory row limit to 2048 and honors GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', () => {
expect(DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT).toBe(2048);
vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '');
expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(2048);
vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '0');
expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(0);
vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '12');
expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(12);
vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', 'nope');
expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(2048);
vi.unstubAllEnvs();
expect(resolveEmbeddingCacheInMemoryRowLimit(7)).toBe(7);
expect(resolveEmbeddingCacheInMemoryRowLimit(-1)).toBe(2048);
});
it('spills above the default 2048-row limit with header-plus-body size 12 + N * D * 4', () => {
const n = DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT + 1;
const builder = createCachedEmbeddingsBuilder({ spillDir: os.tmpdir() });
for (let i = 0; i < n; i++) {
ingestCachedEmbeddingRow(builder, row(`n${i}`, 1), true);
}
const snapshot = finalizeCachedEmbeddingsSnapshot(builder);
if (snapshot.spill) spills.push(snapshot.spill);
expect(snapshot.embeddings).toEqual([]);
expect(snapshot.spill?.rowCount).toBe(n);
expect(statSync(snapshot.spill!.path).size).toBe(12 + n * DIMS * 4);
});
it('rejects a dim mismatch once spilling and rejects a short or bad-magic header', () => {
const builder = createCachedEmbeddingsBuilder({
inMemoryRowLimit: 0,
spillDir: os.tmpdir(),
});
ingestCachedEmbeddingRow(builder, row('a', 1), true);
expect(() =>
ingestCachedEmbeddingRow(builder, { ...row('b', 2), embedding: [1, 2, 3] }, true),
).toThrow(/dim mismatch/);
abortCachedEmbeddingsBuilder(builder);
const shortPath = path.join(os.tmpdir(), `gitnexus-embed-restore-short-${process.pid}.bin`);
writeFileSync(shortPath, Buffer.from('NOPE'));
spills.push({ path: shortPath });
expect(() => readSpillVectors({ path: shortPath, dims: DIMS, rowCount: 1 }, [0])).toThrow(
/invalid embedding spill header/,
);
const badMagic = Buffer.alloc(12);
badMagic.write('NOPE', 0, 4, 'ascii');
badMagic.writeUInt8(1, 4);
badMagic.writeUInt32LE(DIMS, 5);
const badPath = path.join(os.tmpdir(), `gitnexus-embed-restore-bad-${process.pid}.bin`);
writeFileSync(badPath, badMagic);
spills.push({ path: badPath });
expect(() => readSpillVectors({ path: badPath, dims: DIMS, rowCount: 1 }, [0])).toThrow(
/invalid embedding spill header/,
);
});
it('reuses an open spill reader across materialize batches', () => {
const builder = createCachedEmbeddingsBuilder({
inMemoryRowLimit: 0,
spillDir: os.tmpdir(),
});
for (let i = 0; i < 4; i++) {
ingestCachedEmbeddingRow(builder, row(`n${i}`, i + 1), true);
}
const snapshot = finalizeCachedEmbeddingsSnapshot(builder);
if (snapshot.spill) spills.push(snapshot.spill);
const reader = new EmbeddingSpillReader(snapshot.spill!);
try {
const first = materializeCachedEmbeddings(snapshot, snapshot.rows.slice(0, 2), reader);
const second = materializeCachedEmbeddings(snapshot, snapshot.rows.slice(2, 4), reader);
expect(first[0]?.embedding[0]).toBeCloseTo(1);
expect(second[1]?.embedding[0]).toBeCloseTo(4);
} finally {
reader.close();
}
});
it('scoped discard unlinks only spills created in that analyze run', async () => {
const other = createCachedEmbeddingsBuilder({
inMemoryRowLimit: 0,
spillDir: os.tmpdir(),
});
ingestCachedEmbeddingRow(other, row('other', 1), true);
const otherSnapshot = finalizeCachedEmbeddingsSnapshot(other);
if (otherSnapshot.spill) spills.push(otherSnapshot.spill);
expect(existsSync(otherSnapshot.spill!.path)).toBe(true);
await withEmbeddingSpillScope(async () => {
const builder = createCachedEmbeddingsBuilder({
inMemoryRowLimit: 0,
spillDir: os.tmpdir(),
});
ingestCachedEmbeddingRow(builder, row('scoped', 2), true);
const snapshot = finalizeCachedEmbeddingsSnapshot(builder);
expect(existsSync(snapshot.spill!.path)).toBe(true);
discardScopedEmbeddingSpills();
expect(existsSync(snapshot.spill!.path)).toBe(false);
expect(existsSync(otherSnapshot.spill!.path)).toBe(true);
});
});
it('unlinks a finished spill that was not disposed', () => {
const builder = createCachedEmbeddingsBuilder({
inMemoryRowLimit: 0,
spillDir: os.tmpdir(),
});
ingestCachedEmbeddingRow(builder, row('n1', 1), true);
const snapshot = finalizeCachedEmbeddingsSnapshot(builder);
expect(snapshot.spill).toBeDefined();
expect(existsSync(snapshot.spill!.path)).toBe(true);
discardLiveEmbeddingSpills();
expect(existsSync(snapshot.spill!.path)).toBe(false);
});
it('throws when materializing a meta row with no matching vector', () => {
const snapshot = normalizeCachedEmbeddings({
embeddings: [
{
nodeId: 'Function:a:foo',
chunkIndex: 0,
startLine: 0,
endLine: 3,
embedding: vector(0.1),
contentHash: 'stub',
},
],
});
expect(() =>
materializeCachedEmbeddings(snapshot, [
{
nodeId: 'Function:missing:bar',
chunkIndex: 0,
startLine: 0,
endLine: 1,
contentHash: 'x',
vectorIndex: 99,
},
]),
).toThrow(/missing cached embedding Function:missing:bar:0/);
});
});

View file

@ -4,7 +4,16 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import * as t from '@babel/types';
import {
type AstNode,
collectDescendants,
lineAt,
nodeStart,
nodeText,
parseTypeScript,
staticMemberName,
} from '../../helpers/parse-typescript-source.js';
import type {
ContractRegistry,
ExtractedContract,
@ -182,65 +191,63 @@ describe('syncGroup when one extractor fails partway through a repo', () => {
*/
const SYNC_SOURCE_PATH = fileURLToPath(new URL('../../../src/core/group/sync.ts', import.meta.url));
/** Every node under `node`, in source order. No branching, so nothing is skippable. */
function descendants(node: ts.Node): ts.Node[] {
const out: ts.Node[] = [];
const visit = (n: ts.Node): void => {
out.push(n);
n.forEachChild(visit);
};
node.forEachChild(visit);
return out;
}
/** `const <name>: StoredContract[] = []` — the per-repo staging buffer. */
function isStagingBufferDeclaration(node: ts.Node): node is ts.VariableDeclaration {
function isStagingBufferDeclaration(node: t.Node): node is t.VariableDeclarator {
if (!t.isVariableDeclarator(node) || !t.isIdentifier(node.id)) return false;
const annotation = node.id.typeAnnotation;
if (
!annotation ||
!t.isTSTypeAnnotation(annotation) ||
!t.isTSArrayType(annotation.typeAnnotation)
) {
return false;
}
const elementType = annotation.typeAnnotation.elementType;
return (
ts.isVariableDeclaration(node) &&
node.type !== undefined &&
ts.isArrayTypeNode(node.type) &&
ts.isTypeReferenceNode(node.type.elementType) &&
ts.isIdentifier(node.type.elementType.typeName) &&
node.type.elementType.typeName.text === 'StoredContract' &&
node.initializer !== undefined &&
ts.isArrayLiteralExpression(node.initializer) &&
node.initializer.elements.length === 0 &&
ts.isVariableDeclarationList(node.parent) &&
(node.parent.flags & ts.NodeFlags.Const) !== 0
t.isTSTypeReference(elementType) &&
t.isIdentifier(elementType.typeName) &&
elementType.typeName.name === 'StoredContract' &&
node.init !== undefined &&
node.init !== null &&
t.isArrayExpression(node.init) &&
node.init.elements.length === 0 &&
t.isVariableDeclaration((node as AstNode).parent) &&
((node as AstNode).parent as t.VariableDeclaration).kind === 'const'
);
}
/** `x.apply(dest, args)` — an argument-limited append in non-spread clothing. */
function isApplyCall(call: ts.CallExpression): boolean {
return ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === 'apply';
function isApplyCall(call: t.CallExpression): boolean {
return (
(t.isMemberExpression(call.callee) || t.isOptionalMemberExpression(call.callee)) &&
staticMemberName(call.callee) === 'apply'
);
}
function describeCall(sourceFile: ts.SourceFile, call: ts.CallExpression): string {
const { line } = sourceFile.getLineAndCharacterOfPosition(call.getStart(sourceFile));
return `${line + 1}: ${call.getText(sourceFile).replace(/\s+/g, ' ')}`;
function describeCall(source: string, call: t.CallExpression): string {
const line = lineAt(source, nodeStart(call));
return `${line}: ${nodeText(source, call).replace(/\s+/g, ' ')}`;
}
describe('the per-repo staging append in sync.ts', () => {
it('appends the staged contracts without spreading them into a call', () => {
const source = fs.readFileSync(SYNC_SOURCE_PATH, 'utf-8');
const sourceFile = ts.createSourceFile(
SYNC_SOURCE_PATH,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const { ast } = parseTypeScript(SYNC_SOURCE_PATH, source);
const allNodes = descendants(sourceFile);
const allNodes = collectDescendants(ast);
const stagingBuffers = allNodes.filter(isStagingBufferDeclaration);
// One staging buffer, or this gate no longer knows which code it guards.
expect(stagingBuffers.map((d) => d.name.getText(sourceFile))).toHaveLength(1);
const stagingNames = stagingBuffers.map((d) => d.name.getText(sourceFile));
const stagingNames = stagingBuffers.map((d) =>
t.isIdentifier(d.id) ? d.id.name : nodeText(source, d.id),
);
expect(stagingNames).toHaveLength(1);
// The block the buffer is declared in — the per-repo loop body.
// VariableDeclarator → VariableDeclaration → BlockStatement (Babel has no
// extra VariableStatement wrapper).
const declaringBlocks = stagingBuffers
.map((d) => d.parent.parent.parent) // declaration → list → statement → block
.filter(ts.isBlock);
.map((d) => (d as AstNode).parent?.parent)
.filter((node): node is t.BlockStatement => t.isBlockStatement(node));
expect(declaringBlocks).toHaveLength(1);
// The extractor try-block: a DIRECT statement of that block whose `try` reads
@ -249,22 +256,22 @@ describe('the per-repo staging append in sync.ts', () => {
// ancestor reads the buffer too. Widening to "any try that mentions it" pulls
// in the entire function body, manifest-window spreads and all.
const extractorTryBlocks = declaringBlocks.flatMap((block) =>
block.statements
.filter(ts.isTryStatement)
block.body
.filter((statement): statement is t.TryStatement => t.isTryStatement(statement))
.filter((statement) =>
descendants(statement.tryBlock).some(
(n) => ts.isIdentifier(n) && stagingNames.includes(n.text),
collectDescendants(statement.block).some(
(n) => t.isIdentifier(n) && stagingNames.includes(n.name),
),
)
.map((statement) => statement.tryBlock),
.map((statement) => statement.block),
);
expect(extractorTryBlocks).toHaveLength(1);
const unboundedAppends = extractorTryBlocks.flatMap((block) =>
descendants(block)
.filter(ts.isCallExpression)
.filter((call) => call.arguments.some(ts.isSpreadElement) || isApplyCall(call))
.map((call) => describeCall(sourceFile, call)),
collectDescendants(block)
.filter((node): node is t.CallExpression => t.isCallExpression(node))
.filter((call) => call.arguments.some((arg) => t.isSpreadElement(arg)) || isApplyCall(call))
.map((call) => describeCall(source, call)),
);
// Every staged contract must reach `autoContracts` through a bounded loop:

View file

@ -19,7 +19,7 @@
*/
import { writeFile, readFile } from 'fs/promises';
import { describe, it, expect } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getStoragePaths,
saveMeta,
@ -35,92 +35,109 @@ import { seedEmbeddingsForFiles } from '../helpers/embedding-seed.js';
const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-dirty-rec-');
async function parkCrashSidecarsAndRebuild(options?: { alwaysSpill?: boolean }) {
vi.stubEnv('GITNEXUS_WORKER_READY_TIMEOUT_MS', '60000');
if (options?.alwaysSpill) {
vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '0');
}
const repo = await setupMiniRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
// Seed real embeddings BEFORE the tamper (tri-review 4669518496 / U5):
// with meta.stats.embeddings = 0 the recovery run derived
// shouldLoadCache=false and never opened the DB pre-wipe — this test
// was vacuous about the exact open the parking protects. Seeded rows +
// a stats stamp route the recovery (which runs force:true internally,
// so forceRegenerate → shouldLoadCache) through the REAL
// embedding-cache preservation open on the just-parked DB.
const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
const seededIdsByFile = await seedEmbeddingsForFiles(
repo.dbPath,
['src/handler.ts', 'src/logger.ts'],
1,
);
const seededNodeIds = [...seededIdsByFile.values()].flat();
expect(seededNodeIds.length).toBeGreaterThan(0);
// Simulate a crashed incremental writeback: dirty flag in meta plus
// leftover sidecars whose bytes must never be replayed. 8KB puts the
// WAL above the tiny-orphan threshold — the state the sidecar
// preflight deliberately leaves in place for engine replay.
const meta = await loadMeta(storagePath);
const tampered: RepoMeta = {
...meta!,
stats: { ...meta!.stats, embeddings: seededNodeIds.length },
incrementalInProgress: {
startedAt: Date.now() - 60_000,
toWriteCount: 12,
phase: 'load-graph',
},
};
await saveMeta(storagePath, tampered);
const walGarbage = Buffer.alloc(8192, 0xab);
const shadowGarbage = Buffer.alloc(4096, 0xcd);
await writeFile(`${lbugPath}.wal`, walGarbage);
await writeFile(`${lbugPath}.shadow`, shadowGarbage);
const logs: string[] = [];
// embeddingsNodeLimit: 1 (KTD9): the recovery runs force:true
// internally, and the seeded stats would otherwise route Phase 4 into
// a real embedder in CI — the 1-node cap suppresses generation while
// leaving the preserve/restore path fully live. On linux the
// wipe-and-restore vector-index seam then fires for real (statically
// linked VECTOR): a CREATE_VECTOR_INDEX over the restored rows is
// expected and harmless here.
const recovered = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, embeddingsNodeLimit: 1 },
{ onProgress: () => {}, onLog: (m) => logs.push(m) },
);
expect(recovered.alreadyUpToDate).toBeUndefined();
// Both sidecars were parked verbatim (renamed, never deleted) before
// any open could replay them…
expect(Buffer.compare(await readFile(`${lbugPath}.wal.dirty-recovery`), walGarbage)).toBe(0);
expect(Buffer.compare(await readFile(`${lbugPath}.shadow.dirty-recovery`), shadowGarbage)).toBe(
0,
);
const joinedLogs = logs.join('\n');
expect(joinedLogs).toContain('Parked lbug.wal.dirty-recovery, lbug.shadow.dirty-recovery');
// …the run traversed the REAL pre-wipe preservation open — recovery's
// internal force on an embedded repo upgrades to regenerate mode, whose
// banner only prints when existingEmbeddingCount was read from the
// seeded stats and the cache-load path engaged…
expect(joinedLogs).toContain(
`--force on a repo with ${seededNodeIds.length} existing embeddings`,
);
// …with generation itself cap-suppressed (no embedder in CI):
expect(joinedLogs).toContain('exceeds the 1-node safety cap');
// …and the rebuild completed into a clean index: dirty flag cleared,
// and the seeded embeddings survived the park → open → wipe → restore
// round-trip (the strongest signal the preservation open really ran:
// the DB was wiped, so these rows can only come from the cache load).
const after = await loadMeta(storagePath);
expect(after!.incrementalInProgress).toBeUndefined();
expect(after!.stats?.embeddings).toBe(seededNodeIds.length);
} finally {
vi.unstubAllEnvs();
await repo.cleanup();
}
}
describe('runFullAnalysis — dirty-flag recovery sidecar parking (#2409)', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('parks the crashed run WAL/shadow sidecars before reopening, then rebuilds clean', async () => {
const repo = await setupMiniRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
await parkCrashSidecarsAndRebuild();
}, 300_000);
// Seed real embeddings BEFORE the tamper (tri-review 4669518496 / U5):
// with meta.stats.embeddings = 0 the recovery run derived
// shouldLoadCache=false and never opened the DB pre-wipe — this test
// was vacuous about the exact open the parking protects. Seeded rows +
// a stats stamp route the recovery (which runs force:true internally,
// so forceRegenerate → shouldLoadCache) through the REAL
// embedding-cache preservation open on the just-parked DB.
const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
const seededIdsByFile = await seedEmbeddingsForFiles(
repo.dbPath,
['src/handler.ts', 'src/logger.ts'],
1,
);
const seededNodeIds = [...seededIdsByFile.values()].flat();
expect(seededNodeIds.length).toBeGreaterThan(0);
// Simulate a crashed incremental writeback: dirty flag in meta plus
// leftover sidecars whose bytes must never be replayed. 8KB puts the
// WAL above the tiny-orphan threshold — the state the sidecar
// preflight deliberately leaves in place for engine replay.
const meta = await loadMeta(storagePath);
const tampered: RepoMeta = {
...meta!,
stats: { ...meta!.stats, embeddings: seededNodeIds.length },
incrementalInProgress: {
startedAt: Date.now() - 60_000,
toWriteCount: 12,
phase: 'load-graph',
},
};
await saveMeta(storagePath, tampered);
const walGarbage = Buffer.alloc(8192, 0xab);
const shadowGarbage = Buffer.alloc(4096, 0xcd);
await writeFile(`${lbugPath}.wal`, walGarbage);
await writeFile(`${lbugPath}.shadow`, shadowGarbage);
const logs: string[] = [];
// embeddingsNodeLimit: 1 (KTD9): the recovery runs force:true
// internally, and the seeded stats would otherwise route Phase 4 into
// a real embedder in CI — the 1-node cap suppresses generation while
// leaving the preserve/restore path fully live. On linux the
// wipe-and-restore vector-index seam then fires for real (statically
// linked VECTOR): a CREATE_VECTOR_INDEX over the restored rows is
// expected and harmless here.
const recovered = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, embeddingsNodeLimit: 1 },
{ onProgress: () => {}, onLog: (m) => logs.push(m) },
);
expect(recovered.alreadyUpToDate).toBeUndefined();
// Both sidecars were parked verbatim (renamed, never deleted) before
// any open could replay them…
expect(Buffer.compare(await readFile(`${lbugPath}.wal.dirty-recovery`), walGarbage)).toBe(0);
expect(
Buffer.compare(await readFile(`${lbugPath}.shadow.dirty-recovery`), shadowGarbage),
).toBe(0);
const joinedLogs = logs.join('\n');
expect(joinedLogs).toContain('Parked lbug.wal.dirty-recovery, lbug.shadow.dirty-recovery');
// …the run traversed the REAL pre-wipe preservation open — recovery's
// internal force on an embedded repo upgrades to regenerate mode, whose
// banner only prints when existingEmbeddingCount was read from the
// seeded stats and the cache-load path engaged…
expect(joinedLogs).toContain(
`--force on a repo with ${seededNodeIds.length} existing embeddings`,
);
// …with generation itself cap-suppressed (no embedder in CI):
expect(joinedLogs).toContain('exceeds the 1-node safety cap');
// …and the rebuild completed into a clean index: dirty flag cleared,
// and the seeded embeddings survived the park → open → wipe → restore
// round-trip (the strongest signal the preservation open really ran:
// the DB was wiped, so these rows can only come from the cache load).
const after = await loadMeta(storagePath);
expect(after!.incrementalInProgress).toBeUndefined();
expect(after!.stats?.embeddings).toBe(seededNodeIds.length);
} finally {
await repo.cleanup();
}
it('restores seeded embeddings from a spill after wipe when the in-memory limit is 0', async () => {
await parkCrashSidecarsAndRebuild({ alwaysSpill: true });
}, 300_000);
});

View file

@ -261,6 +261,31 @@ describe('detect_changes BasicBlock exclusion (#2082 U7)', () => {
});
describe('runFullAnalysis — pdg-mode flip (#2099 F1)', () => {
it('resolves preserveExistingPdg from metadata after entering the locked analysis path', async () => {
const repo = await setupMiniRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const { storagePath } = getStoragePaths(repo.dbPath);
const cb = { onProgress: () => {}, onLog: () => {} };
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, pdg: true }, cb);
const blocks = await countBasicBlocks(repo.dbPath);
expect(blocks).toBeGreaterThan(0);
const preserved = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, preserveExistingPdg: true },
cb,
);
expect(preserved.alreadyUpToDate).toBe(true);
expect((await loadMeta(storagePath))!.pdg).toBeDefined();
expect(await countBasicBlocks(repo.dbPath)).toBe(blocks);
} finally {
await repo.cleanup();
}
}, 600_000);
it('off→on flip forces a full writeback that persists the CFG layer; on→off removes it', async () => {
const repo = await setupMiniRepo();
try {

View file

@ -47,10 +47,21 @@
* and `detect-changes-local-id-stability.test.ts`.
*/
import { describe, expect, it } from 'vitest';
import ts from 'typescript';
import * as t from '@babel/types';
import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
type AstNode,
type CommentRange,
forEachChild,
leadingCommentRanges,
lineAt,
nodeStart,
nodeText,
parseTypeScript,
trailingCommentRanges,
} from '../helpers/parse-typescript-source.js';
const SRC_DIR = fileURLToPath(new URL('../../src/', import.meta.url));
@ -135,18 +146,18 @@ function walkTs(dir: string, relBase: string, out: string[]): string[] {
* helpers (`seedBlockQuery`, `buildFtsQueryCypher`), and anything built from
* those. Resolved to a fixpoint so an ordering constant can be composed.
*/
function collectOrderingNames(sf: ts.SourceFile): Set<string> {
function collectOrderingNames(source: string, ast: t.Node): Set<string> {
const initializers = new Map<string, string>();
const visit = (node: ts.Node): void => {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
initializers.set(node.name.text, node.initializer.getText(sf));
const visit = (node: t.Node): void => {
if (t.isVariableDeclarator(node) && t.isIdentifier(node.id) && node.init) {
initializers.set(node.id.name, nodeText(source, node.init));
}
if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
initializers.set(node.name.text, node.getText(sf));
if (t.isFunctionDeclaration(node) && t.isIdentifier(node.id)) {
initializers.set(node.id.name, nodeText(source, node));
}
ts.forEachChild(node, visit);
forEachChild(node, visit);
};
visit(sf);
visit(ast);
const ordering = new Set<string>();
const referencesOrdering = (text: string): boolean =>
@ -166,38 +177,38 @@ function collectOrderingNames(sf: ts.SourceFile): Set<string> {
// ---------------------------------------------------------------------------
// Query units
// ---------------------------------------------------------------------------
const isLiteralUnit = (node: ts.Node): boolean =>
ts.isStringLiteralLike(node) || ts.isTemplateExpression(node);
const isLiteralUnit = (node: t.Node): boolean =>
t.isStringLiteral(node) || t.isTemplateLiteral(node);
/** Climb `+` concatenation so a query split across fragments is ONE unit. */
function concatenationRoot(node: ts.Node): ts.Node {
let current = node;
function concatenationRoot(node: AstNode): AstNode {
let current: AstNode = node;
while (
current.parent !== undefined &&
ts.isBinaryExpression(current.parent) &&
current.parent.operatorToken.kind === ts.SyntaxKind.PlusToken
t.isBinaryExpression(current.parent) &&
current.parent.operator === '+'
) {
current = current.parent;
}
return current;
}
function collectQueryUnits(sf: ts.SourceFile): ts.Node[] {
const byPosition = new Map<number, ts.Node>();
const visit = (node: ts.Node): void => {
function collectQueryUnits(source: string, ast: t.Node): AstNode[] {
const byPosition = new Map<number, AstNode>();
const visit = (node: AstNode): void => {
if (!isLiteralUnit(node)) {
ts.forEachChild(node, visit);
forEachChild(node, visit);
return;
}
// Never descend INTO a literal: a nested template inside `${...}` is
// already covered by the outer unit's text.
const unit = concatenationRoot(node);
const text = unit.getText(sf);
const text = nodeText(source, unit);
if (LIMIT_RE.test(text) && (CYPHER_CLAUSE_RE.test(text) || BARE_LIMIT_FRAGMENT_RE.test(text))) {
byPosition.set(unit.getStart(sf), unit);
byPosition.set(nodeStart(unit), unit);
}
};
visit(sf);
visit(ast as AstNode);
return [...byPosition.entries()].sort((a, b) => a[0] - b[0]).map(([, node]) => node);
}
@ -216,14 +227,14 @@ interface CommentBlock {
end: number;
}
const isFunctionLike = (node: ts.Node): boolean =>
ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node) ||
ts.isConstructorDeclaration(node) ||
ts.isGetAccessorDeclaration(node) ||
ts.isSetAccessorDeclaration(node);
const isFunctionLike = (node: t.Node): boolean =>
t.isFunctionDeclaration(node) ||
t.isFunctionExpression(node) ||
t.isArrowFunctionExpression(node) ||
t.isClassMethod(node) ||
t.isClassPrivateMethod(node) ||
t.isObjectMethod(node) ||
t.isTSDeclareMethod(node);
/**
* Comment blocks a marker for `node` may live in, NEAREST FIRST: the query's
@ -239,22 +250,21 @@ const isFunctionLike = (node: ts.Node): boolean =>
* Consecutive `//` lines arrive as one range EACH, so they are merged back into
* a block: a reason may then wrap across lines and still fit `printWidth: 100`.
*/
function markerBlocks(source: string, node: ts.Node): CommentBlock[] {
const nearestFirst: ts.CommentRange[] = [];
function markerBlocks(source: string, node: AstNode): CommentBlock[] {
const nearestFirst: CommentRange[] = [];
const seen = new Set<number>();
let current: ts.Node | undefined = node;
let current: AstNode | undefined = node;
for (let depth = 0; current !== undefined && depth < 20; depth++) {
for (const range of [
...(ts.getLeadingCommentRanges(source, current.getFullStart()) ?? []),
...(ts.getTrailingCommentRanges(source, current.getEnd()) ?? []),
]) {
for (const range of [...leadingCommentRanges(current), ...trailingCommentRanges(current)]) {
if (!seen.has(range.pos)) {
seen.add(range.pos);
nearestFirst.push(range);
}
}
const parent: ts.Node | undefined = current.parent;
if (parent === undefined || isFunctionLike(parent) || ts.isSourceFile(parent)) break;
const parent: AstNode | undefined = current.parent;
if (parent === undefined || isFunctionLike(parent) || t.isFile(parent) || t.isProgram(parent)) {
break;
}
current = parent;
}
@ -318,15 +328,15 @@ const reasonIsSubstantive = (reason: string): boolean =>
// ---------------------------------------------------------------------------
export function analyzeSource(file: string, source: string): QueryFinding[] {
if (!LIMIT_RE.test(source)) return [];
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
const ordering = collectOrderingNames(sf);
const { ast } = parseTypeScript(file, source);
const ordering = collectOrderingNames(source, ast);
/** A marker declares ONE query: a claimed range cannot also cover the next. */
const claimed = new Map<number, number>();
return collectQueryUnits(sf).map((unit) => {
const text = unit.getText(sf);
return collectQueryUnits(source, ast).map((unit) => {
const text = nodeText(source, unit);
const limitOffset = Math.max(text.search(LIMIT_RE), 0);
const line = sf.getLineAndCharacterOfPosition(unit.getStart(sf) + limitOffset).line + 1;
const line = lineAt(source, nodeStart(unit) + limitOffset);
const excerpt = text.replace(/\s+/g, ' ').slice(0, 160);
const base = { file, line, excerpt };

View file

@ -7,7 +7,7 @@ import {
saveMeta,
type RepoMeta,
} from '../../src/storage/repo-manager.js';
import { EMBEDDING_DIMS } from '../../src/core/lbug/schema.js';
import { EMBEDDING_DIMS, STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js';
import { getIndexIncompleteReasons } from '../../src/core/index-freshness.js';
import type {
EmbeddingPipelineOptions,
@ -1225,6 +1225,313 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951
await tmpRepo.cleanup();
}
});
it('warns and continues without restore when loadCachedEmbeddings rejects (R11)', async () => {
const RESTORED_NODE_ID = 'Function:src/app.ts:handler:1';
const stubNode = {
id: RESTORED_NODE_ID,
label: 'Function',
name: 'handler',
properties: { filePath: 'src/app.ts' },
};
const executeWithReusedStatement = vi.fn(async () => []);
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
initLbug: vi.fn(async () => undefined),
loadGraphToLbug: vi.fn(async () => undefined),
getLbugStats: vi.fn(async () => ({ nodes: 2, edges: 0, communities: 0, processes: 0 })),
executeQuery: vi.fn(async () => []),
executeWithReusedStatement,
closeLbug: vi.fn(async () => undefined),
wipeLbugDbFiles: vi.fn(async () => undefined),
tryFlushWAL: vi.fn(async () => true),
loadCachedEmbeddings: vi.fn(async () => {
throw new Error('spill write failed');
}),
deleteNodesForFile: vi.fn(async () => undefined),
deleteNodesForFiles: vi.fn(async () => undefined),
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
queryImporters: vi.fn(async () => []),
queryImportersBatch: vi.fn(async () => []),
loadFTSExtension: vi.fn(async () => false),
}));
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
initialiseSearchFTSStemmer: vi.fn(() => 'porter'),
createSearchFTSIndexes: vi.fn(async () => []),
verifySearchFTSIndexes: vi.fn(async () => []),
}));
vi.doMock('../../src/core/ingestion/pipeline.js', () => ({
runPipelineFromRepo: vi.fn(async (repoPath: string) => ({
repoPath,
totalFileCount: 1,
graph: {
forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode),
getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined),
},
})),
}));
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
registerRepo: vi.fn(async () => 'cache-load-reject-repo'),
ensureGitNexusIgnored: vi.fn(async () => undefined),
}));
vi.doMock('../../src/core/embeddings/embedding-pipeline.js', async (importActual) => ({
...(await importActual<typeof import('../../src/core/embeddings/embedding-pipeline.js')>()),
batchInsertEmbeddings: vi.fn(async () => {
throw new Error('restore must not run after a cache-load failure');
}),
}));
const tmpRepo = await createTempDir('gitnexus-run-analyze-cache-reject-');
try {
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
await fs.mkdir(storagePath, { recursive: true });
await saveMeta(storagePath, {
repoPath: tmpRepo.dbPath,
lastCommit: '',
indexedAt: new Date().toISOString(),
stats: { embeddings: 1 },
});
const logs: string[] = [];
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(
tmpRepo.dbPath,
{ force: true, embeddingsNodeLimit: 1 },
{ onProgress: () => {}, onLog: (m) => logs.push(m) },
);
expect(logs.some((m) => m.includes('Warning: could not load cached embeddings'))).toBe(true);
expect(logs.some((m) => m.includes('spill write failed'))).toBe(true);
expect(
executeWithReusedStatement.mock.calls.some((call) =>
String(call[0]).includes('CREATE (e:CodeEmbedding'),
),
).toBe(false);
} finally {
await tmpRepo.cleanup();
}
});
it('omits failed restore rows from the Phase 4 skip-set so they can be re-embedded', async () => {
const RESTORED_NODE_ID = 'Function:src/app.ts:handler:1';
const CACHED_HASH = 'cached-stable-hash';
const stubNode = {
id: RESTORED_NODE_ID,
label: 'Function',
name: 'handler',
properties: { filePath: 'src/app.ts' },
};
let existingEmbeddings: Map<string, string> | undefined;
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
initLbug: vi.fn(async () => undefined),
loadGraphToLbug: vi.fn(async () => undefined),
getLbugStats: vi.fn(async () => ({ nodes: 2, edges: 0, communities: 0, processes: 0 })),
executeQuery: vi.fn(async () => []),
executeWithReusedStatement: vi.fn(async () => []),
closeLbug: vi.fn(async () => undefined),
wipeLbugDbFiles: vi.fn(async () => undefined),
tryFlushWAL: vi.fn(async () => true),
loadCachedEmbeddings: vi.fn(async () => ({
embeddingNodeIds: new Set([RESTORED_NODE_ID]),
embeddings: [
{
nodeId: RESTORED_NODE_ID,
chunkIndex: 0,
startLine: 0,
endLine: 3,
embedding: new Array(EMBEDDING_DIMS).fill(0),
contentHash: CACHED_HASH,
},
],
})),
deleteNodesForFile: vi.fn(async () => undefined),
deleteNodesForFiles: vi.fn(async () => undefined),
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
queryImporters: vi.fn(async () => []),
queryImportersBatch: vi.fn(async () => []),
loadFTSExtension: vi.fn(async () => false),
}));
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
initialiseSearchFTSStemmer: vi.fn(() => 'porter'),
createSearchFTSIndexes: vi.fn(async () => []),
verifySearchFTSIndexes: vi.fn(async () => []),
}));
vi.doMock('../../src/core/ingestion/pipeline.js', () => ({
runPipelineFromRepo: vi.fn(async (repoPath: string) => ({
repoPath,
totalFileCount: 1,
graph: {
forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode),
getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined),
},
})),
}));
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
registerRepo: vi.fn(async () => 'ktd7-skip-set-repo'),
ensureGitNexusIgnored: vi.fn(async () => undefined),
}));
const runEmbeddingPipeline = vi.fn(
async (
_executeQuery: unknown,
_executeWithReusedStatement: unknown,
_onProgress: unknown,
_config: unknown,
_cachedNodeIds: unknown,
embeddings: Map<string, string> | undefined,
) => {
existingEmbeddings = embeddings;
return {
nodesProcessed: 0,
chunksProcessed: 0,
vectorIndexReady: false,
semanticMode: 'exact-scan' as const,
failedNodeIds: [],
};
},
);
vi.doMock('../../src/core/embeddings/embedding-pipeline.js', () => ({
runEmbeddingPipeline,
buildVectorIndex: vi.fn(async () => false),
batchInsertEmbeddings: vi.fn(async () => {
throw new Error('nth batch insert failed');
}),
}));
const tmpRepo = await createTempDir('gitnexus-run-analyze-ktd7-');
try {
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
await fs.mkdir(storagePath, { recursive: true });
await saveMeta(storagePath, {
repoPath: tmpRepo.dbPath,
lastCommit: '',
indexedAt: new Date().toISOString(),
stats: { embeddings: 1 },
});
const logs: string[] = [];
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(
tmpRepo.dbPath,
{ force: true },
{ onProgress: () => {}, onLog: (m) => logs.push(m) },
);
expect(runEmbeddingPipeline).toHaveBeenCalled();
expect(existingEmbeddings?.get(RESTORED_NODE_ID)).toBe(STALE_HASH_SENTINEL);
expect(logs.some((m) => m.includes('Warning: could not restore'))).toBe(true);
} finally {
await tmpRepo.cleanup();
}
});
it('marks a node stale when only some of its restore batches succeed', async () => {
const RESTORED_NODE_ID = 'Function:src/app.ts:handler:1';
const CACHED_HASH = 'cached-stable-hash';
const stubNode = {
id: RESTORED_NODE_ID,
label: 'Function',
name: 'handler',
properties: { filePath: 'src/app.ts' },
};
const embeddings = Array.from({ length: 201 }, (_, chunkIndex) => ({
nodeId: RESTORED_NODE_ID,
chunkIndex,
startLine: chunkIndex,
endLine: chunkIndex + 1,
embedding: new Array(EMBEDDING_DIMS).fill(0),
contentHash: CACHED_HASH,
}));
let existingEmbeddings: Map<string, string> | undefined;
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
initLbug: vi.fn(async () => undefined),
loadGraphToLbug: vi.fn(async () => undefined),
getLbugStats: vi.fn(async () => ({ nodes: 2, edges: 0, communities: 0, processes: 0 })),
executeQuery: vi.fn(async () => []),
executeWithReusedStatement: vi.fn(async () => []),
closeLbug: vi.fn(async () => undefined),
wipeLbugDbFiles: vi.fn(async () => undefined),
tryFlushWAL: vi.fn(async () => true),
loadCachedEmbeddings: vi.fn(async () => ({
embeddingNodeIds: new Set([RESTORED_NODE_ID]),
embeddings,
})),
deleteNodesForFile: vi.fn(async () => undefined),
deleteNodesForFiles: vi.fn(async () => undefined),
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
queryImporters: vi.fn(async () => []),
queryImportersBatch: vi.fn(async () => []),
loadFTSExtension: vi.fn(async () => false),
}));
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
initialiseSearchFTSStemmer: vi.fn(() => 'porter'),
createSearchFTSIndexes: vi.fn(async () => []),
verifySearchFTSIndexes: vi.fn(async () => []),
}));
vi.doMock('../../src/core/ingestion/pipeline.js', () => ({
runPipelineFromRepo: vi.fn(async (repoPath: string) => ({
repoPath,
totalFileCount: 1,
graph: {
forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode),
getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined),
},
})),
}));
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
registerRepo: vi.fn(async () => 'partial-restore-skip-set-repo'),
ensureGitNexusIgnored: vi.fn(async () => undefined),
}));
const runEmbeddingPipeline = vi.fn(
async (
_executeQuery: unknown,
_executeWithReusedStatement: unknown,
_onProgress: unknown,
_config: unknown,
_cachedNodeIds: unknown,
embeddings: Map<string, string> | undefined,
) => {
existingEmbeddings = embeddings;
return {
nodesProcessed: 0,
chunksProcessed: 0,
vectorIndexReady: false,
semanticMode: 'exact-scan' as const,
failedNodeIds: [],
};
},
);
let inserts = 0;
vi.doMock('../../src/core/embeddings/embedding-pipeline.js', () => ({
runEmbeddingPipeline,
buildVectorIndex: vi.fn(async () => false),
batchInsertEmbeddings: vi.fn(async () => {
inserts += 1;
if (inserts > 1) throw new Error('second batch insert failed');
}),
}));
const tmpRepo = await createTempDir('gitnexus-run-analyze-partial-restore-');
try {
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
await fs.mkdir(storagePath, { recursive: true });
await saveMeta(storagePath, {
repoPath: tmpRepo.dbPath,
lastCommit: '',
indexedAt: new Date().toISOString(),
stats: { embeddings: 201 },
});
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(tmpRepo.dbPath, { force: true }, { onProgress: () => {} });
expect(inserts).toBe(2);
expect(existingEmbeddings?.get(RESTORED_NODE_ID)).toBe(STALE_HASH_SENTINEL);
} finally {
await tmpRepo.cleanup();
}
});
});
/**

View file

@ -39,6 +39,7 @@ const workflowDocument = load(workflow) as {
'timeout-minutes'?: unknown;
with?: Record<string, unknown>;
env?: Record<string, string>;
'working-directory'?: string;
}>;
}
>;
@ -476,16 +477,26 @@ exit 1`);
expect(containment).toContain('gitnexus-cursor-integration/skills/gitnexus-review/*');
});
it('installs node_modules for the monorepo root, gitnexus-shared, and gitnexus', () => {
// The benchmark sandbox-copies node_modules from all three (tasks.scenarios.yaml).
// The root tree was absent on the first real run because only the two subpackage
// steps ran, so capture_task_dependency_binding aborted at task binding.
it('installs node_modules for the monorepo root and gitnexus, and compiles shared from the parent', () => {
// The benchmark sandbox-copies node_modules from root and gitnexus
// (tasks.scenarios.yaml). Shared is compiled by gitnexus `npm run build`
// (scripts/build.js); a dedicated npm ci in gitnexus-shared stalls CI.
const rootStep = findStep('Install monorepo root dependencies');
expect(rootStep).toBeDefined();
expect(rootStep).not.toHaveProperty('working-directory'); // installs at the repo root
expect(String(rootStep?.run)).toContain('npm ci');
expect(stepRun('Build pinned shared runtime')).toContain('npm ci');
expect(stepRun('Install and build pinned GitNexus runtime')).toContain('npm ci');
expect(findStep('Build pinned shared runtime')).toBeUndefined();
const gitnexusRun = stepRun('Install and build pinned GitNexus runtime');
expect(gitnexusRun).toContain('npm ci');
expect(gitnexusRun).toContain('npm run build');
expect(gitnexusRun).toContain('mkdir -p ../gitnexus-shared/node_modules');
expect(
evolveJob?.steps?.some(
(step) =>
step['working-directory'] === 'gitnexus-shared' &&
String(step.run ?? '').includes('npm ci'),
),
).toBe(false);
});
it('waits for the runner boot-time package lock before installing containment tools', () => {

View file

@ -100,6 +100,7 @@ export default defineConfig({
'test/integration/analyze-wal-checkpoint-failure.test.ts',
'test/integration/lbug-non-ascii-path.test.ts',
'test/integration/lbug-conn-serialization.test.ts',
'test/integration/load-cached-embeddings-spill.test.ts',
'test/integration/group/manifest-resolve-symbol-2325.test.ts',
'test/integration/group/manifest-synthetic-impact-lbug.test.ts',
'test/integration/group/http-route-resolve-symbol.test.ts',
@ -179,6 +180,7 @@ export default defineConfig({
'test/integration/analyze-wal-checkpoint-failure.test.ts',
'test/integration/lbug-non-ascii-path.test.ts',
'test/integration/lbug-conn-serialization.test.ts',
'test/integration/load-cached-embeddings-spill.test.ts',
'test/integration/group/manifest-resolve-symbol-2325.test.ts',
'test/integration/group/manifest-synthetic-impact-lbug.test.ts',
'test/integration/group/http-route-resolve-symbol.test.ts',

362
package-lock.json generated
View file

@ -15,7 +15,8 @@
"husky": "^9.1.7",
"lint-staged": "^15.5.0",
"prettier": "^3.8.0",
"prettier-plugin-tailwindcss": "^0.7.0"
"prettier-plugin-tailwindcss": "^0.7.0",
"typescript": "^5.9.3"
}
},
"node_modules/@babel/code-frame": {
@ -626,6 +627,189 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
"integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2",
"@typescript-eslint/utils": "8.57.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
"integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.57.2",
"@typescript-eslint/tsconfig-utils": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
"integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.57.2",
"@typescript-eslint/types": "^8.57.2",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
"integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
"integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
"integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.57.2",
"@typescript-eslint/tsconfig-utils": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
"integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.57.2",
"@typescript-eslint/types": "^8.57.2",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
"integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz",
@ -651,7 +835,35 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
"integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.57.2",
"@typescript-eslint/tsconfig-utils": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
"integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
@ -673,6 +885,23 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
"integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz",
@ -691,48 +920,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
"integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
"integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2",
"@typescript-eslint/utils": "8.57.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
@ -747,58 +934,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
"integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.57.2",
"@typescript-eslint/tsconfig-utils": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
"integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz",
@ -943,9 +1078,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2154,13 +2289,13 @@
}
},
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
@ -2520,9 +2655,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
@ -2669,14 +2804,14 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@ -2704,9 +2839,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@ -2761,7 +2896,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View file

@ -20,7 +20,8 @@
"husky": "^9.1.7",
"lint-staged": "^15.5.0",
"prettier": "^3.8.0",
"prettier-plugin-tailwindcss": "^0.7.0"
"prettier-plugin-tailwindcss": "^0.7.0",
"typescript": "^5.9.3"
},
"lint-staged": {
"*.{ts,tsx}": [