Merge branch 'main' into main

This commit is contained in:
Иван 2026-10-06 15:56:55 +03:00 • committed by GitHub
commit 020d480633
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
268 changed files with 12779 additions and 2207 deletions

View file

@ -53,6 +53,9 @@ jobs:
lifecycle-scripts: 'false'
- run: npx tsc --noEmit
working-directory: gitnexus
- name: Typecheck tests
run: npm run typecheck:tests
working-directory: gitnexus
typecheck-web:
runs-on: ubuntu-latest

View file

@ -287,6 +287,7 @@ jobs:
# ── Locate test results ──
RESULTS_FILE=$(find_first "$DIR/test-reports" "test-results.json")
WEB_RESULTS_FILE=$(find_first "$DIR/test-reports" "web-test-results.json")
PYTEST_RESULTS_FILE=$(find_first "$DIR/test-reports" "pytest-results.json")
sum_results() {
local file=$1
@ -303,12 +304,21 @@ jobs:
# framework, not as a top-line metric).
read -r CLI_T CLI_P CLI_F CLI_S _ CLI_D <<< "$(sum_results "$RESULTS_FILE")"
read -r WEB_T WEB_P WEB_F WEB_S _ WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")"
read -r PY_T PY_P PY_F PY_S _ PY_D <<< "$(sum_results "$PYTEST_RESULTS_FILE")"
TOTAL=$((CLI_T + WEB_T))
PASSED=$((CLI_P + WEB_P))
FAILED=$((CLI_F + WEB_F))
SKIPPED=$((CLI_S + WEB_S))
TOTAL=$((CLI_T + WEB_T + PY_T))
PASSED=$((CLI_P + WEB_P + PY_P))
FAILED=$((CLI_F + WEB_F + PY_F))
SKIPPED=$((CLI_S + WEB_S + PY_S))
DURATION=$((CLI_D > WEB_D ? CLI_D : WEB_D))
DURATION=$((DURATION > PY_D ? DURATION : PY_D))
EXECUTION_ERRORS=0
for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE" "$PYTEST_RESULTS_FILE"; do
if [ -n "$rf" ] && [ -f "$rf" ]; then
errors=$(jq -r '(.executionFailures // []) | length' "$rf")
EXECUTION_ERRORS=$((EXECUTION_ERRORS + errors))
fi
done
# ── Status helpers ──
status_icon() {
@ -385,22 +395,22 @@ jobs:
if [ "$TOTAL" -gt 0 ] 2>/dev/null; then
echo "### Test Results"
echo ""
echo "| Tests | Passed | Failed | Skipped | Duration |"
echo "| Tests | Passed | Failed | Unverified | Duration |"
echo "|-------|--------|--------|---------|----------|"
echo "| ${TOTAL} | ${PASSED} | ${FAILED} | ${SKIPPED} | ${DURATION}s |"
echo ""
if [ "$FAILED" = "0" ]; then
if [[ "$FAILED" == "0" && "$SKIPPED" == "0" && "$EXECUTION_ERRORS" == "0" && "$TESTS" == "success" ]]; then
echo "✅ All **${PASSED}** tests passed"
else
echo "❌ **${FAILED}** failed / **${PASSED}** passed"
echo "❌ Test execution is incomplete or failed: **${FAILED}** failed, **${SKIPPED}** unverified, **${EXECUTION_ERRORS}** runner/suite errors."
fi
if [ "$SKIPPED" != "0" ]; then
echo ""
echo "<details>"
echo "<summary>${SKIPPED} test(s) skipped — expand for details</summary>"
echo "<summary>${SKIPPED} test(s) have no recorded pass — expand for details</summary>"
echo ""
for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE"; do
for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE" "$PYTEST_RESULTS_FILE"; do
if [ -n "$rf" ] && [ -f "$rf" ]; then
jq -r '
.testResults[]

View file

@ -32,6 +32,7 @@ jobs:
env:
GITNEXUS_REQUIRE_FTS: '1'
GITNEXUS_REQUIRE_ZIG: '1'
GITNEXUS_REQUIRE_VECTOR: '1'
steps:
# persist-credentials: false — runs tests + uploads a blob artifact; the
# default-persisted token must not be capturable through it (zizmor
@ -79,8 +80,8 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-blob-${{ matrix.shard }}
path: gitnexus/.vitest-reports/
# .vitest-reports is a dotdir; upload-artifact excludes hidden files by
path: gitnexus/.vitest/blob/
# .vitest is a dotdir; upload-artifact excludes hidden files by
# default, which would upload an empty artifact and break the merge.
include-hidden-files: true
retention-days: 5
@ -109,19 +110,19 @@ jobs:
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: coverage-blob-*
path: gitnexus/.vitest-reports
path: gitnexus/.vitest/blob
merge-multiple: true
- name: Merge coverage + enforce thresholds
run: >-
npx vitest --mergeReports
npx vitest --mergeReports=.vitest/blob
--reporter=default
--reporter=json
--reporter=./scripts/execution-reporter.ts
--outputFile=test-results.json
--coverage
--coverage.reporter=json-summary
--coverage.reporter=json
--coverage.reporter=text
--coverage.thresholdAutoUpdate=false
--coverage.thresholds.autoUpdate=false
working-directory: gitnexus
# gitnexus-shared already built by setup-gitnexus above
- name: Install gitnexus-web dependencies
@ -131,7 +132,8 @@ jobs:
run: >-
npx vitest run
--reporter=default
--reporter=json
--reporter=../gitnexus/scripts/execution-reporter.ts
--includeTaskLocation
--outputFile=web-test-results.json
working-directory: gitnexus-web
- name: Run docker-server integration tests
@ -140,7 +142,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-reports
name: coverage-reports
path: |
gitnexus/coverage/coverage-summary.json
gitnexus/coverage/coverage-final.json
@ -162,7 +164,7 @@ jobs:
steps:
- id: gen
run: |
TOTAL=3 # cross-platform (windows/macOS) shards per OS
TOTAL=4 # cross-platform (windows/macOS) shards per OS
COV_TOTAL=3 # ubuntu coverage shards (merged before thresholds)
if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then
echo "shard totals must be >= 1" >&2; exit 1
@ -254,8 +256,48 @@ jobs:
shell: bash
env:
SHARD: ${{ matrix.shard }}/${{ needs.shard-plan.outputs.total }}
GITNEXUS_TEST_REPORT: ${{ matrix.os }}-${{ matrix.shard }}.json
run: npx tsx scripts/run-cross-platform.ts --shard="$SHARD"
working-directory: gitnexus
- name: Upload platform execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-${{ matrix.os }}-${{ matrix.shard }}
path: gitnexus/${{ matrix.os }}-${{ matrix.shard }}.json
if-no-files-found: error
retention-days: 5
# Branch protection still requires these six names from the three-shard
# matrix. Keep them as aggregate gates as the real matrix grows: every native
# shard and the complete execution audit must pass before any gate succeeds.
# These jobs only verify dependency results; tests run in cross-platform above.
protected-platform-checks:
name: ${{ matrix.os }} (platform-sensitive) ${{ matrix.check }}/3
needs: [cross-platform, test-completeness]
if: always()
permissions: {}
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest]
check: [1, 2, 3]
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Require all native shards and the execution audit
shell: bash
env:
NATIVE_RESULT: ${{ needs.cross-platform.result }}
EXECUTION_RESULT: ${{ needs.test-completeness.result }}
run: |
echo "Compatibility gate for the former three-shard check name."
echo "All native shards: $NATIVE_RESULT"
echo "Complete execution audit: $EXECUTION_RESULT"
if [[ "$NATIVE_RESULT" != "success" || "$EXECUTION_RESULT" != "success" ]]; then
echo "::error::Every native shard and the execution audit must succeed."
exit 1
fi
# Tree-sitter ABI gate (#1922). Two halves, both blocking:
# 1. Static, offline: assert every grammar's compiled ABI loads on the
@ -482,11 +524,8 @@ jobs:
# heap, so parallel forks both skew the timings and OOM the worker pool — they
# must run one file at a time.
#
# go-pipeline-benchmark.test.ts is deliberately NOT included: its
# worker-pool (#1848) suite spins a real worker pool that exits unexpectedly
# under vitest's fork pool (reproduced in validation), which would make this
# gate flaky. Go is already guarded by its non-gated O(n^2) tripwire (runs in
# the main coverage job) plus its golden capture-parity test.
# Every opt-in suite is discovered by run-benchmarks.ts. A failing worker
# benchmark is a failure to investigate, never a reason to omit that suite.
benchmarks:
name: benchmarks (GITNEXUS_BENCH)
runs-on: ubuntu-latest
@ -876,28 +915,18 @@ jobs:
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
if: ${{ !cancelled() }}
# cpp-adl-benchmark.test.ts and csharp-razor-view-components-benchmark.test.ts
# are not `*-pipeline-benchmark.test.ts` files but belong here for the
# same reason: they are skipIf-gated on GITNEXUS_BENCH, so the scaling
# guards they hold never run in the main coverage job.
env:
GITNEXUS_BENCH: '1'
GITNEXUS_WORKER_READY_TIMEOUT_MS: '60000'
run: >-
npx vitest run --no-file-parallelism
test/integration/cobol-pipeline-benchmark.test.ts
test/integration/csharp-pipeline-benchmark.test.ts
test/integration/csharp-razor-view-components-benchmark.test.ts
test/integration/objective-c-pipeline-benchmark.test.ts
test/integration/cpp-adl-benchmark.test.ts
test/integration/data-route-table-benchmark.test.ts
test/integration/instance-ownership-pipeline-benchmark.test.ts
test/integration/spring-bean-resource-benchmark.test.ts
test/integration/spring-dynamic-lookup-benchmark.test.ts
test/integration/rust-pipeline-benchmark.test.ts
test/integration/php-pipeline-benchmark.test.ts
test/integration/ruby-pipeline-benchmark.test.ts
run: npm run test:benchmarks
working-directory: gitnexus
- name: Upload benchmark execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-benchmarks
path: gitnexus/benchmarks.json
if-no-files-found: error
retention-days: 5
# Locked eval suite. setup-uv and uv itself are immutable so CI exercises
# exactly the dependency graph developers run from eval/uv.lock.
@ -916,8 +945,88 @@ jobs:
python-version: '3.13'
enable-cache: true
cache-dependency-glob: eval/uv.lock
- run: uv run --locked --extra dev python -m pytest tests -q
- run: uv run --locked --extra dev python -m pytest tests -q --junitxml=pytest-locked.xml
working-directory: eval
- name: Upload pytest execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-pytest-locked
path: eval/pytest-locked.xml
if-no-files-found: error
retention-days: 5
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Exercise real workflow evidence preflight
if: ${{ !cancelled() }}
run: >-
npx vitest run test/unit/skill-evolution-workflow.test.ts
--reporter=default --reporter=./scripts/execution-reporter.ts --outputFile=preflight.json
working-directory: gitnexus
- name: Upload preflight execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-preflight
path: gitnexus/preflight.json
if-no-files-found: error
retention-days: 5
test-completeness:
name: every test executed
needs:
[
shard-plan,
coverage-merge,
cross-platform,
benchmarks,
eval-tests,
eval-containment-linux,
eval-containment-windows,
]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
- name: Download coverage and web reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: coverage-reports
- name: Download all execution receipts
if: ${{ !cancelled() }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: execution-*
path: execution-reports
merge-multiple: true
- name: Require a real pass for every collected test
env:
PLATFORM_SHARDS: ${{ needs.shard-plan.outputs.total }}
run: |
cp gitnexus/test-results.json execution-reports/coverage.json
cd gitnexus
node --import tsx scripts/test-completeness.ts ../execution-reports test-results.json "$PLATFORM_SHARDS"
- name: Require a real pass for every pytest case
if: ${{ !cancelled() }}
run: python3 eval/check_test_execution.py execution-reports eval/pytest-results.json
- name: Upload complete test reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-reports
path: |
gitnexus/coverage/coverage-summary.json
gitnexus/coverage/coverage-final.json
gitnexus/test-results.json
gitnexus-web/web-test-results.json
eval/pytest-results.json
if-no-files-found: error
retention-days: 5
# Native Linux ownership and Bubblewrap boundary. The environment flag makes
# the real namespace test mandatory; a missing/blocked bwrap is a failure.
@ -992,8 +1101,19 @@ jobs:
tests/test_workflow_bench_sessions.py
tests/test_ce_plugin_runtime.py
tests/test_offline_sweep_integration.py
tests/test_mock_provider.py -q
tests/test_mock_provider.py
tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant
tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout
-q --junitxml=pytest-ubuntu.xml
working-directory: eval
- name: Upload pytest execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-pytest-ubuntu
path: eval/pytest-ubuntu.xml
if-no-files-found: error
retention-days: 5
# Native Windows Job Object canary. POSIX-only tests skip by platform, while
# the grandchild delayed-write test must execute and pass on this runner.
@ -1015,5 +1135,13 @@ jobs:
run: >-
uv run --locked --extra dev python -m pytest
tests/test_process_control.py tests/test_model_gateway.py
-k "not locked_litellm" -q
-k "not locked_litellm" -q --junitxml=pytest-windows.xml
working-directory: eval
- name: Upload pytest execution report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: execution-pytest-windows
path: eval/pytest-windows.xml
if-no-files-found: error
retention-days: 5

9
.gitignore vendored
View file

@ -31,6 +31,7 @@ npm-debug.log*
# Testing
coverage/
.vitest/
.tmp-test/
gitnexus/.tmp-test/
@ -131,5 +132,13 @@ local_docs/
.context/
gitnexus/web/
# Local copies of CI execution receipts.
gitnexus/benchmarks.json
gitnexus/preflight.json
gitnexus/test-results.json
gitnexus/windows-latest-*.json
gitnexus/macos-latest-*.json
gitnexus-web/web-test-results.json
# Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py)
eval/workflow_bench/learnings.jsonl

View file

@ -611,6 +611,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| Variable | Default | Effect | Tune when… |
| ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_TEST_REPORT` | unset | Write a JSON execution receipt from `scripts/run-cross-platform.ts`; accepts a basename ending in `.json`. | CI platform shards use distinct filenames so the completeness check can require every report. |
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |

View file

@ -19,6 +19,7 @@ From `gitnexus/`:
| Command | What it runs | When to use |
| ----------------------------- | -------------------------------------------------- | ------------------------------------- |
| `npm test` | Full suite (all 3 vitest projects) | Before opening a PR |
| `npm run typecheck:tests` | TypeScript checks for source, tests, and helpers | Before opening a PR |
| `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop |
| `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers |
| `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact |
@ -26,6 +27,11 @@ From `gitnexus/`:
| `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue |
| `npm run test:watch` | Vitest in watch mode | Active development |
Vitest transpiles TypeScript without checking types. Run `npm run typecheck:tests`
in `gitnexus/` to check test code and helpers against the production types. This
uses `tsc --noEmit -p tsconfig.test.json`; parser input files under `test/fixtures/`
are excluded because they are sample source code, not part of the test program.
### `gitnexus-web/` commands
From `gitnexus-web/`:
@ -41,7 +47,7 @@ From `gitnexus-web/`:
```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 && npx tsc --noEmit && npm run typecheck:tests && npm test
cd ../gitnexus-web && npx tsc -b --noEmit && npm test
```
@ -119,7 +125,45 @@ GitHub Actions (`.github/workflows/ci.yml`) orchestrate:
| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages |
| `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes |
The `CI Gate` job in `ci.yml` is the single required check for branch protection. It requires quality, tests, e2e, and scope-parity to all pass.
The `CI Gate` job in `ci.yml` requires the quality and test workflows to pass.
The browser E2E workflow must pass or be skipped because no web files changed.
Branch protection also requires six platform check names from the former
three-shard matrix. These names remain as aggregate gates: all native shards
and the `every test executed` audit must succeed before any of them passes.
Failed, cancelled, skipped, or missing dependency results fail these gates.
The actual native tests run in the current Windows/macOS shard matrix.
The `typecheck` job runs both the production compiler check and
`npm run typecheck:tests`. A type error in either check fails the job and the CI gate.
### Complete execution, including platform and benchmark tests
The required `every test executed` job reconciles execution receipts from Ubuntu
coverage, every Windows/macOS shard, the serial benchmark run, and the real Python
workflow preflight. It requires a recorded pass for every collected test. A skip
on Linux is satisfied only by a pass of that exact test in another required job.
Test identities include the file, suite/title and source location; ambiguous
parameterized cases must have unique titles. Missing receipts, missing test files,
unhandled runner errors, failed hooks, failed assertions, and tests with no pass
all fail the gate. Web tests are checked separately with the same rules.
The locked pytest suite and Linux/Windows containment jobs also upload JUnit
receipts. The same gate checks every Python test file was collected and every
case passed in at least one job, while preserving failures from any job. The
Linux containment job supplies Bubblewrap, the pinned CLI, and built Vitest
dependencies for tests that cannot run in the basic Python job. Python results
are included in the combined PR report.
The PR report shows the reconciled result as **Unverified**. Zero means every test
has execution evidence; individual OS logs still show tests that require another
OS as skipped. Failed executions remain failures even if another job passes.
`npm run test:benchmarks` discovers all tests gated by `GITNEXUS_BENCH` and runs
them serially. Keep timing measurements out of parallel coverage workers. The
`eval-tests` job installs the locked Python dependencies and runs the workflow
preflight Vitest tests as well as pytest; those tests must exercise real Python
validation, never a stubbed success.
## Regression testing

View file

@ -331,8 +331,8 @@ const respondOk = (_req, res) => {
//
// upstream request handler, replaceable mid-test via `ctx.handler`;
// null points the proxy at a port nothing ever listens on
// listenAfterMs bind the upstream this late, so the first attempt(s) hit
// ECONNREFUSED (a single-instance restart window)
// listenAfterMs bind the upstream this late after the first refused attempt
// (a single-instance restart window)
// schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's
// `fromService: { property: hostport }` yields it
// env extra environment for docker-server.mjs
@ -366,18 +366,15 @@ async function withProxy(
})
: null;
// A late (or never) bind needs its port reserved up front; otherwise let the
// OS assign one at listen time.
const upstreamPort =
server && listenAfterMs === 0
? await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port)))
: await getFreePort();
const bindTimer =
server && listenAfterMs > 0
? setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs)
: null;
// Keep the upstream port bound until the proxy port is chosen. Releasing it
// sooner lets the OS assign both services the same port and proxy to itself.
const reservation = server ?? createServer();
const upstreamPort = await new Promise((r) =>
reservation.listen(0, '127.0.0.1', () => r(reservation.address().port)),
);
const port = await getFreePort();
let bindTimer = null;
const target = `127.0.0.1:${upstreamPort}`;
const proc = spawnServerWithEnv(dir, port, {
GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`,
@ -390,18 +387,25 @@ async function withProxy(
...env,
});
proc.stderr.setEncoding('utf8');
proc.stderr.on('data', (chunk) => {
const collectStderr = (chunk) => {
ctx.stderr += chunk;
});
// Process startup must not consume the restart window or skip the retry.
if (server && listenAfterMs > 0 && !bindTimer && ctx.stderr.includes('ECONNREFUSED; retry')) {
bindTimer = setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs);
}
};
proc.stderr.on('data', collectStderr);
try {
await waitForServer(port);
if (!server || listenAfterMs > 0) await new Promise((r) => reservation.close(r));
await fn(port, ctx);
} finally {
proc.stderr.off('data', collectStderr);
if (bindTimer) clearTimeout(bindTimer);
await killAndWait(proc);
if (server?.listening) {
server.closeAllConnections?.();
await new Promise((r) => server.close(r));
if (reservation.listening) {
reservation.closeAllConnections?.();
await new Promise((r) => reservation.close(r));
}
await rm(dir, { recursive: true, force: true });
}
@ -661,8 +665,8 @@ it('returns 502 when the upstream is unreachable', async () => {
// -- Connection-retry across an upstream restart window ---------------------
//
// `listenAfterMs: 400` binds the upstream late, so the first attempt hits
// ECONNREFUSED and must be retried — a single-instance restart. The default 3
// `listenAfterMs: 400` binds the upstream 400ms after the first ECONNREFUSED,
// so the request must be retried — a single-instance restart. The default 3
// attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind.
it('retries a connection-refused POST and succeeds once the upstream is up', async () => {
@ -676,6 +680,7 @@ it('retries a connection-refused POST and succeeds once the upstream is up', asy
assert.match(res.body, /"ok":true/);
assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)');
assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact');
assert.match(ctx.stderr, /ECONNREFUSED; retry/, 'the restart gap must exercise a retry');
});
});

View file

@ -0,0 +1,121 @@
"""Require a real passing execution of every pytest case across the CI jobs."""
from __future__ import annotations
import json
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
RECEIPTS = ("pytest-locked.xml", "pytest-ubuntu.xml", "pytest-windows.xml")
def reconcile(reports: list[Path], expected_files: list[str]) -> dict:
if not reports:
raise ValueError("No pytest execution reports supplied")
tests: dict[tuple[str, str], set[str]] = {}
durations: list[float] = []
for report in reports:
root = ET.parse(report).getroot()
suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite"))
if root.tag not in {"testsuite", "testsuites"} or not suites:
raise ValueError(f"Invalid pytest report: {report}")
seen: set[tuple[str, str]] = set()
duration = 0.0
for suite in suites:
cases = suite.findall("testcase")
if not cases:
raise ValueError(f"Empty pytest suite in {report}")
counts = {"tests": len(cases), "failures": 0, "errors": 0, "skipped": 0}
for case in cases:
key = (case.get("classname", ""), case.get("name", ""))
if not all(key) or key in seen:
raise ValueError(f"Missing or ambiguous pytest identity in {report}: {key}")
seen.add(key)
for status in ("failures", "errors", "skipped"):
tag = {"failures": "failure", "errors": "error", "skipped": "skipped"}[status]
counts[status] += len(case.findall(tag))
status = (
"failed"
if case.find("failure") is not None or case.find("error") is not None
else "pending"
if case.find("skipped") is not None
else "passed"
)
tests.setdefault(key, set()).add(status)
for name, count in counts.items():
if int(suite.get(name, "-1")) != count:
raise ValueError(f"Inconsistent pytest {name} count in {report}")
duration += float(suite.get("time", "0"))
durations.append(duration)
modules = {module for module, _ in tests}
for file in expected_files:
module = file.removesuffix(".py").replace("/", ".")
if not any(name == module or name.startswith(module + ".") for name in modules):
raise ValueError(f"Pytest file was never collected: {file}")
suites_by_module: dict[str, dict] = {}
failed: list[str] = []
pending = 0
for (module, name), statuses in sorted(tests.items()):
status = "failed" if "failed" in statuses else "passed" if "passed" in statuses else "pending"
full_name = f"{module}::{name}"
if status == "failed":
failed.append(full_name)
pending += status == "pending"
suite = suites_by_module.setdefault(
module,
{
"name": module,
"status": "passed",
"assertionResults": [],
"endTime": round(max(durations) * 1000),
},
)
suite["assertionResults"].append(
{
"fullName": full_name,
"ancestorTitles": [module],
"title": name,
"status": status,
}
)
if status == "failed":
suite["status"] = "failed"
return {
"success": not failed and pending == 0,
"numTotalTests": len(tests),
"numPassedTests": len(tests) - len(failed) - pending,
"numFailedTests": len(failed),
"numPendingTests": pending,
"numTotalTestSuites": len(suites_by_module),
"numFailedTestSuites": sum(s["status"] == "failed" for s in suites_by_module.values()),
"executionFailures": failed,
"startTime": 0,
"testResults": list(suites_by_module.values()),
}
def main() -> int:
if len(sys.argv) != 3:
raise ValueError("Usage: check_test_execution.py <reports-dir> <output.json>")
directory, output = map(Path, sys.argv[1:])
root = Path(__file__).resolve().parent
expected = [p.relative_to(root).as_posix() for p in (root / "tests").rglob("test_*.py")]
report = reconcile([directory / name for name in RECEIPTS], expected)
output.write_text(json.dumps(report), encoding="utf-8")
print(
f"Eval execution: {report['numPassedTests']}/{report['numTotalTests']} passed; "
f"{report['numPendingTests']} unverified; {report['numFailedTests']} failures"
)
for suite in report["testResults"]:
for case in suite["assertionResults"]:
if case["status"] != "passed":
print(f"{case['status']}: {case['fullName']}", file=sys.stderr)
return 0 if report["success"] else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,142 @@
"""The execution gate must reject absent, skipped, failed, or ambiguous evidence."""
from pathlib import Path
import json
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
import pytest
from check_test_execution import reconcile
def receipt(tmp_path: Path, filename: str, cases: list[tuple[str, str]], module="tests.test_native") -> Path:
suite = ET.Element("testsuite", tests=str(len(cases)), failures="0", errors="0", skipped="0", time="1.5")
for name, status in cases:
case = ET.SubElement(suite, "testcase", classname=module, name=name)
if status != "passed":
ET.SubElement(case, status)
count = {"failure": "failures", "error": "errors", "skipped": "skipped"}[status]
suite.set(count, str(int(suite.get(count)) + 1))
path = tmp_path / filename
ET.ElementTree(suite).write(path, encoding="utf-8")
return path
def test_platform_skip_requires_a_matching_real_pass(tmp_path):
linux = receipt(tmp_path, "linux.xml", [("test_windows", "skipped"), ("test_posix", "passed")])
windows = receipt(tmp_path, "windows.xml", [("test_windows", "passed"), ("test_posix", "skipped")])
report = reconcile([linux, windows], ["tests/test_native.py"])
assert report["success"] is True
assert report["numPassedTests"] == report["numTotalTests"] == 2
assert report["numPendingTests"] == report["numFailedTests"] == 0
def test_unresolved_skip_is_not_a_pass(tmp_path):
path = receipt(tmp_path, "report.xml", [("test_missing_runtime", "skipped")])
report = reconcile([path], [])
assert report["success"] is False
assert report["numPendingTests"] == 1
assert report["numPassedTests"] == 0
assert report["testResults"][0]["assertionResults"][0]["ancestorTitles"] == ["tests.test_native"]
@pytest.mark.parametrize("status", ["failure", "error"])
def test_failure_is_not_hidden_by_another_job_passing(tmp_path, status):
failed = receipt(tmp_path, "failed.xml", [("test_shared", status)])
passed = receipt(tmp_path, "passed.xml", [("test_shared", "passed")])
report = reconcile([failed, passed], [])
assert report["success"] is False
assert report["numFailedTests"] == 1
assert report["numPassedTests"] == 0
assert report["executionFailures"] == ["tests.test_native::test_shared"]
def test_same_title_in_another_file_does_not_resolve_a_skip(tmp_path):
pending = receipt(tmp_path, "pending.xml", [("test_same", "skipped")])
unrelated = receipt(tmp_path, "unrelated.xml", [("test_same", "passed")], module="tests.test_other")
report = reconcile([pending, unrelated], [])
assert report["numTotalTests"] == 2
assert report["numPendingTests"] == 1
def test_duplicate_identity_is_rejected(tmp_path):
path = receipt(tmp_path, "duplicate.xml", [("test_same", "passed"), ("test_same", "skipped")])
with pytest.raises(ValueError, match="ambiguous pytest identity"):
reconcile([path], [])
def test_missing_receipt_is_rejected(tmp_path):
with pytest.raises(FileNotFoundError):
reconcile([tmp_path / "missing.xml"], [])
def test_empty_receipt_is_rejected(tmp_path):
path = receipt(tmp_path, "empty.xml", [])
with pytest.raises(ValueError, match="Empty pytest suite"):
reconcile([path], [])
def test_missing_file_is_rejected(tmp_path):
path = receipt(tmp_path, "report.xml", [("test_present", "passed")])
with pytest.raises(ValueError, match="never collected: tests/test_missing.py"):
reconcile([path], ["tests/test_missing.py"])
def test_inconsistent_counts_are_rejected(tmp_path):
path = receipt(tmp_path, "report.xml", [("test_present", "passed")])
path.write_text(path.read_text().replace('tests="1"', 'tests="2"'))
with pytest.raises(ValueError, match="Inconsistent pytest tests count"):
reconcile([path], [])
def test_pytest_testsuites_wrapper_and_test_classes(tmp_path):
path = receipt(tmp_path, "report.xml", [("test_method", "passed")], module="tests.test_native.TestNative")
suite = ET.parse(path).getroot()
root = ET.Element("testsuites")
root.append(suite)
ET.ElementTree(root).write(path, encoding="utf-8")
assert reconcile([path], ["tests/test_native.py"])["success"] is True
def test_missing_report_list_is_rejected():
with pytest.raises(ValueError, match="No pytest execution reports"):
reconcile([], [])
@pytest.mark.parametrize("scenario", ["passed", "pending", "failed", "missing-receipt", "missing-file"])
def test_cli_requires_complete_execution_evidence(tmp_path, scenario):
script = tmp_path / "check_test_execution.py"
shutil.copyfile(Path(__file__).parents[1] / script.name, script)
tests = tmp_path / "tests"
tests.mkdir()
(tests / "test_native.py").write_text("# inventory fixture\n")
reports = tmp_path / "reports"
reports.mkdir()
receipt(reports, "pytest-locked.xml", [("test_native", "skipped" if scenario == "pending" else "passed")])
receipt(reports, "pytest-ubuntu.xml", [("test_native", "error" if scenario == "failed" else "skipped")])
if scenario != "missing-receipt":
receipt(reports, "pytest-windows.xml", [("test_native", "skipped")])
if scenario == "missing-file":
(tests / "test_missing.py").write_text("# must be collected\n")
output = tmp_path / "pytest-results.json"
result = subprocess.run(
[sys.executable, str(script), str(reports), str(output)],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == (0 if scenario == "passed" else 1)
if scenario.startswith("missing-"):
assert not output.exists()
assert ("pytest-windows.xml" if scenario == "missing-receipt" else "never collected") in result.stderr
else:
report = json.loads(output.read_text())
assert report["success"] is (scenario == "passed")
assert report["numTotalTests"] == 1
assert report["numPassedTests"] == (scenario == "passed")
assert report["numPendingTests"] == (scenario == "pending")
assert report["numFailedTests"] == (scenario == "failed")
assert "Eval execution:" in result.stdout

View file

@ -5,6 +5,7 @@ import json
import os
import subprocess
import sys
import threading
import time
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
@ -1428,21 +1429,32 @@ def test_outer_runner_pid_namespace_kills_setsid_descendant(tmp_path):
pytest.skip(str(exc))
raise AssertionError("pytest.skip() returned unexpectedly")
sentinel = tmp_path / "escaped"
ready = tmp_path / "descendant-ready"
cancel = threading.Event()
descendant = (
"import time,pathlib; "
f"pathlib.Path({str(ready)!r}).touch(); print('ready', flush=True); "
f"time.sleep(1); pathlib.Path({str(sentinel)!r}).touch()"
)
child = (
"import os,subprocess,sys,time; "
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"],preexec_fn=os.setsid); "
f"subprocess.Popen([sys.executable,'-c',{descendant!r}],preexec_fn=os.setsid); "
"time.sleep(10)"
)
result = run_managed(
pid_namespace_command([sys.executable, "-c", child], bwrap_bin=bwrap),
timeout=0.15,
# Cancel only after the escaped-session descendant has actually started.
# A timeout during bwrap/Python startup must not count as containment.
timeout=10,
terminate_grace=0.1,
require_pid_namespace=True,
cancel_event=cancel,
stdout_observer=lambda _chunk: cancel.set(),
)
time.sleep(1.1)
assert ready.exists(), "the setsid descendant must start before it can be contained"
assert not result.ok
assert result.state in {"timeout", "forced-kill"}
assert result.state == "cancelled"
time.sleep(1.1)
assert not sentinel.exists()

View file

@ -250,7 +250,11 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
# Carries the real-CLI identity probe, which needs CLAUDE_CANARY_BIN -
# set only on this job. Omitted from this list it skipped everywhere.
"tests/test_mock_provider.py",
# These also need runtime dependencies absent from the locked pytest job.
"tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant",
"tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout",
"-q",
"--junitxml=pytest-ubuntu.xml",
]
bwrap_canary_marker = re.compile(
r'@pytest\.mark\.skipif\(\s*os\.environ\.get\("GITNEXUS_REQUIRE_BWRAP_CANARY"\)',
@ -266,6 +270,72 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
assert "eval-containment-windows:" in workflow
@pytest.fixture(scope="module")
def ci_test_jobs():
repo_root = Path(__file__).resolve().parents[2]
workflow = yaml.safe_load((repo_root / ".github" / "workflows" / "ci-tests.yml").read_text())
return workflow["jobs"]
def test_platform_ci_preserves_required_check_names_with_complete_execution_gates(ci_test_jobs):
jobs = ci_test_jobs
gate = jobs["protected-platform-checks"]
matrix = gate["strategy"]["matrix"]
names = {
"tests / " + gate["name"].replace("${{ matrix.os }}", platform).replace("${{ matrix.check }}", str(check))
for platform in matrix["os"]
for check in matrix["check"]
}
assert names == {
f"tests / {platform} (platform-sensitive) {check}/3"
for platform in ("windows-latest", "macos-latest")
for check in (1, 2, 3)
}
plan = jobs["shard-plan"]["steps"][0]["run"]
total = int(re.search(r"^TOTAL=(\d+)\b", plan, re.MULTILINE)[1])
native_names = {
"tests / "
+ jobs["cross-platform"]["name"]
.replace("${{ matrix.os }}", platform)
.replace("${{ matrix.shard }}", str(shard))
.replace("${{ needs.shard-plan.outputs.total }}", str(total))
for platform in jobs["cross-platform"]["strategy"]["matrix"]["os"]
for shard in range(1, total + 1)
}
assert names.isdisjoint(native_names)
assert set(gate["needs"]) == {"cross-platform", "test-completeness"}
assert gate["if"] == "always()"
assert not gate.get("continue-on-error", False)
assert not jobs["cross-platform"].get("continue-on-error", False)
assert not jobs["test-completeness"].get("continue-on-error", False)
assert gate["permissions"] == {}
assert len(gate["steps"]) == 1
step = gate["steps"][0]
assert not step.get("continue-on-error", False)
assert "if" not in step
assert step["shell"] == "bash"
assert step["env"] == {
"NATIVE_RESULT": "${{ needs.cross-platform.result }}",
"EXECUTION_RESULT": "${{ needs.test-completeness.result }}",
}
@pytest.mark.parametrize("native_result", ["success", "failure", "cancelled", "skipped", ""])
@pytest.mark.parametrize("execution_result", ["success", "failure", "cancelled", "skipped", ""])
def test_required_platform_check_fails_unless_every_dependency_passed(ci_test_jobs, native_result, execution_result):
step = ci_test_jobs["protected-platform-checks"]["steps"][0]
result = subprocess.run(
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
env={**os.environ, "NATIVE_RESULT": native_result, "EXECUTION_RESULT": execution_result},
capture_output=True,
text=True,
timeout=10,
check=False,
)
expected = 0 if native_result == execution_result == "success" else 1
assert result.returncode == expected, result.stdout + result.stderr
def test_shipped_scenarios_opt_out_the_cross_module_cell_and_rebuild_graph_assets():
task_file = Path(__file__).resolve().parents[1] / "workflow_bench" / "tasks.scenarios.yaml"
tasks = yaml.safe_load(task_file.read_text())["tasks"]
@ -1092,4 +1162,3 @@ def test_an_uninvoked_skill_still_counts_toward_the_arm_median():
# The invariant that makes the half-fix unsafe: the median and the run count
# the gate reads must cover the same rows.
assert agg["valid_runs"] == 2

275
eval/uv.lock generated
View file

@ -678,7 +678,7 @@ wheels = [
[[package]]
name = "datasets"
version = "4.8.3"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dill" },
@ -696,9 +696,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "xxhash" },
]
sdist = { url = "https://files.pythonhosted.org/packages/27/74/0c5f29109e02c4c33e8634e31d5cd7d90ecc3ed7c95e1b377ece08354068/datasets-4.8.3.tar.gz", hash = "sha256:882fb1bb514772bec17fbcad2e32985893954d0a0ecf42266e5091386be1f3b7", size = 604294, upload-time = "2026-03-19T17:43:59.32Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/5b/836516269d4f618efe621661cfb6f9acc57e6f95265db3efaee48a5ffe04/datasets-5.0.1.tar.gz", hash = "sha256:ce22bb851efd7494f08aad33b940803784434f6e77763d00679a0dc45fcf686a", size = 641498, upload-time = "2026-07-28T11:09:12.016Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl", hash = "sha256:a66cb506097bfce8461b076fb9e86ea216e00fd622387ba19909d9c1689ff8f9", size = 526834, upload-time = "2026-03-19T17:43:57.337Z" },
{ url = "https://files.pythonhosted.org/packages/44/0b/98fc6eb83333508ca5f44c52b3e287ea8137a0ad582714e2cbc67a02154b/datasets-5.0.1-py3-none-any.whl", hash = "sha256:9fbf73688f8c18f7529b4fe592abd04015f81d1e58001e4bac73ffb2b39d7cc4", size = 559079, upload-time = "2026-07-28T11:09:10.266Z" },
]
[[package]]
@ -953,11 +953,11 @@ wheels = [
[[package]]
name = "fsspec"
version = "2026.2.0"
version = "2026.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" }
sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
{ url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" },
]
[package.optional-dependencies]
@ -1776,119 +1776,160 @@ wheels = [
[[package]]
name = "multidict"
version = "6.7.1"
version = "6.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" },
{ url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" },
{ url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" },
{ url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" },
{ url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" },
{ url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" },
{ url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" },
{ url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" },
{ url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" },
{ url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" },
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" },
{ url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" },
{ url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" },
{ url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" },
{ url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" },
{ url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" },
{ url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" },
{ url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" },
{ url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" },
{ url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" },
{ url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" },
{ url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" },
{ url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" },
{ url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" },
{ url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" },
{ url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" },
{ url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" },
{ url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" },
{ url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" },
{ url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" },
{ url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" },
{ url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" },
{ url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" },
{ url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" },
{ url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" },
{ url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" },
{ url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" },
{ url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" },
{ url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" },
{ url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" },
{ url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" },
{ url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" },
{ url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" },
{ url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" },
{ url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" },
{ url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" },
{ url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" },
{ url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" },
{ url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" },
{ url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" },
{ url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" },
{ url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" },
{ url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" },
{ url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" },
{ url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" },
{ url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" },
{ url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" },
{ url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" },
{ url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" },
{ url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" },
{ url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" },
{ url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" },
{ url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" },
{ url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" },
{ url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" },
{ url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" },
{ url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" },
{ url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" },
{ url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" },
{ url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" },
{ url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" },
{ url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" },
{ url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" },
{ url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" },
{ url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" },
{ url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" },
{ url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" },
{ url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" },
{ url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" },
{ url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" },
{ url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" },
{ url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" },
{ url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" },
{ url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" },
{ url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" },
{ url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" },
{ url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" },
{ url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" },
{ url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" },
{ url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" },
{ url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" },
{ url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" },
{ url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" },
{ url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" },
{ url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" },
{ url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" },
{ url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" },
{ url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" },
{ url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" },
{ url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" },
{ url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" },
{ url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" },
{ url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" },
{ url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" },
{ url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" },
{ url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" },
{ url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" },
{ url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" },
{ url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" },
{ url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" },
{ url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" },
{ url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" },
{ url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" },
{ url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" },
{ url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" },
{ url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" },
{ url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" },
{ url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" },
{ url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" },
{ url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" },
{ url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" },
{ url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" },
{ url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" },
{ url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" },
{ url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" },
{ url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" },
{ url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" },
{ url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" },
{ url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" },
{ url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" },
{ url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" },
{ url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" },
{ url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" },
{ url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" },
{ url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" },
{ url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" },
]
[[package]]

View file

@ -5338,9 +5338,9 @@
}
},
"node_modules/joi": {
"version": "18.2.8",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz",
"integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==",
"version": "18.2.9",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz",
"integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {

View file

@ -36,6 +36,8 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`.
For Claude Code, Cursor, and Codex, setup keeps an existing URL-based `gitnexus` MCP entry and its authentication settings. Skills are still installed; setup installs Claude Code and Codex hooks, while Cursor hooks require [manual installation](../gitnexus-cursor-integration/README.md#hook-install).
### Editor Support
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
@ -398,7 +400,7 @@ An operation must name a protocol, either through its own `bindings` or through
Like Actuator snapshots, documents are external to git freshness — replacing one moves no commit and dirties no file — so an enabled run always rebuilds, and the first later run without the option rebuilds once to remove document-derived evidence. There is no glob-based auto-discovery, and the option is unsupported with `--watch`.
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
> **`gitnexus uninstall`** removes the named GitNexus MCP entries, hooks, and skill directories from detected editors. This includes a URL-based MCP entry you configured yourself, even if setup preserved it. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
## Remote Embeddings

View file

@ -1,5 +1,8 @@
# Incremental node identity reconciliation
For the native cause of #3421/#3423 and regressions covering both missing
context results and Unicode Property FTS, see [search-root-cause.md](search-root-cause.md).
From `gitnexus/`:
```sh

View file

@ -0,0 +1,251 @@
#!/usr/bin/env node
/** Native search contracts for #3421/#3423. See search-root-cause.md. */
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
// Diagnostic overrides allow the identical workload to test an older native engine.
const lbug = require(process.env.LBUG_MODULE || '@ladybugdb/core');
const scenario = process.argv[2];
assert.ok(
['context', 'property'].includes(scenario),
'Usage: reproduce-search.cjs context|property [--keep]',
);
(async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ladybug-search-'));
const checks = [];
let database;
let connection;
const record = async (name, fn) => {
try {
checks.push({ name, ok: true, ...(await fn()) });
} catch (error) {
checks.push({ name, ok: false, error: String(error) });
}
};
const close = async () => {
const activeConnection = connection;
const activeDatabase = database;
connection = undefined;
database = undefined;
const errors = [];
try {
await activeConnection?.close();
} catch (error) {
errors.push(error);
}
try {
await activeDatabase?.close();
} catch (error) {
errors.push(error);
}
if (errors.length) throw new AggregateError(errors, 'Native cleanup failed');
};
const query = async (sql, params) => {
const raw = params
? await connection.execute(await connection.prepare(sql), params)
: await connection.query(sql);
const rows = [];
const errors = [];
// FTS expands into multiple statements: consume and close every result,
// including when an earlier statement reports a deferred native error.
for (const result of Array.isArray(raw) ? raw : [raw]) {
try {
rows.push(...(await result.getAll()));
} catch (error) {
errors.push(error);
}
try {
await result.close();
} catch (error) {
errors.push(error);
}
}
if (errors.length) throw new AggregateError(errors, errors.map(String).join('; '));
return rows;
};
const table = scenario === 'context' ? 'Function' : 'Property';
const columns = ['id', 'name', 'filePath', 'startLine', 'endLine'];
if (scenario === 'property') columns.push('content', 'description');
const indices = Array.from({ length: 8192 }, (_, i) => i);
const changed = (i) => i < 32 || i >= 8192 - 32;
const tuple = (i) =>
scenario === 'context'
? [
`Function:src/owner${Math.floor(i / 32)}.ts:unchangedSwiftTarget${i}`,
`unchangedSwiftTarget${i}`, // Out-of-line name, unlike the short fn64 fixture.
`src/owner${Math.floor(i / 32)}.ts`,
(i % 32) * 4,
(i % 32) * 4 + 2,
]
: [
`Property:src/owner${Math.floor(i / 32)}.swift:property${i}`,
`unchangedPropertyValue${i}${'王'.repeat(i % 17)}`,
`src/owner${Math.floor(i / 32)}.swift`,
i,
i + 1,
`var unchangedPropertyValue${i} = "Valid UTF8 Å王 café 東京 ${i} ${'Résumé Ελληνικά Привет 🙂 '.repeat(i % 53)}"${i === 64 ? ' retainedsearchsentinel' : ''}`,
`Documentation for property ${i} ${'正常な日本語 naïve façade Å王 '.repeat(i % 37)}`,
];
const csv = (selected) =>
columns.join(',') +
'\n' +
selected
.map((i) =>
tuple(i)
.map((value) => '"' + String(value).replaceAll('"', '""') + '"')
.join(','),
)
.join('\n') +
'\n';
const literal = (value) => "'" + value.replace(/\\/g, '/').replaceAll("'", "\\'") + "'";
const copy = (file) =>
query(
`COPY ${table} FROM ${literal(path.join(directory, file))} ` +
`(HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`,
);
const extension =
process.env.FTS_LIBRARY ||
path.resolve(
__dirname,
'../../vendor/lbug-fts/prebuilds',
`${process.platform}-${process.arch}`,
'libfts.lbug_extension',
);
const open = async (readOnly) => {
database = new lbug.Database(
path.join(directory, 'db'),
256 * 1024 * 1024,
false,
readOnly,
4 * 1024 ** 3,
true,
64 * 1024 * 1024,
true,
true,
);
connection = new lbug.Connection(database);
if (scenario === 'property') await query(`LOAD ${literal(extension)}`);
};
const scan = async (stage, selected = indices) =>
record(`${stage}:scan`, async () => {
const rows = await query(
`MATCH (n:${table}) RETURN ${columns.map((c) => `n.${c} AS ${c}`).join(', ')}`,
);
const expected = new Set(selected.map((i) => JSON.stringify(tuple(i))));
const actual = rows.map((row) => JSON.stringify(columns.map((c) => row[c])));
const actualSet = new Set(actual);
const wrong = actual.filter((row) => !expected.has(row)).length;
const missing = [...expected].filter((row) => !actualSet.has(row)).length;
assert.deepEqual(
{ rows: rows.length, wrong, missing },
{ rows: selected.length, wrong: 0, missing: 0 },
);
return { rows: rows.length, wrong, missing };
});
const lookup = async (stage) => {
const target = tuple(64); // Retained throughout DELETE/COPY.
const select =
'n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine';
for (const [kind, sql, params] of [
['primary-key', `MATCH (n:Function {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }],
// Match LocalBackend.resolveContextSymbol's label-free UID/name queries.
['uid', `MATCH (n {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }],
[
'name',
`MATCH (n) WHERE n.name = $symName RETURN ${select} ORDER BY n.id LIMIT 20`,
{ symName: target[1] },
],
])
await record(`${stage}:${kind}`, async () => {
const rows = await query(sql, params);
assert.deepEqual(
rows.map((row) => columns.map((column) => row[column])),
[target],
);
return { ids: rows.map((row) => row.id) };
});
};
const build = () =>
query("CALL CREATE_FTS_INDEX('Property', 'property_fts', ['name', 'content', 'description'])");
const drop = () => query("CALL DROP_FTS_INDEX('Property', 'property_fts')");
const search = async (stage) => {
await record(`${stage}:catalog`, async () => {
const indexes = await query('CALL SHOW_INDEXES() RETURN *');
const matches = indexes.filter(
(row) => row.table_name === 'Property' && row.index_name === 'property_fts',
);
assert.equal(matches.length, 1);
assert.deepEqual(matches[0].property_names, ['name', 'content', 'description']);
return { indexes: matches.length };
});
await record(`${stage}:fts-query`, async () => {
const rows = await query(
"CALL QUERY_FTS_INDEX('Property', 'property_fts', 'retainedsearchsentinel') RETURN node, score ORDER BY score DESC, node.id LIMIT 20",
);
const ids = rows.map((row) => row.node.id);
assert.deepEqual(ids, [tuple(64)[0]]);
return { ids };
});
for (const column of ['name', 'content', 'description'])
await record(`${stage}:lower-${column}`, async () => {
const rows = await query(`MATCH (n:Property) RETURN LOWER(n.${column}) AS value`);
assert.equal(rows.length, indices.length);
return { rows: rows.length };
});
};
const check = async (stage) => {
await scan(stage);
if (scenario === 'context') await lookup(stage);
else await search(stage);
};
try {
await fs.writeFile(path.join(directory, 'full.csv'), csv(indices));
await fs.writeFile(path.join(directory, 'delta.csv'), csv(indices.filter(changed)));
await open(false);
const schema = columns.map((c) => `${c} ${c.endsWith('Line') ? 'INT64' : 'STRING'}`).join(', ');
await query(`CREATE NODE TABLE ${table}(${schema}, PRIMARY KEY(id))`);
// Multiple labels make the UID query use a filtered scan, as in GitNexus.
if (scenario === 'context') await query(`CREATE NODE TABLE Method(${schema}, PRIMARY KEY(id))`);
await copy('full.csv');
if (scenario === 'property') await record('baseline:build', build);
await query('CHECKPOINT');
await check('baseline');
if (scenario === 'property') await drop();
const suffix = scenario === 'context' ? 'ts' : 'swift';
await query(
`MATCH (n:${table}) WHERE n.filePath IN ['src/owner0.${suffix}', 'src/owner255.${suffix}'] DETACH DELETE n`,
);
await scan(
'after-delete',
indices.filter((i) => !changed(i)),
);
await copy('delta.csv');
// Build first: scanning strings beforehand can change the old engine's error manifestation.
if (scenario === 'property') await record('after-copy:build', build);
await check('after-copy');
if (scenario === 'property') {
await record('repair:drop', drop);
await record('repair:build', build);
await check('repair');
}
await query('CHECKPOINT');
await check('after-checkpoint');
await close();
await open(true);
await check('reopened');
} catch (error) {
checks.push({ name: 'setup-or-write', ok: false, error: String(error) });
} finally {
await record('close', close);
if (!process.argv.includes('--keep')) {
await record('remove-fixture', () => fs.rm(directory, { recursive: true, force: true }));
}
}
console.log(JSON.stringify({ scenario, native: lbug.VERSION, directory, checks }));
if (checks.some((check) => !check.ok)) process.exitCode = 1;
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,100 @@
# Incremental search failures: native cause and regression contracts
Issues [#3421](https://github.com/abhigyanpatwari/GitNexus/issues/3421) and
[#3423](https://github.com/abhigyanpatwari/GitNexus/issues/3423) reported two
symptoms after a small incremental write: valid source strings caused Property
FTS to fail in `LOWER`, and unchanged symbols stopped resolving by both name and
UID. A full rebuild restored service in both reports.
## Cause and effective fix
LadybugDB 0.18.3's `StringColumn::scanFiltered` compared a position in the output
vector with a segment-local length. When a scan crossed segments after selective
DELETE/COPY, it could skip valid string positions. The native scan could return
blank or stale string values while a labeled primary-key lookup still returned
the correct tuple. This establishes an incorrect read, not physical byte loss.
Upstream [LadybugDB #737](https://github.com/LadybugDB/ladybug/pull/737), commit
`a10cecbc76e05f993af6c6f4a57edbbf438bb376`, passes the current segment's scan length
into `scanFiltered` and bounds positions by
`offsetInResult <= pos < offsetInResult + numValuesToScan`. The local read offset
is `startOffsetInChunk + pos - offsetInResult`. The correction first shipped in
LadybugDB 0.19.0.
GitNexus already includes it through the 0.21.1 dependency in
[#3442](https://github.com/abhigyanpatwari/GitNexus/pull/3442), commit
`412446408d0e3f286b9e461fc451c25bf6283b3c`. Keep that native correction and the
existing publication reconciliation. Changing only the UID query to a labeled
lookup would hide one symptom while leaving name scans and FTS vulnerable.
## Controlled causal check
On Linux x64, a clean build of upstream tag `v0.18.3` reproduced both symptoms via
the native C API, without GitNexus, its parser, or the Node binding. Applying
**only the two production-file changes from #737** to that same checkout and
rebuilding removed both failures using the same CSV input and FTS extension
(`v0.18.1/linux_amd64`, the extension ABI used by core 0.18.3).
| Observation after selective COPY | Untouched 0.18.3 | 0.18.3 + #737 only |
| --- | --- | --- |
| Label-free retained UID lookup | 0 rows | Correct row |
| Label-free retained name lookup | 0 rows | Correct row |
| Labeled primary-key control | Correct row | Correct row |
| Function scan with blank ID/name | 1,984 rows | 0 rows |
| Property FTS creation | `LOWER: Invalid UTF-8` | Success |
| Property scan with blank ID/name | 1,984 rows | 0 rows |
| Property `LOWER(content)` | `Invalid UTF-8` | Success |
| Drop/recreate Property FTS | Missing index / orphan table | Success |
The Node fixtures also fail on published core 0.18.3 and pass on 0.21.1. Incorrect
tuple counts vary with the fixture and scan shape (1,984 or 3,936 observed).
The UTF-8 exception itself varies between runs: a failed string scan can return
blank values without throwing. Therefore the regression oracle compares every
source field and checks lookup results, rather than requiring an exception or
treating a successful FTS build as sufficient.
FTS creation expands into multiple statements in the extension's
`create_fts_index.cpp`: it creates internal tables, tokenizes property values
with `LOWER`, then registers the index. A failure during tokenization can leave
`0_property_fts_appears_info` without a catalog index. Dropping by index name then
fails, and creating again can encounter the orphan table. FTS-only repair cannot
correct the underlying string scan. The experiment reproduced this failure
chain, but not #3421's exact missing-index message without an appended native
error. Neither reporter's overwritten private database was available, so this
is a controlled reproduction of both reported symptoms, not a historical replay.
## Permanent regressions
From `gitnexus/`:
```sh
node bench/incremental-write-integrity/reproduce-search.cjs context
node bench/incremental-write-integrity/reproduce-search.cjs property
npx vitest run test/integration/lbug-incremental-search.test.ts
```
Each workload loads 8,192 rows, checkpoints, deletes the first and last owners
(64 rows), then copies back exactly those rows. Row 64 remains untouched.
- `context` uses out-of-line ASCII names and a second node table. Those details
matter: a single table can turn the UID query into a healthy primary-key scan,
and short names can stay readable while their IDs are blank. It runs the
label-free UID/name query shapes from `LocalBackend.resolveContextSymbol` plus
the labeled primary-key control.
- `property` uses valid, variable-length Unicode in name/content/description,
builds all three FTS columns, checks the catalog and a retained unique search
term, exercises `LOWER` on each column, and drops/recreates the index. It uses
the production FTS result shape (`RETURN node, score`).
Both compare the complete expected tuple set before/after the write, after an
explicit checkpoint, and after read-only reopen. The Property fixture also
checks the repair cycle. The Vitest wrapper runs each native workload in a child
process, requires every expected phase, and rejects nonzero exits and signals.
It belongs to the serialized `lbug-db` project and the Windows/macOS native test list.
The harness requires no network access. It defaults to the pinned core and
vendored platform FTS library. For a diagnostic comparison, set `LBUG_MODULE`
to an absolute path to another installed `@ladybugdb/core` and `FTS_LIBRARY`
to its matching native extension. Core 0.18.3 is expected to exit nonzero; do not
skip the test on that version. `--keep` retains only the synthetic fixture files;
otherwise cleanup errors are reported and fail the command.

File diff suppressed because it is too large Load diff

View file

@ -52,12 +52,14 @@
"serve": "tsx src/cli/index.ts serve",
"dev": "tsx watch src/cli/index.ts",
"test": "vitest run",
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
"test:unit": "vitest run test/unit",
"pretest:integration": "node scripts/build.js",
"test:integration": "vitest run test/integration",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:cross-platform": "tsx scripts/run-cross-platform.ts",
"test:benchmarks": "node --import tsx scripts/run-benchmarks.ts",
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
"prepare": "node scripts/build.js",
@ -126,11 +128,11 @@
"@types/express": "^5.0.6",
"@types/node": "^26.0.0",
"@types/proxy-addr": "^2.0.3",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/coverage-v8": "^5.0.0",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
"typescript": "^7.0.2",
"vitest": "^4.0.18"
"vitest": "^5.0.0"
},
"overrides": {
"adm-zip": ">=0.6.0",

View file

@ -167,6 +167,7 @@ const LBUG_NATIVE = [
'test/integration/lbug-close-handle-release.test.ts',
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
'test/integration/lbug-interrupted-checkpoint-recovery.test.ts',
'test/integration/lbug-incremental-search.test.ts',
'test/integration/lbug-readonly-init.test.ts',
'test/integration/lbug-non-ascii-path.test.ts',
// Cross-repo trace e2e: builds two real lbug indexes + a real bridge and
@ -205,9 +206,8 @@ const LBUG_NATIVE = [
// (the same reason fts-extension-e2e.test.ts is registered below), so the
// FTS-unavailable branch has to run on a real Windows/macOS runner rather
// than only on Ubuntu where FTS always loads. And its both-blocked case is
// gated on GITNEXUS_REQUIRE_VECTOR=1, which ci-tests.yml sets ONLY on this
// job — everywhere else an unavailable VECTOR extension skips instead of
// failing. Budget: four real analyze runs, so expect it to sit alongside the
// gated on GITNEXUS_REQUIRE_VECTOR=1, which ci-tests.yml requires in both
// coverage and platform jobs so an unavailable VECTOR fails loudly. Budget: four real analyze runs, so expect it to sit alongside the
// VECTOR sibling's ~87s Windows measurement.
'test/unit/incremental-index-extension-dml-gate.test.ts',
];
@ -289,6 +289,7 @@ const SPAWN_CLI = [
// Worker threads tests — exercise real worker_threads which have
// platform-specific behavior (thread spawning, IPC, exit handling)
const WORKER_THREADS = [
'test/unit/community-processor.test.ts',
'test/integration/worker-pool.test.ts',
'test/integration/parse-impl-quarantine-cache-skip.test.ts',
];
@ -307,6 +308,10 @@ const NATIVE_ADDON_SMOKE = [
// Filesystem behavior tests — exercise operations that vary across
// platforms (CRLF, symlinks, permissions, temp dirs)
const FILESYSTEM = [
// The deletion-guard cases in this file require real Windows path semantics.
'test/unit/canonicalize-path-long-path-prefix.test.ts',
'test/unit/storage-resolver.test.ts',
'test/unit/dart-package-imports.test.ts',
// Cargo membership uses path normalization, descriptor validation, symlinks,
// and Rust native parsing (including long Windows source strings).
'test/unit/scope-resolution/rust-cargo-targets.test.ts',

View file

@ -0,0 +1,38 @@
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { JsonReporter, type TestRunEndReason } from 'vitest/node';
/** Vitest's stock JSON omits unhandled errors, even when success is true. */
export default class ExecutionReporter extends JsonReporter {
constructor() {
super({});
}
override async onTestRunEnd(
modules: Parameters<JsonReporter['onTestRunEnd']>[0],
errors: readonly unknown[] = [],
reason: TestRunEndReason = 'interrupted',
): Promise<void> {
const configuredOutput = this.ctx.config.outputFile;
const outputFile = resolve(
this.ctx.config.root,
(typeof configuredOutput === 'string' ? configuredOutput : configuredOutput?.json) ??
'.vitest/json/output.json',
);
// Vitest 5 writes JSON directly instead of calling writeReport. Use an
// explicit path so this reporter also works for the web suite on Vitest 4.
this.options.outputFile = outputFile;
await super.onTestRunEnd(modules);
const report = JSON.parse(await readFile(outputFile, 'utf8'));
await writeFile(
outputFile,
JSON.stringify({
...report,
success: report.success && errors.length === 0 && reason === 'passed',
executionErrors: errors.length,
executionReason: reason,
}),
'utf8',
);
}
}

View file

@ -0,0 +1,32 @@
import { execFileSync } from 'node:child_process';
import { globSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../', import.meta.url));
// Discover the opt-in suites from source so a new benchmark cannot silently
// miss a hand-maintained workflow list. Keep wall-clock measurements serial.
const files = globSync('test/**/*.test.ts', { cwd: root })
.filter((file) =>
/process\.env(?:\.GITNEXUS_BENCH|\[['"]GITNEXUS_BENCH['"]\])/.test(
readFileSync(new URL(`../${file.replaceAll('\\', '/')}`, import.meta.url), 'utf8'),
),
)
.sort();
if (!files.length) throw new Error('No benchmark test suites discovered');
execFileSync(
process.execPath,
[
fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)),
'run',
'--no-file-parallelism',
...files,
'--reporter=default',
'--reporter=./scripts/execution-reporter.ts',
'--outputFile=benchmarks.json',
],
{
cwd: root,
stdio: 'inherit',
env: { ...process.env, GITNEXUS_BENCH: '1' },
},
);

View file

@ -80,8 +80,19 @@ console.log(
);
const startedAt = Date.now();
const reportName = process.env.GITNEXUS_TEST_REPORT;
if (reportName && !/^[a-zA-Z0-9_-]+\.json$/.test(reportName)) {
throw new Error('GITNEXUS_TEST_REPORT must be a JSON basename');
}
const reportArgs = reportName
? [
'--reporter=default',
'--reporter=./scripts/execution-reporter.ts',
`--outputFile=${reportName}`,
]
: [];
try {
execFileSync('npx', ['vitest', 'run', ...files], {
execFileSync('npx', ['vitest', 'run', ...files, ...reportArgs], {
cwd: ROOT,
stdio: 'inherit',
timeout: timeoutMs,

View file

@ -0,0 +1,203 @@
import { globSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { TestRunEndReason } from 'vitest/node';
type Assertion = {
ancestorTitles: string[];
title: string;
fullName: string;
status: string;
location?: { line: number; column: number };
};
type Suite = {
name: string;
status: string;
assertionResults: Assertion[];
endTime?: number;
message?: string;
};
type Report = {
success?: boolean;
numTotalTests?: number;
startTime?: number;
executionErrors: number;
executionReason: TestRunEndReason;
testResults: Suite[];
};
/** Stable across GitHub's Linux, macOS and Windows checkout roots. */
function testPath(name: string, packageName: string): string {
const normalized = name.replaceAll('\\', '/');
const relative =
normalized.match(new RegExp(`(?:^|/)${packageName}/((?:test|src)/.+)$`))?.[1] ?? normalized;
if (!/^(?:test|src)\/.+\.test\.[jt]sx?$/.test(relative)) {
throw new Error(`Invalid test report path: ${name}`);
}
return relative;
}
/** A skip is resolved only by a recorded pass of that same test in another job. */
export function reconcileTestReports(
inputs: unknown[],
expectedFiles: string[] = [],
packageName = 'gitnexus',
) {
if (inputs.length === 0) throw new Error('No execution reports supplied');
const tests = new Map<
string,
{ file: string; assertion: Assertion; passed: boolean; failed: boolean }
>();
const failures = new Set<string>();
const failedSuites = new Set<string>();
const startTimes: number[] = [];
const endTimes: number[] = [];
for (const [index, input] of inputs.entries()) {
const report = input as Report;
if (!report || !Array.isArray(report.testResults) || report.testResults.length === 0) {
throw new Error(`Missing or empty execution report ${index}`);
}
if (report.success === false) failures.add(`Report ${index}: unsuccessful runner result`);
if (
!Number.isInteger(report.executionErrors) ||
report.executionErrors < 0 ||
!['passed', 'failed', 'interrupted'].includes(report.executionReason)
) {
throw new Error(`Report ${index}: missing execution reporter evidence`);
}
if (report.executionErrors || report.executionReason !== 'passed') {
failures.add(
`Report ${index}: ${report.executionErrors} unhandled errors; ${report.executionReason}`,
);
}
if (typeof report.startTime === 'number') startTimes.push(report.startTime);
let count = 0;
const seen = new Set<string>();
for (const suite of report.testResults) {
const file = testPath(suite.name, packageName);
if (!Array.isArray(suite.assertionResults) || suite.assertionResults.length === 0) {
throw new Error(`No collected tests in ${file}`);
}
if (suite.status === 'failed') {
failures.add(`${file}: failed suite`);
failedSuites.add(file);
}
if (typeof suite.endTime === 'number') endTimes.push(suite.endTime);
for (const assertion of suite.assertionResults) {
if (
!['passed', 'failed', 'pending', 'skipped', 'todo', 'disabled'].includes(
assertion.status,
) ||
!Array.isArray(assertion.ancestorTitles) ||
typeof assertion.title !== 'string' ||
(assertion.location !== undefined &&
(!Number.isInteger(assertion.location.line) ||
!Number.isInteger(assertion.location.column)))
) {
throw new Error(`Malformed assertion in ${file}`);
}
// Collection order differs across platforms. Source location, not an
// ordinal, distinguishes equal titles declared at different locations.
// Helper-generated tests may have no location; their complete title
// must then be unique within the file (the duplicate check still applies).
const title = JSON.stringify([...assertion.ancestorTitles, assertion.title]);
const key = JSON.stringify([
file,
title,
assertion.location?.line ?? null,
assertion.location?.column ?? null,
]);
if (seen.has(key))
throw new Error(
`Ambiguous test identity in ${file}: ${title}; give parameterized cases unique titles`,
);
seen.add(key);
const result = tests.get(key) ?? { file, assertion, passed: false, failed: false };
result.passed ||= assertion.status === 'passed';
result.failed ||= assertion.status === 'failed';
tests.set(key, result);
if (result.failed) failures.add(`${file}: ${assertion.fullName ?? title}`);
count++;
}
}
if (typeof report.numTotalTests === 'number' && report.numTotalTests !== count) {
throw new Error(`Report ${index}: expected ${report.numTotalTests} tests, found ${count}`);
}
}
const unverified = [...tests.values()]
.filter((test) => !test.passed && !test.failed)
.map((test) => `${test.file}: ${test.assertion.fullName}`);
const suites = new Map<string, Suite>();
for (const test of tests.values()) {
const status = test.failed ? 'failed' : test.passed ? 'passed' : 'pending';
const suite = suites.get(test.file) ?? {
name: test.file,
status: 'passed',
assertionResults: [],
};
suite.assertionResults.push({ ...test.assertion, status });
if (status === 'failed' || failedSuites.has(test.file)) suite.status = 'failed';
suites.set(test.file, suite);
}
for (const file of expectedFiles) {
if (!suites.has(file.replaceAll('\\', '/')))
throw new Error(`Test file was never collected: ${file}`);
}
const passed = [...tests.values()].filter((test) => test.passed && !test.failed).length;
const endTime = endTimes.length ? Math.max(...endTimes) : 0;
return {
total: tests.size,
passed,
unverified,
failures: [...failures],
report: {
success: failures.size === 0 && unverified.length === 0,
numTotalTests: tests.size,
numPassedTests: passed,
numFailedTests: [...tests.values()].filter((test) => test.failed).length,
numPendingTests: unverified.length,
numTotalTestSuites: suites.size,
numFailedTestSuites: failedSuites.size,
executionFailures: [...failures],
startTime: startTimes.length ? Math.min(...startTimes) : 0,
testResults: [...suites.values()].map((suite) => ({ ...suite, endTime })),
},
};
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const [directory, output, shardsText] = process.argv.slice(2);
const shards = Number(shardsText);
if (!directory || !output || !Number.isInteger(shards) || shards < 1) {
throw new Error('Usage: test-completeness.ts <reports-dir> <output.json> <platform-shards>');
}
const names = ['coverage.json', 'benchmarks.json', 'preflight.json'];
for (const os of ['windows-latest', 'macos-latest']) {
for (let shard = 1; shard <= shards; shard++) names.push(`${os}-${shard}.json`);
}
// Exact expected receipts: an absent/cancelled job is never an empty success.
const root = fileURLToPath(new URL('../', import.meta.url));
const expected = globSync('test/**/*.test.ts', { cwd: root });
const result = reconcileTestReports(
names.map((name) => JSON.parse(readFileSync(path.join(directory, name), 'utf8'))),
expected,
);
writeFileSync(output, JSON.stringify(result.report));
console.log(
`Test execution: ${result.passed}/${result.total} passed; ${result.unverified.length} unverified; ${result.failures.length} failures`,
);
for (const message of [...result.unverified, ...result.failures]) console.error(message);
if (!result.report.success) process.exitCode = 1;
const webPath = path.join(root, '../gitnexus-web/web-test-results.json');
const web = reconcileTestReports(
[JSON.parse(readFileSync(webPath, 'utf8'))],
globSync('{test,src}/**/*.test.{ts,tsx}', { cwd: path.join(root, '../gitnexus-web') }),
'gitnexus-web',
);
writeFileSync(webPath, JSON.stringify(web.report));
console.log(
`Web execution: ${web.passed}/${web.total} passed; ${web.unverified.length} unverified; ${web.failures.length} failures`,
);
for (const message of [...web.unverified, ...web.failures]) console.error(message);
if (!web.report.success) process.exitCode = 1;
}

View file

@ -56,7 +56,7 @@ export interface McpJsoncTarget {
export interface CodexMcpTarget {
id: 'codex';
label: string;
/** Absolute path to ~/.codex/config.toml. */
/** Absolute path to CODEX_HOME/config.toml (default ~/.codex/config.toml). */
configFile: string;
/** The TOML table header (without brackets) setup writes / uninstall strips. */
tomlSection: string;
@ -100,6 +100,9 @@ export interface EditorTargets {
* `process.env.HOME` before invoking sees the right locations.
*/
export function getEditorTargets(home: string = os.homedir()): EditorTargets {
const codexHome = process.env.CODEX_HOME
? path.resolve(process.env.CODEX_HOME)
: path.join(home, '.codex');
const mcpJsonc: McpJsoncTarget[] = [
{
id: 'cursor',
@ -166,7 +169,7 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
const codex: CodexMcpTarget = {
id: 'codex',
label: 'Codex',
configFile: path.join(home, '.codex', 'config.toml'),
configFile: path.join(codexHome, 'config.toml'),
tomlSection: 'mcp_servers.gitnexus',
};
@ -205,10 +208,10 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
// Codex hooks use Claude Code's exact {hooks: {Event: [...]}} JSON shape
// and hookSpecificOutput response contract, in a dedicated hooks.json
// (https://developers.openai.com/codex/hooks).
settingsFile: path.join(home, '.codex', 'hooks.json'),
settingsFile: path.join(codexHome, 'hooks.json'),
events: ['PreToolUse', 'PostToolUse'],
needle: 'gitnexus-hook',
scriptDir: path.join(home, '.codex', 'hooks', 'gitnexus'),
scriptDir: path.join(codexHome, 'hooks', 'gitnexus'),
},
{
id: 'antigravity',

View file

@ -4,7 +4,11 @@ import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
import path from 'node:path';
import { cliInfo } from './cli-message.js';
import { getGitRoot } from '../storage/git.js';
import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
import {
acquireIndexLock,
requireExclusiveIndexLock,
sweepStagingArtifacts,
} from '../storage/index-lock.js';
import { getStoragePaths, loadMeta, saveMeta } from '../storage/repo-manager.js';
import {
closeLbug,
@ -50,12 +54,22 @@ export const embeddingsSyncCommand = async (inputPath?: string): Promise<void> =
// Writes go to the slot's own graph. A shared-store checkout that reads an
// immutable commit graph (#3352) takes a private copy first.
const lbugPath = path.join(metaDir, LBUG_DIRECTORY);
const lock = await acquireIndexLock(metaDir);
const lock = await acquireIndexLock(metaDir, { sweep: false });
try {
requireExclusiveIndexLock(
lock,
`Cannot acquire the index lock at ${metaDir}; refusing an unlocked embeddings sync.`,
);
// Sync writes the published graph and cannot recover a staged generation.
// Reject even malformed receipts before sweeping staging files or writing.
const recoveryCheckpoint = (await loadMeta(metaDir))?.embeddingCheckpoint;
if (recoveryCheckpoint && Object.hasOwn(recoveryCheckpoint, 'recovery')) {
throw new Error(
'Cannot sync embeddings: the index checkpoint references staged embeddings. ' +
'Run `gitnexus analyze` to recover them first.',
);
}
sweepStagingArtifacts(metaDir);
if (!(await ensurePrivateSharedGraph(metaDir, (m) => console.log(` ${m}`)))) {
throw new Error('The shared graph this checkout reads is gone. Run gitnexus analyze first.');
}

View file

@ -12,7 +12,16 @@ import os from 'os';
import { execFile, execFileSync } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
import {
parseTree,
findNodeAtLocation,
getNodeValue,
modify,
applyEdits,
ParseError,
parse as parseJsonc,
} from 'jsonc-parser';
import { parse as parseToml, TomlError } from 'smol-toml';
import { packageVersion } from '../core/package-version.js';
import { getGlobalDir } from '../storage/repo-manager.js';
import {
@ -214,7 +223,8 @@ async function mergeJsoncFile(
filePath: string,
keyPath: string[],
value: unknown,
): Promise<boolean> {
options?: { preserveHttpEntry?: boolean },
): Promise<boolean | 'preserved-http'> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
@ -240,6 +250,15 @@ async function mergeJsoncFile(
const tree = parseTree(raw, parseErrors);
if (tree && tree.type === 'object' && parseErrors.length === 0) {
const existingNode = options?.preserveHttpEntry ? findNodeAtLocation(tree, keyPath) : undefined;
const existingEntry = existingNode === undefined ? undefined : getNodeValue(existingNode);
if (
existingEntry !== null &&
typeof existingEntry === 'object' &&
typeof (existingEntry as Record<string, unknown>).url === 'string'
) {
return 'preserved-http';
}
const formattingOptions = detectIndentation(raw);
const edits = modify(raw, keyPath, value, { formattingOptions });
const result = applyEdits(raw, edits);
@ -306,8 +325,12 @@ async function setupCursor(result: SetupResult): Promise<void> {
const { file: mcpPath, keyPath } = mcpTarget('cursor');
try {
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry(), {
preserveHttpEntry: true,
});
if (ok === 'preserved-http') {
result.configured.push('Cursor (existing HTTP MCP entry kept)');
} else if (ok) {
result.configured.push('Cursor');
} else {
result.errors.push('Cursor: mcp.json is corrupt — skipping to preserve existing content');
@ -327,8 +350,12 @@ async function setupClaudeCode(result: SetupResult): Promise<void> {
// Claude Code stores MCP config in ~/.claude.json
const { file: mcpPath, keyPath } = mcpTarget('claude');
try {
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
if (ok) {
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry(), {
preserveHttpEntry: true,
});
if (ok === 'preserved-http') {
result.configured.push('Claude Code (existing HTTP MCP entry kept)');
} else if (ok) {
result.configured.push('Claude Code');
} else {
result.errors.push(
@ -1044,13 +1071,47 @@ async function upsertCodexConfigToml(configPath: string): Promise<void> {
await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8');
}
async function codexHasHttpMcpEntry(configPath: string): Promise<boolean> {
let raw: string;
try {
raw = await fs.readFile(configPath, 'utf-8');
} catch (err) {
if (isEnoent(err)) return false;
throw err;
}
const config = parseToml(raw);
const servers = config.mcp_servers;
if (servers === null || typeof servers !== 'object') return false;
const entry = (servers as Record<string, unknown>).gitnexus;
return (
entry !== null &&
typeof entry === 'object' &&
typeof (entry as Record<string, unknown>).url === 'string'
);
}
async function setupCodex(result: SetupResult): Promise<void> {
const codexDir = path.join(os.homedir(), '.codex');
if (!(await dirExists(codexDir))) {
const configPath = getEditorTargets().codex.configFile;
const codexDir = path.dirname(configPath);
if (!process.env.CODEX_HOME && !(await dirExists(codexDir))) {
result.skipped.push('Codex (not installed)');
return;
}
try {
if (await codexHasHttpMcpEntry(configPath)) {
result.configured.push('Codex (existing HTTP MCP entry kept)');
return;
}
} catch (err) {
result.errors.push(
err instanceof TomlError
? `Codex: invalid config.toml (line ${err.line}, column ${err.column}); MCP entry unchanged`
: `Codex: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
try {
const entry = getMcpEntry();
await execFileAsync('codex', ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args], {
@ -1064,9 +1125,8 @@ async function setupCodex(result: SetupResult): Promise<void> {
}
try {
const configPath = getEditorTargets().codex.configFile;
await upsertCodexConfigToml(configPath);
result.configured.push('Codex (MCP added to ~/.codex/config.toml)');
result.configured.push('Codex (MCP added to active config.toml)');
} catch (err: any) {
result.errors.push(`Codex: ${err.message}`);
}
@ -1265,7 +1325,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
* Install global Codex skills to ~/.agents/skills/gitnexus/
*/
async function installCodexSkills(result: SetupResult): Promise<void> {
const codexDir = path.join(os.homedir(), '.codex');
const codexDir = path.dirname(getEditorTargets().codex.configFile);
if (!(await dirExists(codexDir))) return;
const skillsDir = skillTarget('codex').dir;

View file

@ -129,7 +129,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
// Step 1: BM25 search (fast, no embeddings)
const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId);
const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId);
// Step 2: Map BM25 file results to symbols
const symbolMatches: Array<{
@ -167,16 +167,18 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
}
// When FTS indexes are unavailable (read-only DB, first run before indexes are built),
// fall back to a direct name CONTAINS query so enrichment still works.
if (symbolMatches.length === 0 && !ftsAvailable) {
// FTS ranks files by mentions, so a widely referenced symbol's definition
// may not be present in the top file results. Fall back to graph names
// whenever those files produce no symbol match, regardless of FTS health.
if (symbolMatches.length === 0) {
const fallbackRows = await executeQuery(
repoId,
`
MATCH (n)
WHERE n.name CONTAINS '${patternFirstWord}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
ORDER BY id
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath,
CASE WHEN n.name = '${patternFirstWord}' THEN 0 ELSE 1 END AS exactRank
ORDER BY exactRank, id
LIMIT 5
`,
).catch(() => []);

View file

@ -0,0 +1,181 @@
/** Isolated strict native reader. Never import this entrypoint into analyze. */
import fs from 'node:fs';
import path from 'node:path';
import lbug from '@ladybugdb/core';
import { createLbugDatabase, toNativeSafePath } from '../lbug/lbug-config.js';
import { FAMILY_SUFFIXES } from '../../storage/embedding-recovery.js';
import {
abortCachedEmbeddingsBuilder,
createCachedEmbeddingsBuilder,
finalizeCachedEmbeddingsSnapshot,
ingestCachedEmbeddingRow,
} from './embedding-restore-spill.js';
import type { StagedEmbeddingExport } from './staged-embedding-recovery.js';
/** Native replay and close may checkpoint; only give them disposable copies. */
function copyRecoveryFamily(dbPath: string, exportDir: string): string {
const replayDir = fs.mkdtempSync(path.join(exportDir, 'replay-'));
const replayPath = path.join(replayDir, path.basename(dbPath));
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
const flags = fs.constants.O_RDONLY | noFollow | (fs.constants.O_NONBLOCK ?? 0);
const buffer = Buffer.allocUnsafe(1024 * 1024);
for (const suffix of FAMILY_SUFFIXES) {
const sourcePath = dbPath + suffix;
let entry: fs.BigIntStats;
try {
entry = fs.lstatSync(sourcePath, { bigint: true });
} catch (error) {
if (suffix && (error as NodeJS.ErrnoException).code === 'ENOENT') continue;
throw error;
}
if (!entry.isFile()) throw new Error('staged embedding family is not a regular file');
const source = fs.openSync(sourcePath, flags);
let destination: number | undefined;
try {
const opened = fs.fstatSync(source, { bigint: true });
const current = fs.lstatSync(sourcePath, { bigint: true });
if (
!opened.isFile() ||
!current.isFile() ||
(noFollow === 0 && opened.ino === 0n) ||
opened.dev !== entry.dev ||
opened.ino !== entry.ino ||
opened.dev !== current.dev ||
opened.ino !== current.ino
) {
throw new Error('staged embedding family changed while opening');
}
destination = fs.openSync(replayPath + suffix, 'wx', 0o600);
let copied = 0n;
for (;;) {
const bytesRead = fs.readSync(source, buffer, 0, buffer.length, null);
if (bytesRead === 0) break;
fs.writeFileSync(destination, buffer.subarray(0, bytesRead));
copied += BigInt(bytesRead);
}
const after = fs.fstatSync(source, { bigint: true });
if (
copied !== opened.size ||
after.size !== opened.size ||
after.mtimeNs !== opened.mtimeNs ||
after.ctimeNs !== opened.ctimeNs
) {
throw new Error('staged embedding family changed while copying');
}
} finally {
try {
if (destination !== undefined) fs.closeSync(destination);
} finally {
fs.closeSync(source);
}
}
}
return replayPath;
}
async function extract(): Promise<void> {
const [dbPath, exportDir, dimensionsArg] = process.argv.slice(2);
const dimensions = Number(dimensionsArg);
if (!dbPath || !exportDir || !Number.isInteger(dimensions) || dimensions <= 0) {
throw new Error('invalid staged embedding extraction arguments');
}
// The parent owns exportDir and reclaims it even after killing this child.
const replayPath = copyRecoveryFamily(dbPath, exportDir);
const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0, spillDir: exportDir });
const rejectedNodeIds = new Set<string>();
let db: lbug.Database | undefined;
let conn: lbug.Connection | undefined;
try {
// Avoid openLbugConnection's test-fixture lock sweep: a recovery source must
// never have its WAL removed, even when an external slot resembles a fixture.
db = createLbugDatabase(lbug, toNativeSafePath(replayPath), { throwOnWalReplayFailure: true });
conn = new lbug.Connection(db);
const queried = await conn.query(
'MATCH (e:CodeEmbedding) 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',
);
const results = Array.isArray(queried) ? queried : [queried];
try {
if (results.length !== 1) throw new Error('unexpected staged embedding query result');
const result = results[0];
while (await result.hasNext()) {
const raw = await result.getNext();
const rec = raw as Record<string, unknown> & unknown[];
const nodeId = rec.nodeId ?? rec[0];
if (typeof nodeId !== 'string' || !nodeId)
throw new Error('invalid staged embedding node id');
const chunkIndex = rec.chunkIndex ?? rec[1];
const startLine = rec.startLine ?? rec[2];
const endLine = rec.endLine ?? rec[3];
const embedding = rec.embedding ?? rec[4];
const contentHash = rec.contentHash ?? rec[5];
const vector =
Array.isArray(embedding) ||
(ArrayBuffer.isView(embedding) && !(embedding instanceof DataView))
? Array.from(embedding as ArrayLike<number>)
: undefined;
if (
!Number.isInteger(chunkIndex) ||
Number(chunkIndex) < 0 ||
!Number.isInteger(startLine) ||
Number(startLine) < 0 ||
!Number.isInteger(endLine) ||
Number(endLine) < Number(startLine) ||
typeof contentHash !== 'string' ||
!contentHash ||
!vector ||
vector.length !== dimensions ||
vector.some(
(value) =>
typeof value !== 'number' ||
!Number.isFinite(value) ||
!Number.isFinite(Math.fround(value)),
)
) {
rejectedNodeIds.add(nodeId);
continue;
}
ingestCachedEmbeddingRow(
builder,
{ nodeId, chunkIndex, startLine, endLine, embedding: vector, contentHash },
true,
);
}
} finally {
for (const result of results) await result.close();
}
// Both closes must succeed. Suppressed native teardown errors are unsafe.
await conn.close();
await db.close();
const snapshot = finalizeCachedEmbeddingsSnapshot(builder);
if (snapshot.spill) fs.renameSync(snapshot.spill.path, path.join(exportDir, 'vectors.bin'));
const manifest: StagedEmbeddingExport = {
version: 1,
dimensions,
rows: snapshot.rows,
rejectedNodeIds: [...rejectedNodeIds],
};
fs.writeFileSync(path.join(exportDir, 'manifest.json'), JSON.stringify(manifest), {
flag: 'wx',
mode: 0o600,
});
} catch (err) {
abortCachedEmbeddingsBuilder(builder);
// Cleanup is best effort on a rejected source, never used to approve output.
try {
await conn?.close();
} catch {
/* rejected */
}
try {
await db?.close();
} catch {
/* rejected */
}
throw err;
}
}
extract().catch((err: unknown) => {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
});

View file

@ -0,0 +1,244 @@
/** Recover paid embedding rows without opening an interrupted native DB in analyze. */
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
abortCachedEmbeddingsBuilder,
createCachedEmbeddingsBuilder,
EmbeddingSpillReader,
emptyCachedEmbeddingsSnapshot,
finalizeCachedEmbeddingsSnapshot,
ingestCachedEmbeddingRow,
materializeCachedEmbeddings,
type CachedEmbeddingMeta,
type CachedEmbeddingsSnapshot,
} from './embedding-restore-spill.js';
export interface StagedEmbeddingRecoveryOptions {
dimensions: number;
/** Active checkpoint window and any incomplete inherited restore groups. */
excludedNodeIds?: Iterable<string>;
timeoutMs?: number;
}
export interface StagedEmbeddingExport {
version: 1;
dimensions: number;
rows: CachedEmbeddingMeta[];
rejectedNodeIds: string[];
}
const RESTORE_BATCH_SIZE = 200;
const DEFAULT_EXTRACTION_TIMEOUT_MS = 120_000;
/** A contiguous prefix is safe only when its node is outside every unsafe window. */
export function validateRecoveredNodeGroups(
rows: readonly CachedEmbeddingMeta[],
excludedNodeIds: ReadonlySet<string> = new Set(),
): Set<string> {
const groups = new Map<string, { hash: string; ordinals: Set<number>; invalid: boolean }>();
for (const row of rows) {
let group = groups.get(row.nodeId);
if (!group) {
group = { hash: row.contentHash, ordinals: new Set(), invalid: false };
groups.set(row.nodeId, group);
}
if (
typeof row.nodeId !== 'string' ||
!row.nodeId ||
typeof row.contentHash !== 'string' ||
!row.contentHash ||
row.contentHash !== group.hash ||
!Number.isInteger(row.chunkIndex) ||
row.chunkIndex < 0 ||
group.ordinals.has(row.chunkIndex) ||
!Number.isInteger(row.startLine) ||
row.startLine < 0 ||
!Number.isInteger(row.endLine) ||
row.endLine < row.startLine
)
group.invalid = true;
group.ordinals.add(row.chunkIndex);
}
const accepted = new Set<string>();
for (const [nodeId, group] of groups) {
if (group.invalid || excludedNodeIds.has(nodeId)) continue;
// Unique ordinals with max n-1 and zero present have no holes.
if (!group.ordinals.has(0)) continue;
if ([...group.ordinals].some((ordinal) => ordinal >= group.ordinals.size)) continue;
accepted.add(nodeId);
}
return accepted;
}
/** Whole recovered nodes replace whole published groups; vectors stay in bounded batches. */
export function mergeRecoveredEmbeddings(
live: CachedEmbeddingsSnapshot,
recovered: CachedEmbeddingsSnapshot,
): CachedEmbeddingsSnapshot {
const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0 });
try {
appendSnapshotRows(
live,
live.rows.filter((row) => !recovered.embeddingNodeIds.has(row.nodeId)),
builder,
);
appendSnapshotRows(recovered, recovered.rows, builder);
return finalizeCachedEmbeddingsSnapshot(builder);
} catch (err) {
abortCachedEmbeddingsBuilder(builder);
throw err;
}
}
function appendSnapshotRows(
snapshot: CachedEmbeddingsSnapshot,
rows: readonly CachedEmbeddingMeta[],
builder: ReturnType<typeof createCachedEmbeddingsBuilder>,
): void {
const reader = snapshot.spill ? new EmbeddingSpillReader(snapshot.spill) : undefined;
try {
for (let i = 0; i < rows.length; i += RESTORE_BATCH_SIZE) {
const batch = materializeCachedEmbeddings(
snapshot,
rows.slice(i, i + RESTORE_BATCH_SIZE),
reader,
);
for (const row of batch)
ingestCachedEmbeddingRow(builder, row as unknown as Record<string, unknown>, true);
}
} finally {
reader?.close();
}
}
/**
* Caller must validate checkpoint identity, schema, exact generation, and hold the
* index lock. A subprocess contains native WAL replay/query/destructor failures.
* A failed strict open is never retried with WAL removed or validation disabled.
*/
export async function recoverStagedEmbeddings(
dbPath: string,
options: StagedEmbeddingRecoveryOptions,
): Promise<CachedEmbeddingsSnapshot> {
if (!Number.isInteger(options.dimensions) || options.dimensions <= 0) {
throw new Error('invalid staged embedding dimensions');
}
const dbStat = fs.lstatSync(dbPath);
if (!dbStat.isFile() || dbStat.isSymbolicLink())
throw new Error('staged embedding DB is not a regular file');
const exportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-export-'));
try {
await runExtractionChild(dbPath, exportDir, options);
const manifestPath = path.join(exportDir, 'manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as StagedEmbeddingExport;
if (
manifest.version !== 1 ||
manifest.dimensions !== options.dimensions ||
!Array.isArray(manifest.rows) ||
!Array.isArray(manifest.rejectedNodeIds) ||
manifest.rejectedNodeIds.some((id) => typeof id !== 'string') ||
manifest.rows.some((row, i) => !row || row.vectorIndex !== i)
)
throw new Error('invalid staged embedding export manifest');
if (manifest.rows.length === 0) return emptyCachedEmbeddingsSnapshot();
const spill = {
path: path.join(exportDir, 'vectors.bin'),
dims: options.dimensions,
rowCount: manifest.rows.length,
};
const vectorStat = fs.lstatSync(spill.path);
if (
!vectorStat.isFile() ||
vectorStat.isSymbolicLink() ||
vectorStat.size !== 12 + spill.rowCount * spill.dims * 4
) {
throw new Error('invalid staged embedding export size');
}
const excluded = new Set(options.excludedNodeIds ?? []);
for (const nodeId of manifest.rejectedNodeIds) excluded.add(nodeId);
const accepted = validateRecoveredNodeGroups(manifest.rows, excluded);
const exported: CachedEmbeddingsSnapshot = {
rows: manifest.rows,
embeddings: [],
embeddingNodeIds: accepted,
spill,
};
const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0 });
const reader = new EmbeddingSpillReader(spill);
try {
const rows = manifest.rows.filter((row) => accepted.has(row.nodeId));
for (let i = 0; i < rows.length; i += RESTORE_BATCH_SIZE) {
for (const row of materializeCachedEmbeddings(
exported,
rows.slice(i, i + RESTORE_BATCH_SIZE),
reader,
)) {
if (
row.embedding.length !== options.dimensions ||
row.embedding.some((value) => !Number.isFinite(value))
) {
throw new Error('invalid staged embedding vector');
}
ingestCachedEmbeddingRow(builder, row as unknown as Record<string, unknown>, true);
}
}
return finalizeCachedEmbeddingsSnapshot(builder);
} catch (err) {
abortCachedEmbeddingsBuilder(builder);
throw err;
} finally {
reader.close();
}
} finally {
fs.rmSync(exportDir, { recursive: true, force: true });
}
}
function runExtractionChild(
dbPath: string,
exportDir: string,
options: StagedEmbeddingRecoveryOptions,
): Promise<void> {
const compiledPath = fileURLToPath(
new URL('./staged-embedding-recovery-child.js', import.meta.url),
);
const sourcePath = compiledPath.replace(/\.js$/, '.ts');
const childPath = fs.existsSync(compiledPath) ? compiledPath : sourcePath;
const args = childPath.endsWith('.ts')
? ['--import', import.meta.resolve('tsx'), childPath]
: [childPath];
args.push(dbPath, exportDir, String(options.dimensions));
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, {
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true,
});
let stderr = '';
let timedOut = false;
child.stderr.on('data', (chunk: Buffer) => {
if (stderr.length < 4096) stderr += chunk.toString().slice(0, 4096 - stderr.length);
});
const timer = setTimeout(() => {
timedOut = true;
// A process boundary is safe to kill even while native code is executing.
child.kill('SIGKILL');
}, options.timeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS);
child.once('error', (err) => {
clearTimeout(timer);
reject(err);
});
child.once('close', (code, signal) => {
clearTimeout(timer);
if (code === 0 && !signal && !timedOut) resolve();
else
reject(
new Error(
`staged embedding extraction failed${timedOut ? ' (timeout)' : ` (${signal ?? code})`}${stderr ? `: ${stderr.trim()}` : ''}`,
),
);
});
});
}

View file

@ -1,27 +1,42 @@
import type Parser from 'tree-sitter';
import Go from 'tree-sitter-go';
import { goImportPackageName } from '../../../ingestion/languages/go/import-package-name.js';
import {
isEchoImportPath,
isGinImportPath,
stringLiteral,
} from '../../../ingestion/route-extractors/go-shared.js';
import {
compilePatterns,
runCompiledPatterns,
unquoteLiteral,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
/**
* Go HTTP plugin. Handles:
* - gin / echo / chi framework routing — `r.GET("/path", handler)`
* - gin / echo framework routing — `r.GET("/path", handler)`, including
* prefixes from route groups bound in the same function (`r.Group("/api")`)
* - net/http stdlib — `http.HandleFunc("/path", handler)`
* - net/http consumer — `http.Get(...)`, `http.NewRequest("METHOD", ...)`
* - resty consumer — `client.R().Delete("/path")`
*/
// ─── Provider: framework routing ──────────────────────────────────────
// Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape).
// Captures the HTTP method (field name), path literal, and the handler —
// anchored to the LAST argument (`@handler .`) so a variadic middleware
// chain (`r.GET("/x", mw, handler)`, gin/echo/chi style) binds the real
// handler, not a middleware identifier (which would otherwise over-match
// and attach the route to the wrong symbol — see #2276 review).
// Matches `\w+\.GET(...)` etc. (gin and echo share this shape).
// Captures the receiver, the HTTP method (field name), and the path literal
// (either Go string form; stringLiteral decodes both, as ingestion does). The
// query does not anchor the path with `.`: tree-sitter counts comments as
// named children, so `GET(/* c */ "/p", h)` would fail the anchor. scan instead
// requires the path to be the first argument in code (comments skipped) and
// picks the handler out of the remaining code arguments. Which argument that is depends on the framework:
// gin is `GET(path, middleware..., handler)` (last), echo is
// `GET(path, handler, middleware...)` (first) — see readFrameworkImports and
// the per-call choice in scan below.
// The handler must be an identifier, an inline func literal, or a method
// value / package-qualified function (`h.ListUsers`, `handlers.ListUsers`);
// anything else there means the call cannot be attributed to a symbol, so it
// is dropped rather than guessed (variadic-middleware over-match, #2276).
const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
name: 'go-framework-route',
language: Go,
@ -31,16 +46,633 @@ const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
query: `
(call_expression
function: (selector_expression
operand: (_) @receiver
field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$"))
arguments: (argument_list
(interpreted_string_literal) @path
[(identifier) (func_literal)] @handler
.))
[(interpreted_string_literal) (raw_string_literal)] @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
/** Named children that are code, not comments (tree-sitter names comments). */
function codeChildren(node: Parser.SyntaxNode | null | undefined): Parser.SyntaxNode[] {
return node ? node.namedChildren.filter((c) => c.type !== 'comment') : [];
}
/** Argument forms a route handler may take. */
const HANDLER_ARG_TYPES: ReadonlySet<string> = new Set([
'identifier',
'func_literal',
'selector_expression',
]);
/**
* The file's framework import aliases: which local qualifiers resolve to
* echo and to gin. Matched on the EXACT import path (shared with ingestion via
* go-shared.ts) rather than the local name, so
* an aliased import still counts; an unaliased import is keyed by its
* conventional package name (`goImportPackageName`). `_` and `.` imports bind
* no qualifier this file can route through. An empty set means the file
* proves nothing about that framework. Echo's verb calls take the handler as
* the FIRST argument after the path (`GET(path, handler, middleware...)`),
* gin's as the LAST (`GET(path, middleware..., handler)`) — `scan` picks the
* rule per call from these sets.
*/
function readFrameworkImports(root: Parser.SyntaxNode): {
echo: Set<string>;
gin: Set<string>;
} {
// Imports sit only at file scope; skip bodies.
const specs = root.namedChildren
.filter((node) => node.type === 'import_declaration')
.flatMap((decl) => decl.descendantsOfType('import_spec'));
const echo = new Set<string>();
const gin = new Set<string>();
for (const spec of specs) {
const importPath = stringLiteral(spec.childForFieldName('path'));
if (importPath === null) continue;
const local = spec.childForFieldName('name')?.text ?? goImportPackageName(importPath);
if (local === '_' || local === '.') continue;
if (isEchoImportPath(importPath)) echo.add(local);
else if (isGinImportPath(importPath)) gin.add(local);
}
return { echo, gin };
}
// ─── Route groups: `v1 := r.Group("/api/v1")` ─────────────────────────
// gin (`*gin.RouterGroup`) and echo (`*echo.Group`) routes registered on a
// group inherit every enclosing `Group(prefix)`. The prefix is recovered by
// walking the route's receiver back through its bindings, lexically, inside
// the enclosing function declaration only: a group handed to another
// function (`registerAdmin(v1)`) arrives as a parameter and contributes no
// prefix there, and a receiver bound to anything but a literal-prefix
// `Group(...)` call contributes none either — the route keeps its literal path.
// Statement-scoped bindings count too: an `if`/`switch` initializer, a `for`
// clause (including `range`), a type-switch guard, and declarations inside a
// switch case all scope over their statement the same way Go scopes them, so
// they shadow an outer group of the same name instead of being skipped. A
// select case's receive binding (`case g := <-ch:`) stops the walk entirely:
// what arrives from the channel is statically unknown, so the route keeps its
// literal path rather than inheriting an outer group.
const MAX_GROUP_DEPTH = 32;
function joinRoutePath(prefix: string, relative: string): string {
let joined = relative;
if (prefix && relative) {
joined = `${prefix.replace(/\/+$/, '')}/${relative.replace(/^\/+/, '')}`;
} else if (prefix) {
joined = prefix;
}
// Collapse duplicate slashes on the FINAL result — every return branch, not
// just the join — because ingestion's normalizeExtractedRoutePath collapses
// all "//" while the downstream contract-id normalizer does not: a path
// that keeps "//" would split into two contract ids across the strategies.
const collapsed = joined.replace(/\/+/g, '/');
// Force a leading "/" for the same reason: ingestion's
// normalizeExtractedRoutePath always adds one, while normalizeHttpPath (the
// shared contract-id normalizer) does not — a literal "x" or a slashless
// Group("api") prefix would emit `...::x` here and `...::/x` there. Gin
// also refuses a registration path that does not start with "/".
return collapsed.startsWith('/') ? collapsed : `/${collapsed}`;
}
/** `parent.Group("/p", mw...)` → its receiver and literal prefix; null otherwise. */
function asGroupCall(
node: Parser.SyntaxNode,
): { parent: Parser.SyntaxNode; prefix: string } | null {
if (node.type !== 'call_expression') return null;
const fn = node.childForFieldName('function');
if (fn?.type !== 'selector_expression' || fn.childForFieldName('field')?.text !== 'Group') {
return null;
}
const parent = fn.childForFieldName('operand');
const first = codeChildren(node.childForFieldName('arguments'))[0];
// Only a string literal carries a prefix, and it must decode to the text
// the runtime registers: stringLiteral applies Go unescaping (both `"…"`
// with escapes and raw `` `…` `` strings). A non-literal argument (a
// variable, concatenation) or an undecodable string contributes no prefix.
const prefix = first ? stringLiteral(first) : null;
if (!parent || prefix === null) return null;
return { parent, prefix };
}
/** The `var_spec`s of a `var_declaration`, flattened out of a grouped `var (…)`. */
function varSpecs(decl: Parser.SyntaxNode): Parser.SyntaxNode[] {
return decl.namedChildren
.flatMap((c) => (c.type === 'var_spec_list' ? c.namedChildren : [c]))
.filter((spec) => spec.type === 'var_spec');
}
/** The expression `name` is assigned by `stmt` (`:=`, `=`, or `var`), if any. */
function boundValue(stmt: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null | undefined {
const pick = (
names: Parser.SyntaxNode[],
values: Parser.SyntaxNode | null,
): Parser.SyntaxNode | null | undefined => {
// Go carries an assignment out left to right, so a repeated target leaves
// the LAST one in place (`g, g = a, b` ends with g == b).
let i = -1;
for (let k = 0; k < names.length; k++) {
if (names[k].type === 'identifier' && names[k].text === name) i = k;
}
if (i < 0) return undefined;
return codeChildren(values)[i] ?? null;
};
switch (stmt.type) {
case 'short_var_declaration':
case 'assignment_statement':
return pick(codeChildren(stmt.childForFieldName('left')), stmt.childForFieldName('right'));
case 'var_spec':
return pick(stmt.childrenForFieldName('name'), stmt.childForFieldName('value'));
case 'var_declaration': {
for (const spec of varSpecs(stmt)) {
const value = pick(spec.childrenForFieldName('name'), spec.childForFieldName('value'));
if (value !== undefined) return value;
}
return undefined;
}
default:
return undefined;
}
}
/**
* A binding whose value cannot be established statically: an earlier
* statement writes the name inside a nested scope (`{ g = r.Group("/new") }`,
* a branch or loop body), so which value reaches the use depends on control
* flow. Callers decline the route instead of picking the older binding.
*/
const CONFLICT = Symbol('conflicting-binding');
/** The name is a parameter (or method receiver): its declaration carries a static type. */
interface ParamBinding {
param: Parser.SyntaxNode;
}
type Binding = Parser.SyntaxNode | null | undefined | typeof CONFLICT | ParamBinding;
function isParamBinding(b: Binding): b is ParamBinding {
return typeof b === 'object' && b !== null && 'param' in b;
}
/** The parameter_declaration of `fn` (parameters or method receiver) declaring `name`. */
function paramDeclaring(fn: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null {
for (const list of [fn.childForFieldName('parameters'), fn.childForFieldName('receiver')]) {
for (const decl of codeChildren(list)) {
if (decl.type !== 'parameter_declaration' && decl.type !== 'variadic_parameter_declaration') {
continue;
}
if (decl.childrenForFieldName('name').some((n) => n.text === name)) return decl;
}
}
return null;
}
/** Whether `inner` lies within `outer`'s source span. */
function within(outer: Parser.SyntaxNode | null, inner: Parser.SyntaxNode): boolean {
return !!outer && outer.startIndex <= inner.startIndex && inner.endIndex <= outer.endIndex;
}
/**
* Whether `node` is itself a write to `name`: a plain `assignment_statement`
* (`g = …`) or the `=` form of a range clause (`for _, g = range xs`) or a
* select receive (`case g = <-ch`), which tree-sitter parses as their own node
* types. A `:=` declares a new variable — loop- or case-local — and is not a
* write to the outer one.
*/
function isWriteTo(node: Parser.SyntaxNode, name: string): boolean {
if (node.type === 'assignment_statement') {
return declaresName(node.childForFieldName('left'), name);
}
return (
(node.type === 'range_clause' || node.type === 'receive_statement') &&
assignsExistingName(node) &&
declaresName(node.childForFieldName('left'), name)
);
}
/**
* Every write to `name` inside `scope` (a statement or a function literal).
* With `executedOnly`, a nested function literal is entered only through a
* call: one merely stored in a value does not run when its enclosing body
* runs.
*/
function writesInScope(
scope: Parser.SyntaxNode,
name: string,
executedOnly: boolean,
): Parser.SyntaxNode[] {
const out: Parser.SyntaxNode[] = [];
const walk = (node: Parser.SyntaxNode): void => {
if (node.type === 'func_literal') {
if (executedOnly && !isInvokedLiteral(node)) return;
const body = node.childForFieldName('body');
if (body) walk(body);
return;
}
if (isWriteTo(node, name)) out.push(node);
for (const child of node.namedChildren) walk(child);
};
walk(scope);
return out;
}
/**
* Whether `scope` writes `name` through a binding declared OUTSIDE it: an
* assignment (or `=` range/receive) whose target resolves to a name the scope
* itself does not declare — a captured outer group rather than a local `:=`,
* `var`, or parameter. An unresolvable or conflicting write reaches out
* conservatively. With `executedOnly`, writes that only run if a nested
* function literal is later called are ignored.
*/
function writesOuterName(scope: Parser.SyntaxNode, name: string, executedOnly = false): boolean {
for (const write of writesInScope(scope, name, executedOnly)) {
const ident = identifierNamed(write.childForFieldName('left'), name);
if (!ident) continue;
// A range/receive `=` target resolves from OUTSIDE its clause: the
// clause's own binding logic describes the loop/case variable, not the
// existing name the `=` writes.
const from =
write.type === 'range_clause' || write.type === 'receive_statement'
? write.parent
: undefined;
const binding = lookupBinding(ident, from);
// null is a declaration without a traceable value; it carries no position
// to place, so a write through it reaches out conservatively.
if (binding === undefined || binding === null || binding === CONFLICT) return true;
const declared = isParamBinding(binding) ? binding.param : binding;
if (!within(scope, declared)) return true;
}
return false;
}
/**
* Whether `stmt` writes `name` through a binding declared outside it — a
* nested assignment, branch, loop, or closure body. A declaration that
* shadows `name` inside the statement (a nested `:=`, a parameter) writes the
* local, not the target the use would see, so it does not count.
*/
function writesNameInside(stmt: Parser.SyntaxNode, name: string): boolean {
return writesOuterName(stmt, name);
}
/** A range clause or receive that assigns (`=`) rather than declares (`:=`). */
function assignsExistingName(clause: Parser.SyntaxNode): boolean {
return !clause.children.some((c) => c.type === ':=');
}
/**
* Whether `fn` is the callee of a call (`func(){ … }()`), ignoring
* parentheses, so its body is known to run.
*/
function isInvokedLiteral(fn: Parser.SyntaxNode): boolean {
let callee: Parser.SyntaxNode = fn;
while (callee.parent?.type === 'parenthesized_expression') callee = callee.parent;
const parent = callee.parent;
return (
!!parent &&
parent.type === 'call_expression' &&
parent.childForFieldName('function')?.id === callee.id
);
}
/**
* Whether evaluating `node` executes a write to `name`. A function literal is
* only entered when it is the callee of a call: `g := func(){ g = … }` merely
* stores the literal, so the outer `g` is untouched, while `func(){ … }()`
* runs its body (writes that resolve to the literal's own locals excepted).
*/
function executedWritesName(node: Parser.SyntaxNode, name: string): boolean {
if (node.type === 'func_literal') {
return isInvokedLiteral(node) && writesOuterName(node, name, true);
}
if (isWriteTo(node, name)) return true;
return node.namedChildren.some((child) => executedWritesName(child, name));
}
/**
* Whether the initializer of the declaration that brings `name` into scope in
* a block writes the OUTER `name`. The right-hand side runs before the new
* variable exists, so `g := func(){ g = … }()` still writes the binding the
* use outside the block would inherit — but a literal only stored in a value
* never runs its body. Grouped `var` specs up to and including the declaring
* one are scanned; later specs read the new local.
*/
function initializerWritesName(decl: Parser.SyntaxNode, name: string): boolean {
if (decl.type === 'short_var_declaration') {
const values = decl.childForFieldName('right');
return !!values && executedWritesName(values, name);
}
if (decl.type === 'var_declaration') {
for (const spec of varSpecs(decl)) {
const value = spec.childForFieldName('value');
if (value && executedWritesName(value, name)) return true;
if (boundValue(spec, name) !== undefined) return false;
}
}
return false;
}
/**
* Whether `stmt` holds a function literal that writes the outer `name`. Such
* a literal can run after any later direct assignment (`reset := func(){ g = … };
* g = …; reset()`), so captured writes are found independently of statement
* order instead of being lost behind the last-assignment return. Writes that
* resolve to a declaration inside the literal (a parameter, a nested `:=`) are
* local and do not count.
*/
function closureWritesName(stmt: Parser.SyntaxNode, name: string): boolean {
const literals =
stmt.type === 'func_literal'
? [stmt, ...stmt.descendantsOfType('func_literal')]
: stmt.descendantsOfType('func_literal');
return literals.some((fn) => writesOuterName(fn, name));
}
/**
* The effect a bare `{ … }` block placed before the use has on the outer
* `name`. A bare block always runs, so its last top-level `name = v` is the
* value that reaches the use (`{ g = r.Group("/new") }` → `/new`); a write
* nested in a branch, loop, or closure inside it is CONFLICT. A `:=` or `var`
* of `name` in the block starts a new variable, so only the statements before
* it touch the outer one — but the declaring statement's initializer still
* does, because it evaluates before the new variable is in scope. undefined
* when the block leaves `name` alone.
*/
function bareBlockWrite(block: Parser.SyntaxNode, name: string): Binding {
const stmts = codeChildren(block);
const declIndex = stmts.findIndex(
(s) =>
(s.type === 'short_var_declaration' || s.type === 'var_declaration') &&
boundValue(s, name) !== undefined,
);
const outerRegion = declIndex < 0 ? stmts : stmts.slice(0, declIndex);
if (declIndex >= 0 && initializerWritesName(stmts[declIndex], name)) return CONFLICT;
if (outerRegion.some((stmt) => closureWritesName(stmt, name))) return CONFLICT;
for (const stmt of outerRegion.reverse()) {
if (stmt.type === 'assignment_statement') {
const value = boundValue(stmt, name);
if (value !== undefined) return value;
}
if (stmt.type === 'block') {
const inner = bareBlockWrite(stmt, name);
if (inner !== undefined) return inner;
continue;
}
if (writesNameInside(stmt, name)) return CONFLICT;
}
return undefined;
}
/** The identifier named `name` on an assignment side (or null). */
function identifierNamed(node: Parser.SyntaxNode | null, name: string): Parser.SyntaxNode | null {
if (!node) return null;
if (node.type === 'identifier') return node.text === name ? node : null;
return node.namedChildren.find((n) => n.type === 'identifier' && n.text === name) ?? null;
}
/** Whether an identifier or expression_list (e.g. a range left side) declares `name`. */
function declaresName(node: Parser.SyntaxNode | null, name: string): boolean {
return identifierNamed(node, name) !== null;
}
/**
* The value last bound to identifier `ident` before its use: the nearest
* binding site in the enclosing scopes, walking outward — preceding statements
* in blocks and switch/select cases (`expression_case`/`type_case`/
* `communication_case`/`default_case` act as statement containers), then
* statement-scoped bindings (`if`/`switch` initializers, `for` clauses
* including `range`, type-switch guards), up to the enclosing function
* declaration. Returns null when the name is a parameter, is bound without a
* value, is received from a channel by a select case head (the received value
* is statically unknown, so the walk stops instead of escaping to an outer
* group), or is not bound in scope.
*/
function findBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | typeof CONFLICT {
const binding = lookupBinding(ident);
return isParamBinding(binding) ? null : (binding ?? null);
}
/**
* findBinding's walk, keeping "declared without a traceable value" (null: a
* `func` literal parameter, `var x T`, a select receive) apart from "not
* declared before reaching the enclosing function declaration" (undefined).
* CONFLICT means a preceding statement writes the name in a nested scope, so
* the value at the use is control-flow dependent. A parameter or method
* receiver of the enclosing function returns its declaration (ParamBinding):
* no value, but a static type. `from` starts the walk at that node's parent
* instead of the identifier's, so an `=` range/receive target can be resolved
* outside its own clause.
*/
function lookupBinding(ident: Parser.SyntaxNode, from?: Parser.SyntaxNode | null): Binding {
const name = ident.text;
let child: Parser.SyntaxNode = from ?? ident;
for (let node = child.parent; node; child = node, node = node.parent) {
if (
node.type === 'function_declaration' ||
node.type === 'method_declaration' ||
node.type === 'func_literal'
) {
const param = paramDeclaring(node, name);
if (param) return { param };
if (node.type === 'func_literal') continue;
return undefined;
}
// Inside a grouped `var ( a = …; b = a.Group(…) )`, the specs before the
// one holding the use are already in scope; the current and later specs
// are not (`var g = g.Group(…)` reads the outer g).
if (node.type === 'var_spec_list') {
const specs = codeChildren(node);
const useIndex = specs.findIndex((s) => s.id === child.id);
for (const spec of specs.slice(0, useIndex).reverse()) {
const value = boundValue(spec, name);
if (value !== undefined) return value;
}
continue;
}
if (
node.type === 'block' ||
node.type === 'expression_case' ||
node.type === 'type_case' ||
node.type === 'communication_case' ||
node.type === 'default_case'
) {
// A select case head can rebind the name (`case g := <-ch:` or
// `case g = <-ch:`); the received value is statically unknown, so the
// walk must STOP with no traceable value — the same decline the
// ingestion-side route bindings record (value null) — instead of
// escaping to an outer group of the same name.
if (node.type === 'communication_case') {
for (const head of node.children) {
if (
head.type === 'receive_statement' &&
declaresName(head.childForFieldName('left'), name)
) {
return null;
}
}
}
const stmts = codeChildren(node);
const useIndex = stmts.findIndex((s) => s.id === child.id);
for (const stmt of stmts.slice(0, useIndex).reverse()) {
const value = boundValue(stmt, name);
if (value !== undefined) return value;
// A bare block always runs: its unconditional writes are bindings.
if (stmt.type === 'block') {
const written = bareBlockWrite(stmt, name);
if (written !== undefined) return written;
continue;
}
// A write nested in an earlier branch, loop, or closure may or may
// not run before the use: the reaching value is unprovable.
if (writesNameInside(stmt, name)) return CONFLICT;
}
continue;
}
// Statement-scoped bindings enclose the use the same way Go scopes them.
if (node.type === 'if_statement' || node.type === 'expression_switch_statement') {
// A use inside the initializer itself (`if g := g.Group(…); …`) reads
// the OUTER binding: the new one only scopes over what follows it.
const init = node.childForFieldName('initializer');
if (init && !within(init, ident)) {
const value = boundValue(init, name);
if (value !== undefined) return value;
}
continue;
}
if (node.type === 'for_statement') {
const clause = codeChildren(node)[0];
// A write in the loop's post statement, condition, or body runs between
// iterations, so from the second pass on the body sees that value
// instead of the one it entered with (`for ; c; g = r.Group("/post")`):
// the prefix is control-flow dependent, so decline it.
if (within(node.childForFieldName('body'), ident)) {
const loopParts = [
node.childForFieldName('body'),
clause?.type === 'for_clause' ? clause.childForFieldName('update') : null,
clause?.type === 'for_clause' ? clause.childForFieldName('condition') : null,
];
if (loopParts.some((part) => part && writesNameInside(part, name))) return CONFLICT;
}
if (clause?.type === 'for_clause') {
// Only the initializer binds before the body; an absent initializer
// (`for ; c; i++`) binds nothing.
const init = clause.childForFieldName('initializer');
if (init && !within(init, ident)) {
const value = boundValue(init, name);
if (value !== undefined) return value;
}
} else if (clause?.type === 'range_clause') {
if (
declaresName(clause.childForFieldName('left'), name) &&
!within(clause.childForFieldName('right'), ident)
) {
return clause.childForFieldName('right') ?? null;
}
}
continue;
}
if (node.type === 'type_switch_statement') {
// `switch g := x.(type)` — the guard list is the `alias` field, which
// only the `:=` form has (bare `switch x.(type)` parses with none),
// matching how the ingestion-side route-bindings read it. The switched
// value is the operand right after the guard list.
const guard = node.childForFieldName('alias');
const switched = codeChildren(node)[1] ?? null;
if (
guard?.type === 'expression_list' &&
declaresName(guard, name) &&
!within(switched, ident)
) {
return switched;
}
continue;
}
}
return undefined;
}
/**
* Whether a mixed-import file's route receiver traces back to echo's
* constructor — `e := echo.New()` / `echo.Default()` with `echo` resolving to
* one of the file's verified echo import aliases — either directly or through
* enclosing `Group(...)` calls (`users := api.Group(…)` ← `api := e.Group(…)`
* ← `echo.New()`), the normal shape of grouped routes (review #7, #10).
* A parameter or method receiver counts by its declared type instead:
* `e *echo.Echo` / `g *echo.Group` (with `echo` one of those aliases) proves
* echo; any other type — gin's, or one the file cannot tie to echo — does not.
* Unrelated packages' `New()`, a local that shadows the echo import name, and
* anything else return false so the caller keeps the conservative
* last-argument fallback instead of guessing. Returns null when the Group chain exceeds MAX_GROUP_DEPTH or a
* binding on it is control-flow dependent (CONFLICT): the framework is then
* unprovable either way, so the caller declines the route.
*/
function receiverBindsToEchoConstructor(
receiver: Parser.SyntaxNode,
echoAliases: ReadonlySet<string>,
depth = 0,
): boolean | null {
if (depth > MAX_GROUP_DEPTH) return null;
// An identifier resolves through its binding; a chained `X.Group(…).Group(…)`
// operand is already a call and is inspected as-is.
const value = receiver.type === 'identifier' ? lookupBinding(receiver) : receiver;
if (value === CONFLICT) return null;
if (isParamBinding(value))
return isEchoRouterType(value.param.childForFieldName('type'), echoAliases);
if (value?.type !== 'call_expression') return false;
const fn = value.childForFieldName('function');
if (fn?.type !== 'selector_expression') return false;
const field = fn.childForFieldName('field')?.text;
const operand = fn.childForFieldName('operand');
if (!operand) return false;
if (field === 'New' || field === 'Default') {
return operand.type === 'identifier' && echoAliases.has(operand.text) && !isLocalName(operand);
}
if (field === 'Group') {
return receiverBindsToEchoConstructor(operand, echoAliases, depth + 1);
}
return false;
}
/** `*echo.Echo` / `echo.Echo` / `*echo.Group` with `echo` a verified echo import alias. */
function isEchoRouterType(
type: Parser.SyntaxNode | null,
echoAliases: ReadonlySet<string>,
): boolean {
const named = type?.type === 'pointer_type' ? codeChildren(type)[0] : type;
if (named?.type !== 'qualified_type') return false;
const pkg = named.childForFieldName('package')?.text;
const typeName = named.childForFieldName('name')?.text;
return !!pkg && echoAliases.has(pkg) && (typeName === 'Echo' || typeName === 'Group');
}
/**
* Whether `ident` names a local value rather than an imported package: a
* declaration in scope (with or without a value — `var echo Factory` shadows
* too) or a parameter/receiver of an enclosing function. Go lets either
* shadow a package qualifier (`func f(echo *Factory) { echo.New() }`).
*/
function isLocalName(ident: Parser.SyntaxNode): boolean {
// lookupBinding covers parameters and receivers too (ParamBinding).
return lookupBinding(ident) !== undefined;
}
/**
* Joined `Group(...)` prefix of a route receiver; '' when it cannot be traced.
* Returns null when the chain exceeds MAX_GROUP_DEPTH, or when a binding on it
* is control-flow dependent (CONFLICT): a partial or stale prefix would emit
* a wrong path, so the caller declines the route instead.
*/
function groupPrefix(receiver: Parser.SyntaxNode, depth = 0): string | null {
if (depth > MAX_GROUP_DEPTH) return null;
const value = receiver.type === 'identifier' ? findBinding(receiver) : receiver;
if (value === CONFLICT) return null;
const group = value ? asGroupCall(value) : null;
if (!group) return '';
const outer = groupPrefix(group.parent, depth + 1);
return outer === null ? null : joinRoutePath(outer, group.prefix);
}
// ─── Provider: net/http `http.HandleFunc("/p", handler)` ─────────────
const HANDLE_FUNC_PATTERNS = compilePatterns({
name: 'go-handle-func',
@ -135,27 +767,87 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
scan(tree) {
const out: HttpDetection[] = [];
// Framework providers: r.GET/POST/... with handler identifier
// Framework providers: r.GET/POST/... on an engine or (nested) route group
const imports = readFrameworkImports(tree.rootNode);
const echoOnly = imports.echo.size > 0 && imports.gin.size === 0;
const mixed = imports.echo.size > 0 && imports.gin.size > 0;
// Handler names declared more than once in this file (`(h *A) List` and
// `(o *B) List`): the emitted name is field-only, so these must resolve
// only when unique in the file instead of taking the first same-named row.
const declaredNames = new Map<string, number>();
for (const decl of tree.rootNode.descendantsOfType([
'function_declaration',
'method_declaration',
])) {
const declName = decl.childForFieldName('name')?.text;
if (declName) declaredNames.set(declName, (declaredNames.get(declName) ?? 0) + 1);
}
for (const match of runCompiledPatterns(FRAMEWORK_ROUTE_PATTERNS, tree)) {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
const handlerNode = match.captures.handler;
const receiverNode = match.captures.receiver;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
const literalPath = stringLiteral(pathNode);
if (literalPath === null) continue;
const argList = pathNode.parent;
if (argList?.type !== 'argument_list') continue;
const args = codeChildren(argList);
if (args[0]?.id !== pathNode.id) continue;
// The path is the first code argument, so everything after it is a handler or
// middleware candidate: echo's verb calls take the FIRST of those, gin's
// the LAST (see FRAMEWORK_ROUTE_PATTERNS / readFrameworkImports). The
// rule is chosen per call: an echo-only file is unambiguous; a
// mixed-import file takes the first argument only when the receiver
// provably traces to echo's constructor (directly or through enclosing
// Group() calls) — everything else keeps the last-argument anchor,
// gin's order and the safer default when the file proves nothing.
const rest = args.slice(1);
if (rest.length === 0) continue;
const echoOrder = mixed
? receiverNode
? receiverBindsToEchoConstructor(receiverNode, imports.echo)
: false
: echoOnly;
// A Group chain deeper than MAX_GROUP_DEPTH proves neither the full
// prefix nor the framework order: decline rather than emit a guess.
if (echoOrder === null) continue;
const prefix = receiverNode ? groupPrefix(receiverNode) : '';
if (prefix === null) continue;
const handlerNode = echoOrder ? rest[0] : rest[rest.length - 1];
if (!HANDLER_ARG_TYPES.has(handlerNode.type)) continue;
const path = receiverNode ? joinRoutePath(prefix, literalPath) : literalPath;
// An inline `func(){…}` handler has no name → emit `name: null` and a
// `line` so it resolves to its containing/closure symbol by line-span
// containment (like a consumer). A named identifier handler keeps its
// name and resolves by name; `line` is harmless there.
// containment (like a consumer). A named handler keeps its name and
// resolves by name; `line` is harmless there. For a method value or a
// package-qualified function (`h.List`, `pkg.List`) that name is the
// field, and the operand (usually a local variable, not the receiver
// type) does not prove where `List` is declared — so the detection is
// marked qualifiedHandler: resolve only to a repo-wide unique `List`,
// never to a same-named local method that merely shares the name.
const isInlineHandler = handlerNode?.type === 'func_literal';
const isQualified = handlerNode?.type === 'selector_expression';
const handlerName = isQualified
? (handlerNode.childForFieldName('field')?.text ?? null)
: (handlerNode?.text ?? null);
out.push({
role: 'provider',
framework: 'go-framework',
method: methodNode.text.toUpperCase(),
path,
name: isInlineHandler ? null : (handlerNode?.text ?? null),
name: isInlineHandler ? null : handlerName,
line: (handlerNode ?? pathNode).startPosition.row + 1,
confidence: 0.8,
...(isQualified ? { qualifiedHandler: true } : {}),
// A bare name declared more than once in this file (a function and a
// method of the same name) resolves only when the file holds exactly
// one match, rather than binding to whichever row the graph lists first.
...(!isQualified &&
!isInlineHandler &&
handlerName &&
(declaredNames.get(handlerName) ?? 0) > 1
? { strictHandlerResolution: true }
: {}),
});
}
@ -164,7 +856,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
const pathNode = match.captures.path;
const handlerNode = match.captures.handler;
if (!pathNode) continue;
const path = unquoteLiteral(pathNode.text);
const path = stringLiteral(pathNode);
if (path === null) continue;
// Inline `func(){…}` handler → resolve by containment (see go-framework
// note above); a named handler resolves by name.
@ -187,7 +879,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
if (!fnNode || !pathNode) continue;
const httpMethod = HTTP_CLIENT_METHOD_TO_HTTP[fnNode.text];
if (!httpMethod) continue;
const path = unquoteLiteral(pathNode.text);
const path = stringLiteral(pathNode);
if (path === null) continue;
out.push({
role: 'consumer',
@ -205,8 +897,8 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const method = unquoteLiteral(methodNode.text);
const path = unquoteLiteral(pathNode.text);
const method = stringLiteral(methodNode);
const path = stringLiteral(pathNode);
if (method === null || path === null) continue;
out.push({
role: 'consumer',
@ -224,7 +916,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
const path = stringLiteral(pathNode);
if (path === null) continue;
out.push({
role: 'consumer',

View file

@ -58,6 +58,14 @@ export interface HttpDetection {
handlerImport?: { name: string; module: string };
/** Resolve only from the registration file or exact import target; never guess repo-wide. */
strictHandlerResolution?: boolean;
/**
* The handler was designated through a qualifier the plugin cannot tie to a
* declaration (`recv.name`, `pkg.name`): `name` alone does not prove the
* handler lives in the registration file. Skip the file-scoped name lookup
* — a same-named but unrelated local symbol would win it — and accept only
* a repo-wide unique match, else keep the file-level fallback.
*/
qualifiedHandler?: boolean;
/**
* The plugin saw a provider handler designator but could not prove its owner.
* Prevents the orchestrator from treating it as an anonymous inline handler

View file

@ -629,11 +629,17 @@ export class HttpRouteExtractor implements ContractExtractor {
if (d.strictHandlerResolution) return null;
return resolveSymbolByNameUnique(d.handlerImport.name);
}
const byName = d.strictHandlerResolution
? resolveFileSymbolByNameUnique(syms, d.name)
: resolveSymbolByName(syms, d.name);
if (byName) return byName;
if (d.strictHandlerResolution) return null;
// A qualified designator (`recv.name`) does not prove the handler is
// declared in this file, so the file-first rung could bind an
// unrelated same-named local symbol: go straight to the unique
// repo-wide match.
if (!d.qualifiedHandler) {
const byName = d.strictHandlerResolution
? resolveFileSymbolByNameUnique(syms, d.name)
: resolveSymbolByName(syms, d.name);
if (byName) return byName;
if (d.strictHandlerResolution) return null;
}
const byGlobal = await resolveSymbolByNameUnique(d.name);
if (byGlobal) return byGlobal;
// A NAMED handler we could not resolve by name (neither file-scoped nor

View file

@ -29,12 +29,9 @@ const _require = createRequire(import.meta.url);
/** Graphology Graph instance type (AbstractGraph from graphology-types avoids CJS/ESM interop namespace issue) */
type GraphInstance = AbstractGraph<Attributes, Attributes, Attributes>;
const leiden: LeidenModule = _require(leidenPath);
/** Vendored Leiden algorithm module shape */
interface LeidenModule {
detailed: (graph: GraphInstance, options: Record<string, unknown>) => LeidenDetailedResult;
}
// The Leiden worker loads its own copy; this load keeps a missing vendor/ asset
// failing at import time and keeps it visible to dockerfile-runtime-asset-parity.
_require(leidenPath);
/** Result returned by leiden.detailed() */
interface LeidenDetailedResult {
@ -110,15 +107,6 @@ interface IcebugWorkerFailure {
* incremental-indexing equivalence test (incremental ≡ full rebuild).
*/
const LEIDEN_SEED = 0xc0de;
function createSeededRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const COMMUNITY_ENGINE_ENV = 'GITNEXUS_COMMUNITY_ENGINE';
/**
@ -474,28 +462,101 @@ const runCommunityEngine = async (
35,
);
const fallback = await runGraphologyLeiden(graph, projection.isLarge, engineRequested);
return { ...fallback, fallbackReason };
return {
...fallback,
fallbackReason: fallback.fallbackReason
? `${fallbackReason}; ${fallback.fallbackReason}`
: fallbackReason,
};
}
};
const runGraphologyLeiden = async (
/**
* Runs the vendored Leiden in a worker so LEIDEN_TIMEOUT_MS can actually fire.
* leiden.detailed is synchronous and, on some graphs, never returns
* (graphology/graphology#557); a timer on the main thread cannot interrupt it.
* The worker is pure JS, so terminate() is safe (unlike the icebug N-API worker).
*/
const GRAPHOLOGY_WORKER_SOURCE = `
const { parentPort, workerData } = require('node:worker_threads');
try {
const Graph = require(workerData.graphologyPath);
const leiden = require(workerData.leidenPath);
const graph = Graph.from(workerData.graph);
const rng = (() => {
let s = workerData.seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
})();
const d = leiden.detailed(graph, {
resolution: workerData.resolution,
maxIterations: workerData.maxIterations,
rng,
});
parentPort.postMessage({
ok: true,
communities: d.communities,
count: d.count,
modularity: d.modularity,
});
} catch (error) {
parentPort.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
`;
export const runGraphologyLeiden = async (
graph: GraphInstance,
isLarge: boolean,
engineRequested: CommunityDetectionEngine,
timeoutMs: number = LEIDEN_TIMEOUT_MS,
): Promise<CommunityEngineResult> => {
try {
const details = await Promise.race([
Promise.resolve(
leiden.detailed(graph, {
const details = await new Promise<LeidenDetailedResult>((resolve, reject) => {
const worker = new Worker(GRAPHOLOGY_WORKER_SOURCE, {
eval: true,
workerData: {
graphologyPath: _require.resolve('graphology'),
leidenPath,
graph: graph.export(),
seed: LEIDEN_SEED,
resolution: isLarge ? 2.0 : 1.0,
maxIterations: isLarge ? 3 : 0,
rng: createSeededRng(LEIDEN_SEED),
}),
),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Leiden timeout')), LEIDEN_TIMEOUT_MS),
),
]);
},
});
let settled = false;
const timeout = setTimeout(() => {
settled = true;
void worker.terminate();
reject(new Error('Leiden timeout'));
}, timeoutMs);
worker.once('message', (message) => {
settled = true;
clearTimeout(timeout);
if (message.ok === true) {
resolve({
communities: message.communities,
count: message.count,
modularity: message.modularity,
});
} else {
reject(new Error(message.error));
}
});
worker.once('error', (error) => {
settled = true;
clearTimeout(timeout);
reject(error);
});
worker.once('exit', (code) => {
if (settled) return;
clearTimeout(timeout);
reject(new Error(`Graphology Leiden worker exited with code ${code}`));
});
});
return { ...details, engine: 'graphology', engineRequested };
} catch (e: any) {
if (e.message !== 'Leiden timeout') {

View file

@ -39,6 +39,11 @@ export interface DartPackageConfigOptions {
* is listed and before its entries are opened.
*/
readonly beforeEntryOpen?: (relativePath: string) => void | Promise<void>;
/**
* Test seam. Production calls omit it. Invoked after the directory is
* opened and before it is listed.
*/
readonly beforeDirectoryList?: (relativePath: string) => void | Promise<void>;
}
type ManifestRead =
@ -123,12 +128,13 @@ function directoryIdentity(stat: BigIntStats): string {
/**
* Path that lists the directory inode already open on `fd`.
* Linux uses `/proc/self/fd/N` and macOS uses `/dev/fd/N`.
* Other platforms have no such path; callers refuse instead of listing by name.
* Only Linux has one: `/proc/self/fd/N`. macOS refuses `opendir` on
* `/dev/fd/N` for a directory (ENOTDIR) and Node has no `fdopendir`, so the
* walker lists the lexical path and verifies the pinned chain around it.
* Every other platform returns null and is not walked.
*/
export function descriptorDirectoryPath(fd: number): string | null {
if (process.platform === 'linux') return `/proc/self/fd/${fd}`;
if (process.platform === 'darwin') return `/dev/fd/${fd}`;
return null;
}
@ -264,11 +270,33 @@ async function openChildFile(
throw Object.assign(new Error('no descriptor anchor'), { code: 'ENOTSUP' });
}
async function listOpenedDirectory(handle: FileHandle, entryLimit: number): Promise<Dirent[]> {
const listing = descriptorDirectoryPath(handle.fd);
if (listing === null) {
/**
* List the directory open on the last frame of `frames`. Linux lists the
* pinned inode through its descriptor. macOS re-checks the pinned chain, lists
* the lexical path, then re-checks the chain, so a path replaced around the
* listing fails the walk instead of being listed.
*/
async function listOpenedDirectory(
frames: readonly WalkFrame[],
entryLimit: number,
beforeList?: () => void | Promise<void>,
): Promise<Dirent[]> {
const frame = frames[frames.length - 1];
if (frame === undefined) {
throw Object.assign(new Error('no directory to list'), { code: 'EINVAL' });
}
const anchored = descriptorDirectoryPath(frame.handle.fd);
if (anchored === null && process.platform !== 'darwin') {
throw Object.assign(new Error('no descriptor listing'), { code: 'ENOTSUP' });
}
if (anchored === null) await assertPinnedChain(frames);
if (beforeList) await beforeList();
const entries = await readDirectoryBounded(anchored ?? frame.absolute, entryLimit);
if (anchored === null) await assertPinnedChain(frames);
return entries;
}
async function readDirectoryBounded(listing: string, entryLimit: number): Promise<Dirent[]> {
const dir = await opendir(listing);
const entries: Dirent[] = [];
try {
@ -295,8 +323,16 @@ async function listOpenedDirectory(handle: FileHandle, entryLimit: number): Prom
*/
export async function readDirectoryNoFollow(directory: string): Promise<Dirent[]> {
const opened = await openVerifiedDirectory(directory);
const frame: WalkFrame = {
relative: '',
absolute: directory,
handle: opened.handle,
identity: opened.identity,
entries: [],
next: 0,
};
try {
return await listOpenedDirectory(opened.handle, DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT);
return await listOpenedDirectory([frame], DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT);
} finally {
await opened.handle.close();
}
@ -345,7 +381,12 @@ export async function loadDartPackageConfig(
const fillFrame = async (frame: WalkFrame): Promise<void> => {
if (++visited > directoryLimit) return incomplete('directory-limit', frame.relative || '.');
try {
frame.entries = await listOpenedDirectory(frame.handle, directoryEntryLimit);
const beforeList = options?.beforeDirectoryList;
frame.entries = await listOpenedDirectory(
stack,
directoryEntryLimit,
beforeList ? () => beforeList(frame.relative) : undefined,
);
} catch (error) {
const reason =
(error as NodeJS.ErrnoException).code === 'E2BIG' ? 'directory-entries' : 'read-directory';

View file

@ -26,6 +26,7 @@
import type Parser from 'tree-sitter';
import { goImportPackageName } from '../languages/go/import-package-name.js';
import { GoRouteBindings, type GoRouteBinding } from '../languages/go/route-bindings.js';
import { isEchoImportPath, isGinImportPath, stringLiteral } from './go-shared.js';
import { normalizeExtractedRoutePath } from './route-path.js';
import type { SyntaxNode } from 'tree-sitter';
import type { ExtractedDecoratorRoute, RouteHandlerReceiver } from '../workers/parse-worker.js';
@ -58,59 +59,6 @@ interface Framework {
const FUNCTION_TYPE_LIST = ['function_declaration', 'method_declaration', 'func_literal'];
const FUNCTION_TYPES: ReadonlySet<string> = new Set(FUNCTION_TYPE_LIST);
function stringLiteral(node: SyntaxNode | null | undefined): string | null {
if (!node || node.hasError) return null;
const body = node.text.slice(1, -1);
// Go discards carriage returns in raw strings, including CRLF source files.
if (node.type === 'raw_string_literal') return body.replace(/\r/g, '');
if (node.type !== 'interpreted_string_literal') return null;
if (!body.includes('\\')) return body;
const simple: Readonly<Record<string, string>> = {
a: '\x07',
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
v: '\v',
'\\': '\\',
'"': '"',
};
const chunks: Buffer[] = [];
const tokens =
/\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g;
let consumed = 0;
for (const match of body.matchAll(tokens)) {
if (match.index !== consumed) return null;
const token = match[0];
consumed += token.length;
if (!token.startsWith('\\')) {
chunks.push(Buffer.from(token));
} else if (simple[token[1]] !== undefined) {
chunks.push(Buffer.from(simple[token[1]]));
} else {
const octal = /[0-7]/.test(token[1]);
const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16);
if (octal || token[1] === 'x') {
// Octal and hex escapes encode bytes, not Unicode code points.
if (value > 255) return null;
chunks.push(Buffer.from([value]));
} else {
if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null;
chunks.push(Buffer.from(String.fromCodePoint(value)));
}
}
}
if (consumed !== body.length) return null;
try {
// Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL.
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
} catch {
return null;
}
}
/** The framework this file routes with, when exactly one is imported. */
function readImports(root: SyntaxNode): {
readonly framework: Framework | null;
@ -129,8 +77,8 @@ function readImports(root: SyntaxNode): {
if (explicit === '_' || explicit === '.') continue;
const local = explicit ?? goImportPackageName(importPath);
if (!local) continue;
if (importPath === 'github.com/gin-gonic/gin') gin = local;
if (/^github\.com\/labstack\/echo(\/v\d+)?$/.test(importPath)) echo = local;
if (isGinImportPath(importPath)) gin = local;
if (isEchoImportPath(importPath)) echo = local;
}
// Both, or neither: no way to tell which argument is the handler.
if (gin !== null && echo === null) {

View file

@ -0,0 +1,89 @@
import type { SyntaxNode } from 'tree-sitter';
/**
* Framework import paths, matched EXACTLY on both layers (ingestion's
* `go-gin-echo.ts` and the group-mode `http-patterns/go.ts`): a substring
* test would classify `example.com/labstack/echo-wrapper` as echo on one
* side only, flipping the handler-argument order and splitting contracts.
*/
export function isGinImportPath(importPath: string): boolean {
return importPath === 'github.com/gin-gonic/gin';
}
export function isEchoImportPath(importPath: string): boolean {
return /^github\.com\/labstack\/echo(\/v\d+)?$/.test(importPath);
}
/**
* Go string-literal decoding shared by the Go route extractors on both
* layers: the ingestion extractor (`go-gin-echo.ts`, Strategy A) and the
* group-mode HTTP plugin (`group/extractors/http-patterns/go.ts`,
* Strategy B). Both sides must produce the SAME text for a route literal,
* or the same route lands under two different contract ids that never
* collide in the merge — a wrong-path duplicate that survives alongside
* the graph's correct entry.
*
* Go's semantics, per `strconv.Unquote`:
* - interpreted (`"…"`) strings decode escapes (`\n`, `\x2f`, `ሴ`,
* `\101`, …); hex and octal escapes encode BYTES, not code points;
* - raw (`` `…` ``) strings have no escapes (a backslash is literal),
* and Go discards carriage returns in them, including CRLF source files;
* - a string that cannot be decoded to valid UTF-8 text (invalid escape,
* non-UTF-8 bytes) returns null — callers decline instead of guessing,
* since a URL cannot carry those bytes losslessly.
*
* Nodes that are not string literals (an identifier prefix, a rune
* literal, an errored subtree) also return null.
*/
export function stringLiteral(node: SyntaxNode | null | undefined): string | null {
if (!node || node.hasError) return null;
const body = node.text.slice(1, -1);
// Go discards carriage returns in raw strings, including CRLF source files.
if (node.type === 'raw_string_literal') return body.replace(/\r/g, '');
if (node.type !== 'interpreted_string_literal') return null;
if (!body.includes('\\')) return body;
const simple: Readonly<Record<string, string>> = {
a: '\x07',
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
v: '\v',
'\\': '\\',
'"': '"',
};
const chunks: Buffer[] = [];
const tokens =
/\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g;
let consumed = 0;
for (const match of body.matchAll(tokens)) {
if (match.index !== consumed) return null;
const token = match[0];
consumed += token.length;
if (!token.startsWith('\\')) {
chunks.push(Buffer.from(token));
} else if (simple[token[1]] !== undefined) {
chunks.push(Buffer.from(simple[token[1]]));
} else {
const octal = /[0-7]/.test(token[1]);
const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16);
if (octal || token[1] === 'x') {
// Octal and hex escapes encode bytes, not Unicode code points.
if (value > 255) return null;
chunks.push(Buffer.from([value]));
} else {
if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null;
chunks.push(Buffer.from(String.fromCodePoint(value)));
}
}
}
if (consumed !== body.length) return null;
try {
// Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL.
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
} catch {
return null;
}
}

View file

@ -36,7 +36,12 @@ import fs from 'fs/promises';
import { constants as fsConstants, existsSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { retryRename } from '../storage/fs-atomic.js';
import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
import {
acquireIndexLock,
requireExclusiveIndexLock,
sweepStagingArtifacts,
} from '../storage/index-lock.js';
import { resolveEmbeddingRecovery } from '../storage/embedding-recovery.js';
import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/node-workspace-packages.js';
import {
logNameFallbackSummary,
@ -1287,6 +1292,8 @@ export async function runFullAnalysis(
const log = (msg: string) => callbacks.onLog?.(stripControlCharacters(msg));
const acquireOpts = {
log,
// Resolve and validate the canonical slot under the lock before cleanup.
sweep: false,
onWaitStart: () =>
callbacks.onProgress('lock', 0, 'Waiting for another analyze to finish on this index…'),
};
@ -1345,6 +1352,7 @@ export async function runFullAnalysis(
}
const flatShared = writeTarget.placement.branch ? undefined : writeTarget.sharedStore;
if (flatShared) await seedSharedSlot(flatShared, repoPath, log);
sweepStagingArtifacts(writeTarget.metaDir, log);
const slotToLeave = options.noShare ? await optedInSlotToLeave(repoPath) : undefined;
const result = await runFullAnalysisInner(
repoPath,
@ -1720,20 +1728,33 @@ async function runFullAnalysisInner(
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
: undefined,
});
const missing = await verifySearchFTSIndexes(executeQuery, ftsIndexes);
if (missing.length > 0) {
// #2889: name WHY each index is missing when the build itself said so.
// Repair now rebuilds every table it can before reporting, so the tables
// absent from this list were genuinely repaired even on a failed run —
// previously the first failure aborted the sweep and the message could
// only ever list "missing", never a reason. Same sentence the analyze
// degrade path prints, so one failure does not read two ways.
const reasons =
const catalogDiagnostics: string[] = [];
let missing: string[] = [];
let catalogError: string | undefined;
try {
missing = await verifySearchFTSIndexes(executeQuery, ftsIndexes, (diagnostic) => {
catalogDiagnostics.push(diagnostic.message);
});
} catch (error) {
// An unreadable catalog is unknown, not proof of missing indexes.
// Keep earlier native build errors when this read also fails.
catalogError = error instanceof Error ? error.message : String(error);
}
if (repairFailures.length > 0 || missing.length > 0 || catalogError !== undefined) {
// A failed DROP may leave a valid old catalog entry. Its presence must
// not certify that this repair succeeded or clear the recovery marker.
const reasons = [
missing.length > 0 ? `missing indexes after rebuild: ${missing.join(', ')}` : '',
repairFailures.length > 0
? ` ${summarizeFtsIndexBuildFailures(repairFailures, ftsIndexes)}.`
: '';
? summarizeFtsIndexBuildFailures(repairFailures, ftsIndexes)
: missing.length > 0
? 'no build error was returned'
: '',
...catalogDiagnostics,
catalogError !== undefined ? `catalog verification failed: ${catalogError}` : '',
].filter(Boolean);
throw new Error(
`FTS repair failed - missing indexes after rebuild: ${missing.join(', ')}.${reasons} ` +
`FTS repair failed - ${reasons.join('. ')}. ` +
'Run `gitnexus analyze --force` to perform a full graph+FTS rebuild; ' +
'if that also fails, verify FTS extension availability via `gitnexus doctor`.',
);
@ -1842,7 +1863,13 @@ async function runFullAnalysisInner(
decision = decideEmbeddingResume(checkpoint, embeddingIdentityForRun, resumeOptions);
}
if (decision.action === 'abort') throw new Error(decision.error);
log(decision.log);
log(
decision.action === 'resume' && checkpoint.recovery
? `Previous analyze recorded an embedding checkpoint (${checkpoint.nodesProcessed}/` +
`${checkpoint.totalNodes} nodes); validating staged vectors before retrying ` +
`${decision.pendingNodeIds.size} pending node(s).`
: decision.log,
);
if (options.dropEmbeddings) {
// --drop-embeddings has always implied a rebuild here; the decision only
// covers the marker.
@ -2671,6 +2698,74 @@ async function runFullAnalysisInner(
}
}
// A checkpoint's pending decision and its paid, complete vectors are
// independent: --force discards the former but can still reuse the latter.
// Select only the explicitly referenced generation, never an orphan by age.
const stagedRecovery = resolveEmbeddingRecovery(metaDir, existingMeta?.embeddingCheckpoint);
const stagedCheckpoint = existingMeta?.embeddingCheckpoint;
const inheritedUnsafeNodeIds = new Set([
...pendingEmbeddingNodeIds,
...(stagedRecovery?.unsafeNodeIds ?? []),
]);
if (shouldLoadCache && !options.dropEmbeddings && stagedRecovery && stagedCheckpoint) {
if (!embeddingIdentityForRun) {
const { resolveEmbeddingIdentity } = await import('./embeddings/embedding-identity.js');
embeddingIdentityForRun = resolveEmbeddingIdentity();
}
const marker = stagedCheckpoint;
const matchesIdentity =
marker.model === embeddingIdentityForRun.model &&
marker.dimensions === embeddingIdentityForRun.dimensions &&
marker.provider === embeddingIdentityForRun.provider;
if (matchesIdentity && stagedRecovery.schemaFingerprint === SCHEMA_FINGERPRINT) {
// A force-discarded pending decision must not turn a known incomplete
// inherited group into a reusable cache merely because its hash matches.
if (inheritedUnsafeNodeIds.size > 0) {
const rows = cachedSnapshot.rows.filter((row) => !inheritedUnsafeNodeIds.has(row.nodeId));
cachedSnapshot = {
...cachedSnapshot,
rows,
embeddingNodeIds: new Set(rows.map((row) => row.nodeId)),
};
}
let recovered: CachedEmbeddingsSnapshot | undefined;
try {
const { recoverStagedEmbeddings, mergeRecoveredEmbeddings } =
await import('./embeddings/staged-embedding-recovery.js');
recovered = await recoverStagedEmbeddings(stagedRecovery.dbPath, {
dimensions: embeddingIdentityForRun.dimensions,
excludedNodeIds: stagedRecovery.unsafeNodeIds,
});
const liveCacheDims = snapshotEmbeddingDims(cachedSnapshot);
if (liveCacheDims !== undefined && liveCacheDims !== embeddingIdentityForRun.dimensions) {
log(
`Embedding dimensions changed (${liveCacheDims}d -> ` +
`${embeddingIdentityForRun.dimensions}d), discarding published cache`,
);
discardCachedEmbeddings();
}
if (recovered.rows.length > 0) {
const merged = mergeRecoveredEmbeddings(cachedSnapshot, recovered);
disposeEmbeddingSpill(cachedSnapshot.spill);
adoptCachedEmbeddings(merged);
}
log(
`Recovered ${recovered.rows.length} complete staged embedding chunk(s) ` +
`for ${recovered.embeddingNodeIds.size} node(s); unchanged content can reuse them.`,
);
} catch (err) {
log(
`Warning: could not recover staged embeddings (${(err as Error).message}); ` +
'the retry will regenerate missing chunks.',
);
} finally {
disposeEmbeddingSpill(recovered?.spill);
}
} else {
log('Staged embedding identity or schema changed; its vectors will not be reused.');
}
}
// ── Load incremental parse cache ──────────────────────────────────
// Content-addressed: `--force` reuses parser shards; `useParseCache: false`
// stages a new generation under a run-unique parse-rebuild.* dir and publishes
@ -4487,6 +4582,16 @@ async function runFullAnalysisInner(
embeddingIdentityForRun = resolveEmbeddingIdentity();
}
const embeddingIdentity = embeddingIdentityForRun;
const stagedRecoveryEnabled = useAtomicSwap && isManualCheckpointEnabled();
if (useAtomicSwap && !stagedRecoveryEnabled) {
log(
'Manual WAL checkpoints are disabled; new staged work cannot be recovered after interruption. ' +
'Any previous durable recovery source is retained until publication.',
);
}
const unsafeRecoveryNodeIds = new Set([...inheritedUnsafeNodeIds, ...restoreFailedNodeIds]);
let activeWindowNodeIds: string[] = [];
let recoveryGenerationDurable = false;
// Build a Map<nodeId, contentHash> from cached embeddings for incremental mode
let existingEmbeddings: Map<string, string> | undefined;
if (cachedSnapshot.embeddingNodeIds.size > 0) {
@ -4540,6 +4645,14 @@ async function runFullAnalysisInner(
stagedCheckpointEmbeddingCount = embeddings;
}
const latestMeta = (await loadMeta(metaDir)) ?? existingMeta;
// An in-place write or manual-checkpoint opt-out cannot create a new
// recoverable staged generation. Keep the complete previous receipt:
// updated progress or unsafe nodes would describe different source bytes.
const preservedRecoveryCheckpoint =
!stagedRecoveryEnabled &&
resolveEmbeddingRecovery(metaDir, latestMeta?.embeddingCheckpoint)
? latestMeta?.embeddingCheckpoint
: undefined;
// First-ever analyze of this repo: no meta exists on disk yet (the
// pre-wipe dirty stamp only fires when one does). Mint the minimum
// RepoMeta requires, with `lastCommit: ''` — never `currentCommit` —
@ -4550,6 +4663,11 @@ async function runFullAnalysisInner(
lastCommit: '',
indexedAt: new Date().toISOString(),
};
const interrupted = mintInterruptedCheckpoint(
embeddingIdentity,
checkpoint,
pendingNodeIds,
);
await saveMeta(metaDir, {
...base,
...(embeddings === undefined || buildPath !== lbugPath
@ -4557,11 +4675,18 @@ async function runFullAnalysisInner(
: { stats: { ...base.stats, embeddings } }),
// Written by a run that is still IN FLIGHT — see the `kind` doc in
// repo-manager.ts.
embeddingCheckpoint: mintInterruptedCheckpoint(
embeddingIdentity,
checkpoint,
pendingNodeIds,
),
embeddingCheckpoint: preservedRecoveryCheckpoint ?? {
...interrupted,
...(stagedRecoveryEnabled
? {
recovery: {
stagingFile: path.basename(buildPath),
schemaFingerprint: SCHEMA_FINGERPRINT,
unsafeNodeIds: [...unsafeRecoveryNodeIds],
},
}
: {}),
},
});
};
@ -4584,7 +4709,21 @@ async function runFullAnalysisInner(
{
forceReembedNodeIds: pendingEmbeddingNodeIds,
onCheckpointWindowStart: async ({ nodeIds, ...checkpoint }) => {
const handoff = stagedRecoveryEnabled && !recoveryGenerationDurable;
if (handoff) {
if (!(await checkpointOnce())) {
throw new Error(
'Could not checkpoint restored embeddings before recovery handoff.',
);
}
recoveryGenerationDurable = true;
}
activeWindowNodeIds = nodeIds;
for (const id of nodeIds) unsafeRecoveryNodeIds.add(id);
await saveEmbeddingCheckpoint(checkpoint, nodeIds);
// Reclaim the old source only after the new durable generation's
// reference is saved. Later windows retain this same generation.
if (handoff) sweepStagingArtifacts(metaDir, log);
},
// ── The mid-run count is a DIAGNOSTIC, not a gate (#2790) ──────
// This used to run the count query bare. THIS callback's rejection
@ -4598,7 +4737,12 @@ async function runFullAnalysisInner(
// touch stats.embeddings" signal — so the checkpoint still lands,
// with whatever count is already on disk left alone.
onCheckpoint: async (checkpoint) => {
await checkpointOnce();
const durable = await checkpointOnce();
if (stagedRecoveryEnabled && !durable) {
throw new Error('Could not checkpoint the completed embedding window for recovery.');
}
for (const id of activeWindowNodeIds) unsafeRecoveryNodeIds.delete(id);
activeWindowNodeIds = [];
const measured = await measurePersistedEmbeddingCount(executeQuery);
if (measured.kind === 'unknown') {
log(
@ -5085,6 +5229,7 @@ async function runFullAnalysisInner(
// is a crash-safety improvement: a failed swap leaves the previous index
// live and the next run recovers via the full-rebuild path.
await saveMeta(metaDir, meta);
sweepStagingArtifacts(metaDir, log);
// Registry freshness is published only after the graph and its metadata.
// A failed close, swap, or metadata save must leave the previous registry
@ -5294,7 +5439,13 @@ async function runFullAnalysisInner(
// rethrow below is the surface, and the lock's sweep remains the backstop.
if (useAtomicSwap && buildPath !== lbugPath) {
try {
await wipeLbugDbFiles(buildPath);
const recovery = resolveEmbeddingRecovery(
metaDir,
(await loadMeta(metaDir))?.embeddingCheckpoint,
);
// Both paths belong to this locked slot. The validated generation
// basename identifies the same file even through a directory alias.
if (recovery?.stagingFile !== path.basename(buildPath)) await wipeLbugDbFiles(buildPath);
} catch {
/* swallow — orphan reclamation must never mask the real failure */
}
@ -5306,6 +5457,12 @@ async function runFullAnalysisInner(
// IndexLockTimeoutError and other domain failures with `instanceof`.
recordLiveIndexMutationRisk(err);
}
if (/max(?:imum)?(?: database| db)? size|database size limit|maxDBSize/i.test(String(err))) {
log(
'The database size limit was reached. Set GITNEXUS_LBUG_MAX_DB_SIZE to a larger ' +
'byte limit before retrying analyze; retained complete embeddings can be reused.',
);
}
throw err;
}
}

View file

@ -3,6 +3,7 @@ import {
dropFTSIndex,
indexRowName,
indexRowTable,
indexRowType,
resolveGateRows,
DEFAULT_FTS_STEMMER,
type IndexCatalogSnapshot,
@ -394,9 +395,40 @@ export const summarizeFtsIndexBuildFailures = (
`FTS index build failed for ${failures.length} of ${indexes.length} tables: ` +
failures.map((f) => `${f.table}.${f.indexName} (${f.error})`).join(', ');
/** Bounded catalog evidence for CLI/log diagnostics, never source or index definitions. */
export interface FtsIndexVerificationDiagnostic {
table: string;
indexName: string;
message: string;
}
const boundCatalogText = (text: string, limit: number): string =>
text.length > limit ? `${text.slice(0, limit - 14)}… [truncated]` : text;
const catalogFieldEvidence = (value: unknown): string =>
typeof value === 'string'
? JSON.stringify(boundCatalogText(value, 80))
: value === undefined
? '<missing>'
: `<invalid ${value === null ? 'null' : typeof value}>`;
const catalogPropertiesEvidence = (value: unknown): string => {
if (!Array.isArray(value)) return `<invalid properties: ${catalogFieldEvidence(value)}>`;
return (
`[${value.slice(0, 8).map(catalogFieldEvidence).join(', ')}]` +
(value.length > 8 ? ` (${value.length - 8} properties truncated)` : '')
);
};
/**
* Return missing or invalid configured indexes. An optional callback receives
* bounded evidence from the same catalog read; a failed read still throws and
* proves neither presence nor absence.
*/
export async function verifySearchFTSIndexes(
executeQuery: (cypher: string) => Promise<unknown[]>,
indexes: readonly FTSIndexDefinition[] = FTS_INDEXES,
onMismatch?: (diagnostic: FtsIndexVerificationDiagnostic) => void,
): Promise<string[]> {
// Read the catalog once and check each configured index both EXISTS and
// covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX
@ -404,34 +436,51 @@ export async function verifySearchFTSIndexes(
// pre-#2299 DB stays queryable yet silently misses `description`, so the probe
// would pass while doc-comment search is still broken (#2299). SHOW_INDEXES
// exposes `property_names` (STRING[]) per index, so we assert coverage directly.
const rows = await executeQuery('CALL SHOW_INDEXES() RETURN *');
const propsByIndex = new Map<string, readonly string[]>();
for (const row of rows) {
if (typeof row !== 'object' || row === null) continue;
const record = row as Record<string, unknown>;
const indexName = indexRowName(record);
// LADYBUGDB-CONTRACT: `property_names` is the one SHOW_INDEXES column with a
// single reader, so it has no shared accessor — see {@link IndexCatalogRow}
// in lbug-adapter.ts for the full column list and the re-validation rule.
// Unlike the gates, an unreadable shape here is safe: it reports the index as
// not covering its columns, i.e. "missing", which degrades keyword search
// loudly rather than passing a broken index off as verified.
const propertyNames = record.property_names;
if (typeof indexName !== 'string' || !Array.isArray(propertyNames)) continue;
propsByIndex.set(
indexName,
propertyNames.filter((p): p is string => typeof p === 'string'),
);
}
const rows = (await executeQuery('CALL SHOW_INDEXES() RETURN *')).filter(
(row): row is Record<string, unknown> => typeof row === 'object' && row !== null,
);
const missing: string[] = [];
for (const { table, indexName, properties } of indexes) {
const actual = propsByIndex.get(indexName);
// Absent from the catalog, or present but not covering every expected column.
if (!actual || !properties.every((p) => actual.includes(p))) {
missing.push(`${table}.${indexName}`);
}
const valid = rows.some((row) => {
// LADYBUGDB-CONTRACT: `property_names` is a named STRING[] column;
// identity/type use the shared catalog readers in lbug-adapter.ts.
const actual = row.property_names;
return (
indexRowTable(row) === table &&
indexRowName(row) === indexName &&
indexRowType(row) === 'FTS' &&
Array.isArray(actual) &&
actual.every((property) => typeof property === 'string') &&
properties.every((property) => actual.includes(property))
);
});
if (valid) continue;
missing.push(`${table}.${indexName}`);
if (!onMismatch) continue;
// Show only rows that could explain this identity mismatch, and only
// catalog identity/type/property fields. Malformed objects are represented
// by their type rather than serialized (they can carry arbitrary content).
const relevant = rows.filter(
(row) => indexRowTable(row) === table || indexRowName(row) === indexName,
);
const observed = relevant
.slice(0, 3)
.map(
(row) =>
`{table=${catalogFieldEvidence(indexRowTable(row))}, ` +
`index=${catalogFieldEvidence(indexRowName(row))}, ` +
`type=${catalogFieldEvidence(indexRowType(row))}, ` +
`properties=${catalogPropertiesEvidence(row.property_names)}}`,
);
const message =
`${table}.${indexName}: expected table=${catalogFieldEvidence(table)}, ` +
`index=${catalogFieldEvidence(indexName)}, type=FTS, ` +
`properties=${catalogPropertiesEvidence(properties)}; observed ` +
(observed.length > 0 ? observed.join(', ') : 'no matching table or index name') +
(relevant.length > 3 ? ` (${relevant.length - 3} catalog rows truncated)` : '');
onMismatch({ table, indexName, message: boundCatalogText(message, 2048) });
}
return missing;
}
@ -535,19 +584,39 @@ export async function buildSearchIndexesOrDegrade(
// means description search is broken (#2299).
const failures = await createSearchFTSIndexes(options);
const indexes = options?.indexes ?? FTS_INDEXES;
const missing = await verifySearchFTSIndexes(executeQuery, indexes);
if (failures.length === 0 && missing.length === 0) return { ok: true };
const diagnostics: FtsIndexVerificationDiagnostic[] = [];
let missing: string[] = [];
let verificationError: string | undefined;
try {
missing = await verifySearchFTSIndexes(executeQuery, indexes, (diagnostic) =>
diagnostics.push(diagnostic),
);
} catch (e) {
// A failed catalog read is unknown, not absence. Keep the native build
// failures already collected rather than replacing them with this error.
verificationError = e instanceof Error ? e.message : String(e);
}
if (failures.length === 0 && missing.length === 0 && verificationError === undefined) {
return { ok: true };
}
// A table that failed to build is necessarily missing too — report it once,
// with its reason, and keep `missing` for indexes nothing explains.
// A failed DROP may leave an old catalog row present. Build failures stand
// on their own; avoid naming the same table again as an unexplained failure.
const named = new Set(failures.map((f) => `${f.table}.${f.indexName}`));
const unexplained = missing.filter((name) => !named.has(name));
const unexplained = diagnostics.filter((d) => !named.has(`${d.table}.${d.indexName}`));
const error = [
failures.length > 0 ? summarizeFtsIndexBuildFailures(failures, indexes) : '',
// Structural incompleteness with no thrown error — classified capability
// (degrade) below, matching prior behavior; a broken *write* surfaces as
// a thrown IO/checkpoint error and is classified integrity there.
unexplained.length > 0 ? `missing indexes after build: ${unexplained.join(', ')}` : '',
unexplained.length > 0
? 'missing indexes or mismatched catalog rows after build ' +
'(no build error was returned for these indexes): ' +
unexplained.map((d) => d.message).join('; ')
: '',
verificationError !== undefined
? `FTS catalog verification failed: ${verificationError}`
: '',
]
.filter((part) => part.length > 0)
.join('; ');
@ -555,9 +624,11 @@ export async function buildSearchIndexesOrDegrade(
// Classify per failure, not over the joined text: capability signatures are
// checked first, so folding the messages together would let an untokenizable
// row mask a genuinely broken write and downgrade an abort into a degrade.
const failureClass = failures.some((f) => classifyFtsBuildError(f.error) === 'integrity')
? 'integrity'
: classifyFtsBuildError(error);
const failureClass =
failures.some((f) => classifyFtsBuildError(f.error) === 'integrity') ||
(verificationError !== undefined && classifyFtsBuildError(verificationError) === 'integrity')
? 'integrity'
: 'capability';
return { ok: false, error, failureClass };
} catch (e) {
const error = e instanceof Error ? e.message : String(e);

View file

@ -1,4 +1,9 @@
import { executeParameterized } from '../../core/lbug/pool-adapter.js';
import {
assertSymbolIdentity,
assertIdentityFields,
rethrowSymbolIdentityError,
} from './query-result-integrity.js';
import {
decodeSpringAopReason,
type SpringAopReason,
@ -151,6 +156,7 @@ const DETERMINISTIC_RELATIONSHIP_ORDER = 'ORDER BY sourceId, targetId, reason, s
* shared decoder. Other DECLARES edges (for example Spring Bean factories)
* and malformed/forward-version evidence are ignored. Query failures are
* fail-soft because older or partially upgraded indexes must remain readable.
* Corrupt identities propagate to the context/impact integrity error boundary.
*/
export async function querySpringAopMetadata(
lbugPath: string,
@ -204,6 +210,24 @@ export async function querySpringAopMetadata(
),
]);
for (const rows of [
outgoingAdviceRows,
incomingAdviceRows,
outgoingPointcutRows,
incomingPointcutRows,
]) {
for (const row of rows) {
assertSymbolIdentity(readRowValue(row, 'sourceId', 0));
assertSymbolIdentity(readRowValue(row, 'targetId', 3));
assertIdentityFields(
readRowValue(row, 'sourceName', 1),
readRowValue(row, 'sourceFilePath', 2),
readRowValue(row, 'targetName', 4),
readRowValue(row, 'targetFilePath', 5),
);
}
}
const behaviors: SpringAopBehaviorMetadata[] = [];
const advices: SpringAopAdviceMetadata[] = [];
const resolvedPointcuts: SpringAopResolvedPointcutMetadata[] = [];
@ -346,7 +370,8 @@ export async function querySpringAopMetadata(
resolvedPointcuts: dedupedResolvedPointcuts,
unresolvedPointcuts: dedupedPointcuts,
};
} catch {
} catch (error) {
rethrowSymbolIdentityError(error);
return undefined;
}
}

View file

@ -28,6 +28,15 @@ import {
} from '../../core/lbug/pool-adapter.js';
import { queryClassBeanMetadata } from './bean-metadata.js';
import { querySpringAopMetadata } from './aop-metadata.js';
import {
SYMBOL_IDENTITY_RECOVERY_SUGGESTION,
SymbolIdentityError,
assertSymbolIdentity,
queryRowValue,
assertIdentityFields,
assertQueryIdentity,
rethrowSymbolIdentityError,
} from './query-result-integrity.js';
import { queryConvexDispatchMetadata } from './convex-metadata.js';
import { isValidQueryParams } from '../../core/lbug/query-params.js';
import { toDisplayLine } from './line-display.js';
@ -324,25 +333,67 @@ function nonBlankUid(value: unknown): string | undefined {
return typeof value === 'string' ? value.trim() || undefined : undefined;
}
const SYMBOL_IDENTITY_RECOVERY_SUGGESTION =
'Run gitnexus analyze --force from the affected repository root to rebuild the index.';
function assertSymbolRowIdentity(row: unknown): void {
assertQueryIdentity(row, 'id', 0, [
['name', 1],
['type', 2],
['filePath', 3],
]);
}
class SymbolIdentityError extends Error {
constructor() {
super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
this.name = 'SymbolIdentityError';
function assertContextRefs(rows: unknown[]): void {
for (const row of rows) {
assertQueryIdentity(row, 'uid', 1, [
['name', 2],
['filePath', 3],
['kind', 4],
]);
assertSymbolIdentity(queryRowValue(row, 'relType', 0));
}
}
/** Validate database identities before using them as graph traversal anchors. */
function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string {
if (
typeof id !== 'string' ||
!id.trim() ||
id.includes('\0') ||
(expectedUid !== undefined && id !== expectedUid)
) {
throw new SymbolIdentityError();
const RESPONSE_IDENTITY_FIELDS = new Set([
'id',
'uid',
'name',
'filePath',
'label',
'kind',
'type',
'relationType',
'processType',
'url',
'method',
'sourceId',
'targetId',
'symbolId',
'symbolName',
'symbolFilePath',
'adviceId',
'adviceName',
'adviceFilePath',
'advisedId',
'advisedName',
'advisedFilePath',
'evidenceId',
]);
/** Cover nested additive identity fields while leaving source/metadata text alone. */
function assertResponseIdentities(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) assertResponseIdentities(item);
} else if (value !== null && typeof value === 'object') {
for (const [key, field] of Object.entries(value)) {
if (key === 'seedBlocks' || key === 'reachableBlocks' || key === 'intraReachableBlocks') {
if (!Array.isArray(field)) throw new SymbolIdentityError();
for (const id of field) assertSymbolIdentity(id);
continue;
}
if (RESPONSE_IDENTITY_FIELDS.has(key)) assertIdentityFields(field);
if (key !== 'content' && key !== 'methodMetadata' && key !== 'bean') {
assertResponseIdentities(field);
}
}
}
}
@ -4393,9 +4444,10 @@ export class LocalBackend {
* "unknown kind" and, worse, makes the `kind` disambiguation hint unable to
* filter it out (#2687).
*
* Failures are swallowed: label enrichment is an optimisation for
* Ordinary query failures are swallowed: label enrichment is an optimisation for
* downstream scoring and #480 Class/Interface BFS seeding; if it fails
* the symbol still resolves, just without the kind-priority bonus.
* Corrupt identities propagate to the context/impact error envelope.
*/
private async enrichCandidateLabels(
repo: RepoHandle,
@ -4429,6 +4481,7 @@ export class LocalBackend {
);
const labelById = new Map<string, string>();
for (const r of rows as any[]) {
assertQueryIdentity(r, 'id', 0, [['label', 1]]);
const id = (r.id ?? r[0]) as string;
const label = (r.label ?? r[1]) as string;
if (id && label && !labelById.has(id)) labelById.set(id, label);
@ -4436,7 +4489,8 @@ export class LocalBackend {
for (const c of candidates) {
if (c.type === '' && labelById.has(c.id)) c.type = labelById.get(c.id) as string;
}
} catch {
} catch (error) {
rethrowSymbolIdentityError(error);
/* best-effort — downstream resolvers still work without the label */
}
}
@ -4561,6 +4615,7 @@ export class LocalBackend {
{ uid },
);
if (rows.length === 0) return { kind: 'not_found' };
assertSymbolRowIdentity(rows[0]);
const r = rows[0] as any;
const symbol = {
id: (r.id ?? r[0]) as string,
@ -4695,6 +4750,10 @@ export class LocalBackend {
if (rows.length === 0) return { kind: 'not_found' };
// Reject every raw candidate before narrowing/scoring can hide a corrupt row.
for (const row of rows) {
assertSymbolRowIdentity(row);
}
// Normalise row shape across object / tuple returns from LadybugDB.
let normalized = rows.map((r: any) => ({
id: (r.id ?? r[0]) as string,
@ -4705,10 +4764,6 @@ export class LocalBackend {
endLine: (r.endLine ?? r[5]) as number,
...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}),
}));
// Reject the whole result before narrowing or scoring: dropping a corrupt
// candidate could make an unrelated surviving symbol look unambiguous.
for (const candidate of normalized) assertSymbolIdentity(candidate.id);
// An exact File path wins over anchored suffix candidates. Without this,
// `lib/a.ts` and `src/lib/a.ts` both score as File candidates and turn an
// otherwise unambiguous exact target into `ambiguous` (#3084 review P2).
@ -4858,7 +4913,9 @@ export class LocalBackend {
},
): Promise<any> {
try {
return await this._contextImpl(repo, params);
const result = await this._contextImpl(repo, params);
if (!result.error) assertResponseIdentities(result);
return result;
} catch (err: any) {
const msg = (err instanceof Error ? err.message : String(err)) || 'Context query failed';
if (err instanceof SymbolIdentityError) {
@ -4978,6 +5035,8 @@ export class LocalBackend {
{ symId },
),
]);
assertContextRefs(incomingRows);
assertContextRefs(incomingAdvisedRows);
incomingRows.push(...incomingAdvisedRows);
let typedPropertyRows: any[] = [];
@ -5082,6 +5141,16 @@ export class LocalBackend {
},
),
]);
assertContextRefs(ctorIncoming);
assertContextRefs(fileIncoming);
assertContextRefs(typedPropertyIncoming);
for (const row of typedProperties) {
assertQueryIdentity(row, 'uid', 0, [
['name', 1],
['filePath', 2],
['kind', 3],
]);
}
typedPropertyRows = typedProperties;
// Deduplicate by (relType, uid) — a caller can have multiple relation
@ -5098,6 +5167,7 @@ export class LocalBackend {
}
}
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('context:class-incoming-expansion', e);
}
}
@ -5128,6 +5198,8 @@ export class LocalBackend {
{ symId },
),
]);
assertContextRefs(outgoingRows);
assertContextRefs(outgoingAdvisedRows);
outgoingRows.push(...outgoingAdvisedRows);
// Process participation.
@ -5151,7 +5223,14 @@ export class LocalBackend {
`,
{ symId },
);
for (const row of processRows) {
assertQueryIdentity(row, 'pid', 0, [
['label', 1],
['entryPointId', 4],
]);
}
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('context:process-participation', e);
}
@ -5188,6 +5267,7 @@ export class LocalBackend {
// (GET/POST pair). URL-only dedup would drop the second endpoint.
const seenRoutes = new Set<string>();
for (const r of routeRows) {
assertIdentityFields(queryRowValue(r, 'url', 0), queryRowValue(r, 'method', 1));
const url = r.url ?? r[0];
const method = r.method ?? r[1];
const dedupKey = routeEnrichmentKey(method ? String(method) : undefined, url);
@ -5197,7 +5277,8 @@ export class LocalBackend {
}
}
} catch (e) {
// Best-effort enrichment — never fail the context call.
rethrowSymbolIdentityError(e);
// Ordinary query failures leave this best-effort enrichment unavailable.
logQueryError('context:route-lookup', e);
}
@ -5247,6 +5328,7 @@ export class LocalBackend {
);
const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType);
const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType);
void aopMetadataPromise.catch(() => undefined);
// R3-1. A `Property` whose name the analyzer declined to link — because
// every definition of it lives in another language — otherwise returns an
@ -5341,6 +5423,7 @@ export class LocalBackend {
try {
chain = await this._computeContextChain(repo, symId, requestedDepth);
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('context:chain-bfs', e);
}
}
@ -5381,8 +5464,8 @@ export class LocalBackend {
processes: processRows.map((r: any) => ({
id: r.pid || r[0],
name: r.label || r[1],
step_index: r.step || r[2],
step_count: r.stepCount || r[3],
step_index: r.step ?? r[2],
step_count: r.stepCount ?? r[3],
})),
};
}
@ -5463,6 +5546,13 @@ export class LocalBackend {
visited: Array.from(visited),
});
if (rows.length === 0) return { nextFrontier: [] };
for (const row of rows) {
assertQueryIdentity(row, 'uid', 0, [
['name', 1],
['filePath', 2],
['kind', 3],
]);
}
// MATCH is one row per CALLS edge. Cypher `WITH DISTINCT` applies
// LIMIT 50 to unique neighbors; this second pass still collapses
// twins if a driver/engine ever returns duplicate rows.
@ -5482,6 +5572,7 @@ export class LocalBackend {
for (const r of fresh) visited.add(r.uid);
return { nodes, nextFrontier: fresh.map((r: any) => r.uid) };
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError(logLabel, e);
return { nextFrontier: [] };
}
@ -7163,7 +7254,9 @@ export class LocalBackend {
private async impact(repo: RepoHandle, params: ImpactParams): Promise<any> {
try {
return await this._impactImpl(repo, params);
const result = await this._impactImpl(repo, params);
if (!result.error) assertResponseIdentities(result);
return result;
} catch (err: any) {
// Return structured error instead of crashing (#321)
const message =
@ -7481,6 +7574,7 @@ export class LocalBackend {
} catch (e) {
probeFailed = true;
candidateProbeFailed = true;
rethrowSymbolIdentityError(e);
logQueryError('impact:ambiguous-candidate', e);
}
return {
@ -7738,6 +7832,7 @@ export class LocalBackend {
});
return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult);
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('impact:pdg-interprocedural-reach', e);
return composeUnifiedPdgImpactResult(pdgResult, null, e);
}
@ -7775,10 +7870,12 @@ export class LocalBackend {
{ ids: blockIds },
);
for (const r of rows as any[]) {
assertIdentityFields(r.callees ?? r[0]);
const raw = String(r.callees ?? r[0] ?? '');
for (const n of raw.split(' ')) if (n) names.add(n);
}
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('impact:pdg-slice-callees', e);
}
return names;
@ -7814,6 +7911,7 @@ export class LocalBackend {
for (const id of splitCalleeIds(r.calleeIds ?? r[0])) ids.add(id);
}
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('impact:pdg-slice-callee-ids', e);
}
return ids;
@ -7967,7 +8065,10 @@ export class LocalBackend {
ORDER BY id
LIMIT 25`,
{ symId, heritage: HERITAGE_TYPES },
).catch(() => []);
).catch((error) => {
rethrowSymbolIdentityError(error);
return [];
});
const undecidedSummary = meta?.undecidedInterfaceSatisfaction;
const undecidedDrops =
undecidedSummary === undefined
@ -8006,6 +8107,10 @@ export class LocalBackend {
}
const ifaceRows = await interfaceRowsPromise;
for (const r of ifaceRows) {
assertQueryIdentity(r, 'id', 0, [
['name', 1],
['label', 2],
]);
const id = (r.id ?? r[0]) as string;
if (id && !boundary.has(id)) {
boundary.set(id, {
@ -8031,7 +8136,10 @@ export class LocalBackend {
WHERE iface.id = $ifaceId AND r.type IN $types
RETURN COUNT(DISTINCT other.id) AS cnt`,
{ ifaceId, types },
).catch(() => []);
).catch((error) => {
rethrowSymbolIdentityError(error);
return [];
});
const cnt =
rows.length > 0 ? Number((rows[0] as any).cnt ?? (rows[0] as any)[0] ?? 0) : 0;
m.set(ifaceId, cnt);
@ -8090,7 +8198,8 @@ export class LocalBackend {
callableValueReferences: droppedBoundaries.callableValueReferences,
},
};
} catch {
} catch (error) {
rethrowSymbolIdentityError(error);
// Never let the heritage probe's failure suppress a drop we already know
// about — the whole point is that silence must not read as certainty.
return epistemicFrom(droppedBoundaries);
@ -8182,6 +8291,7 @@ export class LocalBackend {
`Impact target '${sym.name || sym[1] || '?'}' resolved without a node id; refusing to report a blast radius`,
);
}
assertSymbolRowIdentity(sym);
// #1858 — kick off the epistemic boundary probe concurrently with the BFS.
// It depends only on symId/symType/symName (all known now) and touches no
@ -8217,6 +8327,7 @@ export class LocalBackend {
opts.skipEpistemic || summaryOnly
? Promise.resolve(undefined)
: querySpringAopMetadata(repo.lbugPath, symId, symType);
void aopMetadataPromise.catch(() => undefined);
const impacted: any[] = [];
const visited = new Set<string>([symId]);
const pdgBridgeEvidenceById = new Map<string, PdgBridgeEvidenceInfo>();
@ -8261,6 +8372,7 @@ export class LocalBackend {
]);
for (const r of ctorRows) {
assertSymbolRowIdentity(r);
const rid = r.id || r[0];
if (rid && !visited.has(rid)) {
visited.add(rid);
@ -8268,6 +8380,7 @@ export class LocalBackend {
}
}
for (const r of fileRows) {
assertSymbolRowIdentity(r);
const rid = r.id || r[0];
if (rid && !visited.has(rid)) {
visited.add(rid);
@ -8292,6 +8405,7 @@ export class LocalBackend {
);
for (const r of typedPropertyRows) {
assertSymbolRowIdentity(r);
const rid = r.id || r[0];
if (rid && !visited.has(rid)) {
visited.add(rid);
@ -8299,6 +8413,7 @@ export class LocalBackend {
}
}
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('impact:class-node-expansion', e);
traversalComplete = false;
}
@ -8336,6 +8451,9 @@ export class LocalBackend {
`,
{ symId },
);
for (const row of memberRows) {
assertSymbolRowIdentity(row);
}
memberRows.sort((a, b) => compareCodeUnits(String(a.id ?? a[0]), String(b.id ?? b[0])));
if (memberRows.length > OBJECT_CALLABLE_MEMBER_CAP) traversalComplete = false;
for (const row of memberRows.slice(0, OBJECT_CALLABLE_MEMBER_CAP)) {
@ -8355,6 +8473,7 @@ export class LocalBackend {
}
}
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('impact:object-callable-expansion', e);
traversalComplete = false;
}
@ -8411,6 +8530,17 @@ export class LocalBackend {
relTypes: relationTypes,
...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}),
});
// Validate before filtering/deduplication; a discarded corrupt edge is
// still evidence that this result's counts cannot be trusted.
for (const row of related) {
assertQueryIdentity(row, 'id', 1, [
['sourceId', 0],
['name', 2],
['type', 3],
['filePath', 4],
]);
assertSymbolIdentity(queryRowValue(row, 'relType', 5));
}
const edges: ImpactFrontierEdge[] = related.map((rel) => ({
id: rel.id || rel[1],
@ -8510,6 +8640,7 @@ export class LocalBackend {
});
}
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('impact:depth-traversal', e);
// Break out of depth loop on query failure but return partial results
// collected so far, rather than silently swallowing the error (#321)
@ -8635,6 +8766,7 @@ export class LocalBackend {
`,
{ ids },
).catch((err) => {
rethrowSymbolIdentityError(err);
processQueryFailed = true;
enrichmentDegraded = true;
logQueryError('impact:process-chunk', err);
@ -8642,6 +8774,14 @@ export class LocalBackend {
});
for (const row of rows) {
assertQueryIdentity(row, 'pId', 0, [
['name', 1],
['processType', 2],
['entryPointId', 3],
['epName', 7],
['epType', 8],
['epFilePath', 9],
]);
const pId = row.pId ?? row[0];
const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0];
// Track mapping from process -> entryPoint so we can backfill missing minStep
@ -8690,6 +8830,7 @@ export class LocalBackend {
ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity);
}
} catch (e) {
rethrowSymbolIdentityError(e);
processQueryFailed = true;
enrichmentDegraded = true;
logQueryError('impact:process-chunk', e);
@ -8712,12 +8853,14 @@ export class LocalBackend {
`,
{ pIds, ids: allImpactedIds },
).catch((err) => {
rethrowSymbolIdentityError(err);
enrichmentDegraded = true;
logQueryError('impact:process-chunk-backfill', err);
return [];
});
for (const mr of missingRows) {
assertQueryIdentity(mr, 'pid', 0, []);
const pid = mr.pid ?? mr[0];
const minStep = mr.minStep ?? mr[1];
const epId = processToEntryPoint.get(String(pid));
@ -8729,6 +8872,7 @@ export class LocalBackend {
}
}
} catch (e) {
rethrowSymbolIdentityError(e);
enrichmentDegraded = true;
logQueryError('impact:process-chunk-backfill', e);
}
@ -8791,6 +8935,7 @@ export class LocalBackend {
`,
{ ids: idsChunk },
).catch((err) => {
rethrowSymbolIdentityError(err);
moduleQueryFailed = true;
enrichmentDegraded = true;
logQueryError('impact:module-chunk', err);
@ -8798,12 +8943,14 @@ export class LocalBackend {
});
for (const r of rows) {
assertIdentityFields(queryRowValue(r, 'name', 0));
const name = r.name ?? r[0] ?? null;
const hits = (r.hits ?? r[1]) || 0;
if (!name) continue;
moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits);
}
} catch (e) {
rethrowSymbolIdentityError(e);
moduleQueryFailed = true;
enrichmentDegraded = true;
logQueryError('impact:module-chunk', e);
@ -8832,16 +8979,19 @@ export class LocalBackend {
`,
{ ids: idsChunk },
).catch((err) => {
rethrowSymbolIdentityError(err);
enrichmentDegraded = true;
moduleClassificationFailed = true;
logQueryError('impact:direct-module-chunk', err);
return [];
});
for (const r of rows) {
assertIdentityFields(queryRowValue(r, 'name', 0));
const name = r.name ?? r[0] ?? null;
if (name) directModuleSet.add(name);
}
} catch (e) {
rethrowSymbolIdentityError(e);
enrichmentDegraded = true;
moduleClassificationFailed = true;
logQueryError('impact:direct-module-chunk', e);
@ -8903,11 +9053,14 @@ export class LocalBackend {
`,
{ ids: chunkIds },
).catch((err) => {
rethrowSymbolIdentityError(err);
enrichmentDegraded = true;
logQueryError('impact:route-chunk', err);
return [];
});
for (const row of rows) {
assertSymbolIdentity(queryRowValue(row, 'hid', 0));
assertIdentityFields(queryRowValue(row, 'url', 1), queryRowValue(row, 'method', 2));
const hid = String(row.hid ?? row[0] ?? '');
const url = row.url ?? row[1];
if (!hid || typeof url !== 'string') continue;
@ -9064,8 +9217,16 @@ export class LocalBackend {
p.processType AS pType, MIN(r.step) AS step
`,
{ ids: chunkIds },
).catch(() => []);
).catch((err) => {
rethrowSymbolIdentityError(err);
return [];
});
for (const row of rows) {
assertQueryIdentity(row, 'sid', 0, []);
assertQueryIdentity(row, 'pid', 1, [
['pName', 2],
['pType', 3],
]);
const sid = row.sid ?? row[0];
if (!sid) continue;
const procEntry = {
@ -9079,6 +9240,7 @@ export class LocalBackend {
else perSymbolProcesses.set(String(sid), [procEntry]);
}
} catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('impact:per-symbol-process-chunk', e);
}
}

View file

@ -27,6 +27,11 @@ import { toDisplayLine } from './line-display.js';
import { toOneBasedLine } from '../../core/ingestion/utils/line-base.js';
import { decodeCallSummary } from '../../core/ingestion/taint/call-summary-codec.js';
import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js';
import {
assertSymbolIdentity,
assertIdentityFields,
assertQueryIdentity,
} from './query-result-integrity.js';
/**
* Parse the `<fnLine>` segment out of a `BasicBlock` id (1-based function start
@ -90,14 +95,19 @@ const INTERPROC_NODE_BUDGET = 5000;
* `classifyPdgBridgeEvidence`); this is the same fact, read at the descent side.
*/
function parseCalleeIdsCell(raw: unknown): { ids: string[]; truncated: boolean } {
assertIdentityFields(raw);
const ids: string[] = [];
let truncated = false;
if (!String(raw ?? '').trim()) return { ids, truncated };
// Split on the SHARED CALLEE_ID_SEP (tab) — ids embed file paths / multi-word
// C++ type tokens that can contain a space, so a space split would fragment
// them. Producer (calleeIdsOfBlock) joins with the same constant.
for (const id of String(raw ?? '').split(CALLEE_ID_SEP)) {
if (id === CALLEES_TRUNCATED_SENTINEL) truncated = true;
else if (id) ids.push(id);
else {
assertSymbolIdentity(id);
ids.push(id);
}
}
return { ids, truncated };
}
@ -218,6 +228,7 @@ async function selfReachingDefEdgesByBlock(
{ ids: blockIds },
);
for (const r of rows as Array<Record<string, unknown>>) {
assertQueryIdentity(r, 'id', 0);
const id = String(r['id'] ?? '');
if (!id) continue;
const decoded = decodeReachingDefReason(r['reason']);
@ -297,6 +308,7 @@ async function pdgStatementsForBlocks(
// Narrow the awaited rows ONCE at the boundary to a typed record shape; read
// the aliased cells via bracket access with String()/Number() coercion.
for (const r of rows as Array<Record<string, unknown>>) {
assertQueryIdentity(r, 'id', 0);
const id = String(r['id'] ?? '');
const line = Number(r['line'] ?? 0);
if (!id || !Number.isFinite(line) || line <= 0) continue;
@ -501,6 +513,10 @@ async function projectBlocksToSymbols(deps: {
// non-aliased row shape) — no per-field `as any`, matching the typed-row
// pattern used elsewhere in this file (e.g. lines ~264, ~1309, ~1386).
for (const r of rows as Array<Record<string, unknown>>) {
assertQueryIdentity(r, 'id', 0, [
['name', 1],
['label', 2],
]);
resolved.push({
id: String(r['id'] ?? r['0'] ?? ''),
name: String(r['name'] ?? r['1'] ?? ''),
@ -1718,6 +1734,7 @@ async function bfsReachableBlocks(input: {
// Narrow the awaited rows ONCE at the boundary (executeParameterized returns
// any[]) to a typed record shape, then read the aliased `id` via bracket
// access — no `as any` sprayed per field.
for (const row of rawRows) assertQueryIdentity(row, 'id', 0);
const rows = rawRows.slice(0, stepLimit) as Array<Record<string, unknown>>;
depthReached = depth + 1;
if (rawRows.length > stepLimit) truncatedByLimit = true;
@ -1796,6 +1813,10 @@ async function calleeIdsByBlock(
// Narrow the awaited rows ONCE at the boundary to a typed record shape; read
// the aliased cells via bracket access — no per-field `as any`.
for (const r of rows as Array<Record<string, unknown>>) {
assertQueryIdentity(r, 'id', 0, [
['calleeIds', 1],
['callees', 2],
]);
const blockId = String(r['id'] ?? '');
if (!blockId) continue;
// ONE pass over the cell classifies BOTH facts — a second full split just to
@ -1877,6 +1898,7 @@ async function calleesWithReturnFlow(
{ ids: calleeIds },
);
for (const r of rows as Array<Record<string, unknown>>) {
assertQueryIdentity(r, 'id', 0);
const id = String(r['id'] ?? '');
if (!id) continue;
const decoded = decodeCallSummary(r['reason']);
@ -1931,6 +1953,7 @@ async function resolveCalleeSpans(
// the aliased columns via bracket access with Number()/String() coercion —
// no per-field `as any` (the same boundary-narrowing the typed helpers use).
for (const r of rows as Array<Record<string, unknown>>) {
assertQueryIdentity(r, 'id', 0, [['filePath', 1]]);
const id = String(r['id'] ?? '');
const filePath = String(r['filePath'] ?? '');
const startLine = Number(r['startLine']);
@ -2168,6 +2191,7 @@ async function interproceduralDescent(input: {
seedBlockQuery(anchorClause, probeLimit),
queryParams,
);
for (const row of rawSeedRows) assertQueryIdentity(row, 'id', 0);
const exceeded = rawSeedRows.length > stepLimit;
const seeds = rawSeedRows
.slice(0, stepLimit)
@ -2353,6 +2377,7 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise<PdgImpactRes
seedBlockQuery(anchorClause, probeLimit),
queryParams,
);
for (const row of rawSeedRows) assertQueryIdentity(row, 'id', 0);
const seedRows = rawSeedRows.slice(0, stepLimit) as Array<Record<string, unknown>>;
let seedBlocks: string[] = seedRows
.map((r) => String(r['id'] ?? ''))

View file

@ -0,0 +1,57 @@
/** Shared integrity boundary for identities returned by impact/context queries. */
export const SYMBOL_IDENTITY_RECOVERY_SUGGESTION =
'Run gitnexus analyze --force from the affected repository root to rebuild the index.';
export class SymbolIdentityError extends Error {
constructor() {
super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
this.name = 'SymbolIdentityError';
}
}
/** Validate database identities before using them as graph traversal anchors. */
export function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string {
if (
typeof id !== 'string' ||
!id.trim() ||
id.includes('\0') ||
(expectedUid !== undefined && id !== expectedUid)
) {
throw new SymbolIdentityError();
}
}
/** Read either native row shape without turning an absent row into a TypeError. */
export function queryRowValue(row: unknown, key: string, index: number): unknown {
if (typeof row !== 'object' || row === null) return undefined;
const value = row as Record<string, unknown>;
return value[key] ?? value[index];
}
/** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */
export function assertIdentityFields(...values: unknown[]): void {
for (const value of values) {
if (
value !== null &&
value !== undefined &&
(typeof value !== 'string' || value.includes('\0'))
) {
throw new SymbolIdentityError();
}
}
}
export function assertQueryIdentity(
row: unknown,
idKey: string,
idIndex: number,
fields: ReadonlyArray<readonly [string, number]> = [],
): void {
assertSymbolIdentity(queryRowValue(row, idKey, idIndex));
for (const [key, index] of fields) assertIdentityFields(queryRowValue(row, key, index));
}
/** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */
export function rethrowSymbolIdentityError(error: unknown): void {
if (error instanceof SymbolIdentityError) throw error;
}

View file

@ -12,6 +12,7 @@ import {
acquireIndexLock,
IndexLockTimeoutError,
requireExclusiveIndexLock,
sweepStagingArtifacts,
type IndexLockHandle,
} from '../storage/index-lock.js';
import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js';
@ -2152,11 +2153,21 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// for the whole embedding write, released in the finally below.
let slotLock: IndexLockHandle | undefined;
try {
slotLock = await acquireIndexLock(storagePath);
slotLock = await acquireIndexLock(storagePath, { sweep: false });
requireExclusiveIndexLock(
slotLock,
`Cannot acquire the index lock at ${storagePath}; refusing an unlocked embedding run.`,
);
// This writer cannot recover staged generations. Preserve their
// receipts, including malformed ones, before sweeping or writing.
const recoveryCheckpoint = (await loadMeta(storagePath))?.embeddingCheckpoint;
if (recoveryCheckpoint && Object.hasOwn(recoveryCheckpoint, 'recovery')) {
throw new Error(
'Cannot generate embeddings: the index checkpoint references staged embeddings. ' +
'Run `gitnexus analyze` to recover them first.',
);
}
sweepStagingArtifacts(storagePath);
// Writes go to the slot's own graph; a shared-store checkout
// reading an immutable commit graph (#3352) takes a private copy.
if (!(await ensurePrivateSharedGraph(storagePath, () => {}))) {

View file

@ -0,0 +1,167 @@
/**
* Filesystem-only staged embedding provenance. Keep this independent of native
* and model imports: every index-lock caller needs the retention decision.
*/
import {
closeSync,
constants,
fstatSync,
lstatSync,
openSync,
readFileSync,
realpathSync,
} from 'node:fs';
import path from 'node:path';
import type { EmbeddingRecoveryReference } from './repo-meta.js';
import { INDEX_METADATA_FILE, LEGACY_METADATA_FILE } from './storage-constants.js';
const STAGING_FILENAME =
/^lbug\.staging\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
export const FAMILY_SUFFIXES = [
'',
'.wal',
'.shadow',
'.wal.checkpoint',
'.lock',
'.checkpoint.intent.lock',
'.checkpoint.apply.lock',
] as const;
export interface ResolvedEmbeddingRecovery extends EmbeddingRecoveryReference {
dbPath: string;
/** Exact basenames, never a prefix match that can preserve another generation. */
familyFiles: string[];
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const isNonemptyString = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0;
const isNodeIds = (value: unknown): value is string[] =>
Array.isArray(value) && value.every(isNonemptyString);
const isCount = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
/**
* Resolve only an explicit interrupted-generation receipt in this canonical
* slot. This validates provenance, not native contents or current identity;
* those checks must pass separately before any rows can be reused.
*/
export const resolveEmbeddingRecovery = (
lockDir: string,
checkpoint: unknown,
): ResolvedEmbeddingRecovery | undefined => {
if (!isRecord(checkpoint) || !isRecord(checkpoint.recovery)) return undefined;
if (checkpoint.kind !== undefined && checkpoint.kind !== 'interrupted') return undefined;
if (
!isNonemptyString(checkpoint.at) ||
!Number.isFinite(Date.parse(checkpoint.at)) ||
!isCount(checkpoint.nodesProcessed) ||
!isCount(checkpoint.totalNodes) ||
checkpoint.nodesProcessed > checkpoint.totalNodes ||
!isCount(checkpoint.chunksProcessed) ||
!isNonemptyString(checkpoint.model) ||
!isCount(checkpoint.dimensions) ||
checkpoint.dimensions === 0 ||
!isNonemptyString(checkpoint.provider) ||
(checkpoint.pendingNodeIds !== undefined && !isNodeIds(checkpoint.pendingNodeIds))
) {
return undefined;
}
const recovery = checkpoint.recovery;
if (
!isNonemptyString(recovery.stagingFile) ||
!STAGING_FILENAME.test(recovery.stagingFile) ||
!isNonemptyString(recovery.schemaFingerprint) ||
!isNodeIds(recovery.unsafeNodeIds)
) {
return undefined;
}
const unsafeNodeIds = new Set(recovery.unsafeNodeIds);
if (
isNodeIds(checkpoint.pendingNodeIds) &&
checkpoint.pendingNodeIds.some((nodeId) => !unsafeNodeIds.has(nodeId))
) {
return undefined;
}
try {
const canonicalDir = realpathSync(lockDir);
if (!lstatSync(canonicalDir).isDirectory()) return undefined;
const familyFiles = FAMILY_SUFFIXES.map((suffix) => recovery.stagingFile + suffix);
for (const [index, filename] of familyFiles.entries()) {
try {
// lstat refuses both live and dangling symlinks, without following one
// to a database outside the slot. The base file must exist.
if (!lstatSync(path.join(canonicalDir, filename)).isFile()) return undefined;
} catch (error) {
if (index > 0 && (error as NodeJS.ErrnoException).code === 'ENOENT') continue;
return undefined;
}
}
return {
dbPath: path.join(canonicalDir, recovery.stagingFile),
stagingFile: recovery.stagingFile,
schemaFingerprint: recovery.schemaFingerprint,
unsafeNodeIds: [...unsafeNodeIds],
familyFiles,
};
} catch {
return undefined;
}
};
/** Synchronous mirror of loadMeta's primary-first, absent-only fallback rule. */
export const readEmbeddingRecovery = (lockDir: string): ResolvedEmbeddingRecovery | undefined => {
let metadataPath = path.join(lockDir, INDEX_METADATA_FILE);
let descriptor: number | undefined;
const noFollow = constants.O_NOFOLLOW ?? 0;
const flags = constants.O_RDONLY | noFollow | (constants.O_NONBLOCK ?? 0);
try {
try {
descriptor = openSync(metadataPath, flags);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') return undefined;
// Windows cannot open with O_NOFOLLOW: an open of a dangling symlink
// reports ENOENT, but that existing primary entry must prevent fallback.
try {
lstatSync(metadataPath);
return undefined;
} catch (statError) {
const statCode = (statError as NodeJS.ErrnoException).code;
if (statCode !== 'ENOENT' && statCode !== 'ENOTDIR') return undefined;
}
metadataPath = path.join(lockDir, LEGACY_METADATA_FILE);
descriptor = openSync(metadataPath, flags);
}
const opened = fstatSync(descriptor, { bigint: true });
const entry = lstatSync(metadataPath, { bigint: true });
// Check the opened file itself and match the current non-symlink entry.
// This also refuses replacement on platforms without O_NOFOLLOW.
if (
!opened.isFile() ||
!entry.isFile() ||
(noFollow === 0 && opened.ino === 0n) ||
opened.dev !== entry.dev ||
opened.ino !== entry.ino
) {
return undefined;
}
const meta: unknown = JSON.parse(readFileSync(descriptor, 'utf8'));
if (!isRecord(meta)) return undefined;
// storagePath describes the flat/cache root, including in branch-slot
// metadata. The current locked directory is the generation boundary.
return resolveEmbeddingRecovery(lockDir, meta.embeddingCheckpoint);
} catch {
return undefined;
} finally {
if (descriptor !== undefined) {
try {
closeSync(descriptor);
} catch {
/* best-effort */
}
}
}
};

View file

@ -62,6 +62,7 @@ import path from 'node:path';
import os from 'node:os';
import { randomBytes, randomUUID, createHash } from 'node:crypto';
import { isProcessAlive } from '../utils/process-identity.js';
import { readEmbeddingRecovery } from './embedding-recovery.js';
const LOCK_FILENAME = 'analyze.lock';
const LOCK_RECORD_VERSION = 1 as const;
@ -433,8 +434,9 @@ const deniedCreateHandle = (
/**
* Delete orphaned build/staging artifacts left in the lock directory by a
* crashed prior writer. Safe precisely because we hold the exclusive lock: no
* other writer can be creating these here right now, so anything present is a
* crash orphan. Matches this slot's staging files ONLY — never `lbug` itself,
* other writer can be creating these here right now. A checkpoint-referenced
* generation is retained for embedding recovery; all other stages are orphans.
* Matches this slot's staging files ONLY — never `lbug` itself,
* never `lbug.wal`/`lbug.shadow` (the LIVE index's own sidecars), and never a
* `branches/<slug>/` sub-slot (which owns its own lock + sweep). Non-recursive.
*/
@ -442,6 +444,7 @@ export const sweepStagingArtifacts = (lockDir: string, log?: (msg: string) => vo
// Matches `lbug.new`, `lbug.new.wal`, `lbug.staging.<id>`, `lbug.staging.<id>.wal`, …
// Does NOT match `lbug`, `lbug.wal`, `lbug.shadow`.
const stagingRe = /^lbug\.(staging\..+|new(\..+)?)$/;
const retained = new Set(readEmbeddingRecovery(lockDir)?.familyFiles ?? []);
let removed = 0;
let entries: string[];
try {
@ -450,7 +453,7 @@ export const sweepStagingArtifacts = (lockDir: string, log?: (msg: string) => vo
return;
}
for (const name of entries) {
if (!stagingRe.test(name)) continue;
if (!stagingRe.test(name) || retained.has(name)) continue;
try {
unlinkSync(path.join(lockDir, name));
removed++;

View file

@ -43,6 +43,15 @@ export type ContentRetention = 'full' | 'symbol' | 'none';
export type FtsProfile = 'full' | 'symbol-no-file-content' | 'name-only';
export const CONTENT_RETENTION_SCHEMA_VERSION = 1;
/** Exact staged generation whose completed embedding groups can survive a retry. */
export interface EmbeddingRecoveryReference {
/** A run-minted basename within this metadata file's index slot. */
stagingFile: string;
schemaFingerprint: string;
/** Active-window and inherited incomplete groups: never reusable until completed. */
unsafeNodeIds: string[];
}
/**
* Versioned receipt for the analyzer process that produced an index.
*
@ -521,6 +530,11 @@ export interface RepoMeta {
* subset of their chunks; for `'partial'` they hold none.
*/
pendingNodeIds?: string[];
/**
* Interrupted atomic builds only. This does not publish the staged graph
* or advance live embedding statistics; it identifies a recovery source.
*/
recovery?: EmbeddingRecoveryReference;
};
/**
* Name of the git branch this index represents (#2106). Absent for the

View file

@ -0,0 +1,52 @@
import lbug from '@ladybugdb/core';
import fs from 'node:fs';
import { createLbugDatabase } from '../../../src/core/lbug/lbug-config.ts';
const [dbPath, mode] = process.argv.slice(2);
const db = createLbugDatabase(lbug, dbPath);
const conn = new lbug.Connection(db);
async function query(cypher) {
const queried = await conn.query(cypher);
for (const result of Array.isArray(queried) ? queried : [queried]) {
await result.getAll();
await result.close();
}
}
await query('CREATE NODE TABLE CodeEmbedding (id STRING, nodeId STRING, chunkIndex INT32, startLine INT64, endLine INT64, embedding FLOAT[2], contentHash STRING, PRIMARY KEY(id))');
async function row(id, nodeId, chunkIndex, hash = 'same', startLine = 1, endLine = 3) {
await query(`CREATE (:CodeEmbedding {id: '${id}', nodeId: '${nodeId}', chunkIndex: ${chunkIndex}, startLine: ${startLine}, endLine: ${endLine}, embedding: [1.0, 2.0], contentHash: ${hash === null ? 'NULL' : `'${hash}'`}})`);
}
await row('complete-0', 'complete', 0);
await row('complete-1', 'complete', 1);
await row('other', 'other', 0);
await query('CHECKPOINT');
if (mode === 'interrupted-checkpoint') {
await row('checkpoint-only', 'checkpoint-only', 0);
const main = fs.readFileSync(dbPath);
const wal = fs.readFileSync(`${dbPath}.wal`);
await conn.close();
await db.close();
// Restore the pre-close bytes: writable close has already checkpointed them.
fs.writeFileSync(dbPath, main);
fs.writeFileSync(`${dbPath}.wal.checkpoint`, wal);
fs.writeFileSync(`${dbPath}.wal`, '');
fs.writeFileSync(`${dbPath}.shadow`, '');
fs.writeFileSync(`${dbPath}.checkpoint.intent.lock`, '');
fs.writeFileSync(`${dbPath}.checkpoint.apply.lock`, '');
} else if (mode === 'hard-kill') {
await row('unsafe', 'unsafe-prefix', 0);
process.kill(process.pid, 'SIGKILL');
} else {
await row('gap', 'gap', 1);
await row('duplicate-0', 'duplicate', 0);
await row('duplicate-1', 'duplicate', 0);
await row('mixed-0', 'mixed', 0, 'old');
await row('mixed-1', 'mixed', 1, 'new');
await row('missing-hash-0', 'missing-hash', 0);
await row('missing-hash-1', 'missing-hash', 1, null);
await row('bad-line', 'bad-line', 0, 'same', -1, 3);
await row('nan-0', 'nan', 0);
await query("CREATE (:CodeEmbedding {id: 'nan-1', nodeId: 'nan', chunkIndex: 1, startLine: 1, endLine: 3, embedding: [CAST('NaN', 'FLOAT'), 2.0], contentHash: 'same'})");
await conn.close();
await db.close();
}

View file

@ -29,7 +29,7 @@ export interface CfgHarness {
* CFG shape). The parser is created once and reused across parses.
*/
export function makeCfgHarness(
grammar: Parser.Language,
grammar: Parameters<Parser['setLanguage']>[0],
visitor: CfgVisitor<SyntaxNode>,
filePath = 'fixture',
): CfgHarness {

View file

@ -0,0 +1,38 @@
import {
buildGraphologyGraph,
runGraphologyLeiden,
} from '../../src/core/ingestion/community-processor.js';
// 105-node / 128-edge graph from #3476 on which the vendored Leiden never
// terminates (graphology/graphology#557). Preserve the original insertion order:
// sorting either the nodes or edges changes the path through the algorithm.
const EDGES =
'39-26 65-66 72-52 10-38 98-68 37-10 26-81 37-38 30-66 18-66 30-97 84-81 50-96 36-76 ' +
'33-29 54-87 4-75 44-31 29-31 60-44 91-10 48-41 68-15 83-90 82-57 50-71 28-55 100-11 ' +
'42-19 28-42 74-92 55-5 7-65 96-41 61-27 60-85 89-67 97-34 94-64 89-6 61-51 12-13 ' +
'35-9 91-20 94-23 91-7 43-99 80-85 80-101 75-50 38-57 4-104 35-6 65-103 83-45 62-91 ' +
'16-37 94-33 103-17 67-66 39-73 68-2 26-31 48-19 43-88 88-47 54-42 26-66 100-65 7-30 ' +
'32-13 56-2 22-24 78-24 84-53 90-71 60-12 76-49 86-35 55-68 96-46 33-30 4-1 24-68 ' +
'23-14 93-3 58-56 64-27 85-87 61-101 36-37 37-85 60-63 37-26 41-74 103-87 78-36 69-97 ' +
'77-42 97-52 57-25 27-34 68-28 95-104 18-96 4-15 60-48 17-14 51-31 83-40 48-75 16-99 ' +
'79-65 70-14 3-27 0-62 21-101 44-59 4-24 34-52 65-8 102-66 67-24 11-26 39-78 6-103 ' +
'4-71 70-4';
const graph = buildGraphologyGraph({
nodes: Array.from({ length: 105 }, (_, i) => ({
id: String(i),
name: String(i),
filePath: `/src/${i}.ts`,
type: 'Function',
})),
edges: EDGES.split(/\s+/).map((edge) => {
const [source, target] = edge.split('-').map(Number);
return [source, target];
}),
symbolCount: 105,
isLarge: false,
});
const result = await runGraphologyLeiden(graph, false, 'graphology', 500);
console.log(JSON.stringify(result));
// No explicit exit: a leaked worker must keep this process alive for the parent to detect.

View file

@ -1,4 +1,4 @@
import { vi } from 'vitest';
import { vi, type Mock } from 'vitest';
import type * as SafeParseModule from '../../src/core/tree-sitter/safe-parse.js';
/**
@ -43,7 +43,7 @@ import type * as SafeParseModule from '../../src/core/tree-sitter/safe-parse.js'
* });
*/
export async function buildSafeParseMock(
spy: ReturnType<typeof vi.fn>,
spy: Mock<typeof SafeParseModule.parseSourceSafe>,
): Promise<typeof SafeParseModule> {
const actual = await vi.importActual<typeof SafeParseModule>(
'../../src/core/tree-sitter/safe-parse.js',

View file

@ -30,10 +30,8 @@ export interface ParsedSource {
const PARSE_PLUGINS: NonNullable<Parameters<typeof parse>[1]>['plugins'] = [
'typescript',
'explicitResourceManagement',
'importAttributes',
'decoratorAutoAccessors',
['decorators', { decoratorsBeforeExport: true }],
'decorators',
];
export function forEachChild(node: Node, visit: (child: AstNode) => void): void {

View file

@ -17,7 +17,19 @@ export default class PerfSequencer extends BaseSequencer {
override async shard(specs: TestSpecification[]): Promise<TestSpecification[]> {
const shard = this.ctx.config.shard;
if (!shard) return specs;
const groups = assignShards(specs, shard.count, specWeight, (spec) => spec.moduleId);
const groups = assignShards(
specs,
shard.count,
(spec) => {
const config = spec.project.config;
const fileParallelism =
'fileParallelism' in config && typeof config.fileParallelism === 'boolean'
? config.fileParallelism
: undefined;
return specWeight({ moduleId: spec.moduleId, project: { config: { fileParallelism } } });
},
(spec) => spec.moduleId,
);
return groups[shard.index - 1] ?? [];
}
}

View file

@ -5,7 +5,8 @@
* without touching the filesystem or LadybugDB.
*/
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { KnowledgeGraph, NodeLabel, RelationshipType } from '../../src/core/graph/types.js';
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
import type { NodeLabel, RelationshipType } from 'gitnexus-shared';
export interface TestNodeInput {
id: string;

View file

@ -0,0 +1,451 @@
/**
* Exercise the CLI -> native staged DB -> durable checkpoint -> SIGKILL ->
* isolated recovery -> graph rebuild -> publication chain. The endpoint is
* local and deterministic; request text is the billing/reuse oracle.
*/
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../helpers/cli-entry.js';
const DIMS = 8;
const NODE_COUNT = 5_128;
const DEADLINE = process.env.CI ? 180_000 : 120_000;
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const adapterUrl = pathToFileURL(path.join(packageRoot, 'src/core/lbug/lbug-adapter.ts')).href;
interface CheckpointMeta {
repoPath: string;
stats?: { embeddings?: number };
embeddingCheckpoint?: {
nodesProcessed: number;
chunksProcessed: number;
pendingNodeIds?: string[];
recovery?: { stagingFile: string; schemaFingerprint: string; unsafeNodeIds: string[] };
};
}
interface Run {
child: ChildProcess;
output: () => string;
done: Promise<{ code: number | null; signal: NodeJS.Signals | null; output: string }>;
}
let root: string;
let stoppedRepo: string;
let server: http.Server;
let endpoint: string;
let submitted: string[] = [];
let completed: string[] = [];
let durableTexts: string[] = [];
const activeWindowCompleted: string[] = [];
let held: string[] = [];
let stopAfterCheckpoint = false;
let currentRepo: string;
let gateResolve: (() => void) | undefined;
const running = new Set<ChildProcess>();
function readMeta(repo: string): CheckpointMeta {
return JSON.parse(fs.readFileSync(path.join(repo, '.gitnexus', 'gitnexus.json'), 'utf8'));
}
function cliEnv(repo: string): NodeJS.ProcessEnv {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith('GITNEXUS_EMBEDDING_') || key.startsWith('GITNEXUS_STORAGE_'))
delete env[key];
}
return {
...env,
GITNEXUS_HOME: path.join(root, `home-${path.basename(repo)}`),
GITNEXUS_SHARED_STORE: 'off',
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
GITNEXUS_LBUG_BUFFER_POOL_SIZE: String(256 * 1024 * 1024),
GITNEXUS_EMBEDDING_URL: endpoint,
GITNEXUS_EMBEDDING_MODEL: 'staged-recovery-fixture',
GITNEXUS_EMBEDDING_DIMS: String(DIMS),
GITNEXUS_EMBEDDING_BATCH_SIZE: '5000',
GITNEXUS_EMBEDDING_SUB_BATCH_SIZE: '64',
GITNEXUS_EMBEDDING_MAX_ATTEMPTS: '1',
GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT: '0',
GITNEXUS_MEMORY: 'off',
// SIGKILL skips process exit hooks; keep orphaned cache/export spills in
// this suite's owned directory so afterAll can remove them as well.
TMPDIR: path.join(root, 'tmp'),
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=2048`.trim(),
CI: '1',
};
}
function runAnalyze(repo: string, flags: string[] = [], onOutput?: (output: string) => void): Run {
let output = '';
const child = spawn(
process.execPath,
[
...CLI_SPAWN_PREFIX,
'analyze',
repo,
'--no-share',
'--skip-skills',
'--skip-fts',
'--workers',
'1',
...flags,
],
{ cwd: repo, env: cliEnv(repo), stdio: ['ignore', 'pipe', 'pipe'] },
);
running.add(child);
const timeout = setTimeout(() => child.kill('SIGKILL'), DEADLINE);
const collect = (chunk: Buffer) => {
output += chunk.toString();
onOutput?.(output);
};
child.stdout?.on('data', collect);
child.stderr?.on('data', collect);
const done = new Promise<{ code: number | null; signal: NodeJS.Signals | null; output: string }>(
(resolve, reject) => {
child.once('error', reject);
child.once('close', (code, signal) => {
clearTimeout(timeout);
running.delete(child);
resolve({ code, signal, output });
});
},
);
return { child, done, output: () => output };
}
async function successfulAnalyze(repo: string, flags: string[] = []): Promise<string[]> {
submitted = [];
stopAfterCheckpoint = false;
currentRepo = repo;
const result = await runAnalyze(repo, flags).done;
expect(result.output, `CLI exited ${result.code}, signal ${result.signal}`).not.toContain(
'SIGABRT',
);
expect(result.code, result.output).toBe(0);
return [...submitted];
}
function cloneStoppedRepo(name: string): string {
const repo = path.join(root, name);
fs.cpSync(stoppedRepo, repo, { recursive: true });
for (const filename of ['gitnexus.json', 'meta.json']) {
const target = path.join(repo, '.gitnexus', filename);
const meta = JSON.parse(fs.readFileSync(target, 'utf8'));
meta.repoPath = repo;
meta.storagePath = path.join(repo, '.gitnexus');
fs.writeFileSync(target, JSON.stringify(meta));
}
return repo;
}
function readPublishedRows(
repo: string,
): Array<{ nodeId: string; chunkIndex: number; embedding: number[] }> {
// Keep native handles out of the vitest fork, and wait for clean teardown
// before accepting the receipt. Every read opens only a published DB.
const receiptPath = path.join(root, `rows-${path.basename(repo)}.json`);
const script = `
const adapter = await import(${JSON.stringify(adapterUrl)});
const fs = await import('node:fs');
await adapter.initLbug(${JSON.stringify(path.join(repo, '.gitnexus', 'lbug'))});
try {
const rows = await adapter.executeQuery('MATCH (e:CodeEmbedding) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.embedding AS embedding');
fs.writeFileSync(${JSON.stringify(receiptPath)}, JSON.stringify(rows));
console.log('ROWS_RECEIPT:' + rows.length);
} finally { await adapter.closeLbug(); }
`;
const result = spawnSync(
process.execPath,
['--import', tsxLoaderUrl(), '--input-type=module', '-e', script],
{
cwd: packageRoot,
env: cliEnv(repo),
encoding: 'utf8',
timeout: 30_000,
},
);
try {
const diagnostic =
`${result.error ?? ''} ${result.signal ?? ''}\n${result.stderr}\n${result.stdout}`.slice(
0,
4096,
);
expect(result.status, diagnostic).toBe(0);
expect(result.stdout).toMatch(/ROWS_RECEIPT:\d+/);
return JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
} finally {
fs.rmSync(receiptPath, { force: true });
}
}
function expectPublishedComplete(repo: string, expectedNodes: number): void {
const meta = readMeta(repo);
expect(meta.embeddingCheckpoint).toBeUndefined();
const rows = readPublishedRows(repo);
expect(new Set(rows.map((row) => row.nodeId)).size).toBe(expectedNodes);
expect(meta.stats?.embeddings).toBe(rows.length);
const byNode = new Map<string, number[]>();
for (const row of rows) {
expect(row.embedding).toHaveLength(DIMS);
expect(row.embedding.every(Number.isFinite)).toBe(true);
const indices = byNode.get(row.nodeId) ?? [];
indices.push(row.chunkIndex);
byNode.set(row.nodeId, indices);
}
for (const indices of byNode.values()) {
expect(indices.sort((a, b) => a - b)).toEqual(
Array.from({ length: indices.length }, (_, index) => index),
);
}
expect(
fs.readdirSync(path.join(repo, '.gitnexus')).filter((name) => name.startsWith('lbug.staging.')),
).toEqual([]);
}
beforeAll(async () => {
if (process.platform === 'win32') return;
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-staged-recovery-e2e-'));
fs.mkdirSync(path.join(root, 'tmp'));
stoppedRepo = path.join(root, 'interrupted');
currentRepo = stoppedRepo;
fs.mkdirSync(stoppedRepo);
const shortFunctions = Array.from(
{ length: NODE_COUNT - 1 },
(_, index) => `export function recoverable${index}() { return ${index}; }`,
);
// Multi-chunk nodes must be reused as a complete group, including their tail.
const longFunction = `export function longRecoverable() {\n${Array.from({ length: 80 }, (_, index) => ` // retained chunk marker ${index} ${'x'.repeat(80)}`).join('\n')}\n return 42;\n}`;
fs.writeFileSync(
path.join(stoppedRepo, 'functions.ts'),
`${longFunction}\n${shortFunctions.join('\n')}\n`,
);
const gitEnv = {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@test',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@test',
};
for (const args of [['init'], ['add', 'functions.ts'], ['commit', '-m', 'recovery fixture']]) {
const result = spawnSync('git', args, { cwd: stoppedRepo, env: gitEnv, encoding: 'utf8' });
expect(result.status, result.stderr).toBe(0);
}
server = http.createServer((request, response) => {
let body = '';
request.on('data', (chunk) => {
body += chunk;
});
request.on('end', () => {
const { input } = JSON.parse(body) as { input: string[] };
submitted.push(...input);
if (
stopAfterCheckpoint &&
(readMeta(currentRepo).embeddingCheckpoint?.nodesProcessed ?? 0) >= 5000
) {
if (activeWindowCompleted.length > 0) {
held = [...input];
gateResolve?.();
// One sub-batch has already inserted rows inside the unsafe window.
// Awaiting the next real request gates a crash before it completes.
return;
}
durableTexts = [...completed];
activeWindowCompleted.push(...input);
}
completed.push(...input);
response.writeHead(200, { 'content-type': 'application/json' });
response.end(
JSON.stringify({
data: input.map((text, index) => ({
index,
embedding: Array.from(
{ length: DIMS },
(_, dimension) => ((text.length + dimension * 17) % 101) / 101,
),
})),
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address() as { port: number };
endpoint = `http://127.0.0.1:${address.port}/v1`;
await successfulAnalyze(stoppedRepo, ['--index-only']);
expect(readMeta(stoppedRepo).stats?.embeddings ?? 0).toBe(0);
completed = [];
held = [];
stopAfterCheckpoint = true;
const gate = new Promise<void>((resolve) => {
gateResolve = resolve;
});
const first = runAnalyze(stoppedRepo, ['--force', '--embeddings']);
await Promise.race([
gate,
first.done.then((result) => {
throw new Error(`CLI exited before recovery gate: ${result.output}`);
}),
]);
first.child.kill('SIGKILL');
expect((await first.done).signal).toBe('SIGKILL');
const interrupted = readMeta(stoppedRepo);
expect(interrupted.embeddingCheckpoint?.nodesProcessed).toBe(5000);
expect(interrupted.embeddingCheckpoint?.recovery?.stagingFile).toMatch(/^lbug\.staging\./);
expect(interrupted.stats?.embeddings ?? 0).toBe(0);
expect(completed.length).toBeGreaterThanOrEqual(5000);
expect(completed.filter((text) => text.includes('longRecoverable')).length).toBeGreaterThan(1);
expect(activeWindowCompleted).toHaveLength(64);
expect(held.length).toBeGreaterThan(0);
gateResolve = undefined;
stopAfterCheckpoint = false;
}, DEADLINE * 2);
afterAll(async () => {
for (const child of running) child.kill('SIGKILL');
await Promise.all(
[...running].map(
(child) => new Promise<void>((resolve) => child.once('close', () => resolve())),
),
);
if (server) {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
if (root) fs.rmSync(root, { recursive: true, force: true });
});
// Atomic publication is POSIX-specific; Windows uses the in-place path.
describe.skipIf(process.platform === 'win32')(
'interrupted staged embedding recovery (real CLI and native DB)',
{ concurrent: false },
() => {
it.each([
['plain', []],
['forced', ['--force', '--embeddings']],
] as const)(
'%s retry bills only unfinished groups and publishes an honest count',
async (name, flags) => {
const repo = cloneStoppedRepo(name);
const texts = await successfulAnalyze(repo, [...flags]);
const durable = new Set(durableTexts);
expect(texts.filter((text) => durable.has(text))).toEqual([]);
for (const text of held) expect(texts).toContain(text);
for (const text of activeWindowCompleted) expect(texts).toContain(text);
expect(texts.length).toBe(128);
expectPublishedComplete(repo, NODE_COUNT);
},
DEADLINE,
);
it(
'manual checkpoint opt-out completes a staged retry without resubmitting durable chunks',
async () => {
const repo = cloneStoppedRepo('manual-checkpoint-opt-out');
const previous = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT;
process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = '0';
try {
const texts = await successfulAnalyze(repo, ['--force', '--embeddings']);
expect(texts.filter((text) => new Set(durableTexts).has(text))).toEqual([]);
expect(texts.length).toBe(128);
expectPublishedComplete(repo, NODE_COUNT);
} finally {
if (previous === undefined) delete process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT;
else process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = previous;
}
},
DEADLINE,
);
it(
'survives another crash after harvesting but before the replacement is durable',
async () => {
const repo = cloneStoppedRepo('crash-again');
const source = readMeta(repo).embeddingCheckpoint?.recovery?.stagingFile;
expect(source).toBeDefined();
if (!source) throw new Error('fixture has no retained generation');
submitted = [];
currentRepo = repo;
let killed = false;
const retry = runAnalyze(repo, [], (output) => {
if (!killed && /Recovered \d+ complete staged embedding chunk/.test(output)) {
killed = true;
retry.child.kill('SIGKILL');
}
});
const result = await retry.done;
expect(killed, result.output).toBe(true);
expect(result.signal).toBe('SIGKILL');
expect(submitted).toEqual([]);
expect(readMeta(repo).embeddingCheckpoint?.recovery?.stagingFile).toBe(source);
expect(fs.existsSync(path.join(repo, '.gitnexus', source))).toBe(true);
const finalTexts = await successfulAnalyze(repo);
expect(finalTexts.filter((text) => new Set(durableTexts).has(text))).toEqual([]);
expect(finalTexts.length).toBe(128);
expectPublishedComplete(repo, NODE_COUNT);
},
DEADLINE * 2,
);
it(
'regenerates changed content and removes deleted nodes while reusing the other complete groups',
async () => {
const repo = cloneStoppedRepo('changed');
const source = path.join(repo, 'functions.ts');
const content = fs
.readFileSync(source, 'utf8')
.replace(
'export function recoverable5() { return 5; }',
'export function recoverable5() { return 999999; }',
)
.replace(
'export function recoverable6() { return 6; }',
'// deleted function retains line offsets',
);
fs.writeFileSync(source, content);
const texts = await successfulAnalyze(repo);
expect(texts.some((text) => text.includes('recoverable5') && text.includes('999999'))).toBe(
true,
);
expect(texts.some((text) => text.includes('recoverable6'))).toBe(false);
expect(texts.filter((text) => new Set(durableTexts).has(text))).toEqual([]);
expect(texts.length).toBe(129);
expectPublishedComplete(repo, NODE_COUNT - 1);
},
DEADLINE,
);
it(
'a forced retry with a different model does not import the staged cache',
async () => {
const repo = cloneStoppedRepo('different-model');
const texts = await successfulAnalyze(repo, [
'--force',
'--embeddings',
'--embedding-model',
'different-model',
]);
for (const text of durableTexts) expect(texts).toContain(text);
expect(texts.length).toBeGreaterThanOrEqual(NODE_COUNT);
expectPublishedComplete(repo, NODE_COUNT);
},
DEADLINE,
);
it(
'explicit drop abandons staged vectors without contacting the provider',
async () => {
const repo = cloneStoppedRepo('drop');
expect(await successfulAnalyze(repo, ['--force', '--drop-embeddings'])).toEqual([]);
expect(readMeta(repo).embeddingCheckpoint).toBeUndefined();
expect(readMeta(repo).stats?.embeddings ?? 0).toBe(0);
expect(readPublishedRows(repo)).toEqual([]);
},
DEADLINE,
);
},
);

View file

@ -51,8 +51,8 @@ withTestLbugDB(
});
expect(response.status).toBe(200);
const body = await response.json();
expect(Array.isArray(body.result)).toBe(true);
expect(body.result[0].one).toBe(1);
expect(body).toHaveProperty('result', expect.any(Array));
expect(body).toHaveProperty('result.0.one', 1);
});
it('returns 403 for a write query on read-only HTTP path', async () => {
@ -65,7 +65,10 @@ withTestLbugDB(
});
expect(response.status).toBe(403);
const body = await response.json();
expect(body.error).toContain('Write queries are not allowed');
expect(body).toHaveProperty(
'error',
expect.stringContaining('Write queries are not allowed'),
);
});
it('returns 400 for invalid params payload', async () => {
@ -76,7 +79,7 @@ withTestLbugDB(
});
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain('"params"');
expect(body).toHaveProperty('error', expect.stringContaining('"params"'));
});
it('returns 400 when cypher is missing', async () => {
@ -87,7 +90,7 @@ withTestLbugDB(
});
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain('Missing "cypher"');
expect(body).toHaveProperty('error', expect.stringContaining('Missing "cypher"'));
});
});
},

View file

@ -21,6 +21,11 @@ const AUGMENT_SEED_DATA = [
`CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 15, isExported: true, content: 'function login authenticates user credentials', description: 'user login'})`,
`CREATE (n:Function {id: 'func:validate', name: 'validate', filePath: 'src/auth.ts', startLine: 17, endLine: 25, isExported: true, content: 'function validate checks user input', description: 'input validation'})`,
`CREATE (n:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash computes bcrypt hash', description: 'password hashing'})`,
...Array.from(
{ length: 5 },
(_, i) =>
`CREATE (n:Function {id: 'a:login-${i}', name: 'loginPartial${i}', filePath: 'src/login-partial-${i}.ts', startLine: 1, endLine: 2, isExported: true, content: 'partial login match ${i}', description: 'login partial'})`,
),
// Class / Method / Interface nodes
`CREATE (n:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService handles authentication', description: 'auth service'})`,
@ -130,23 +135,40 @@ withTestLbugDB(
expect(result).toBe('');
});
// ─── Negative-safety: fallback must stay gated on !ftsAvailable ───
//
// When FTS is available but happens to return zero BM25 hits, the
// CONTAINS fallback must NOT fire — preserving the original early-return
// semantics. If anyone later loosens the gate to `symbolMatches.length
// === 0` alone, this test fails.
it('does NOT fire CONTAINS fallback when FTS is available but BM25 returns empty', async () => {
it('falls back to graph names when FTS is available but BM25 returns empty', async () => {
const bm25 = await import('../../src/core/search/bm25-index.js');
const spy = vi
.spyOn(bm25, 'searchFTSFromLbug')
.mockResolvedValue({ results: [], ftsAvailable: true });
try {
// 'login' WOULD match a graph node via CONTAINS, but FTS is available
// and empty → fallback gate must hold → result must be ''.
// FTS health does not imply its top file results contain the symbol's
// definition. The graph-name fallback must recover the exact node.
const result = await augment('login', handle.dbPath);
expect(result).toBe('');
expect(result).toContain('[GitNexus]');
expect(result).toMatch(/^login \(src\/auth\.ts\)$/m);
} finally {
spy.mockRestore();
}
});
it('finds an exact-name symbol when FTS ranks only mentioning files', async () => {
const bm25 = await import('../../src/core/search/bm25-index.js');
const spy = vi.spyOn(bm25, 'searchFTSFromLbug').mockResolvedValue({
results: Array.from({ length: 5 }, (_, i) => ({
filePath: `src/caller-${i}.ts`,
score: 100 - i,
rank: i + 1,
nodeIds: [`file:caller-${i}`],
})),
ftsAvailable: true,
});
try {
// Five partial login matches have IDs that sort before func:login, so
// the five-row fallback only retains the exact node when exact names
// rank ahead of partial matches.
const result = await augment('login', handle.dbPath);
expect(result).toContain('[GitNexus]');
expect(result).toMatch(/^login \(src\/auth\.ts\)$/m);
} finally {
spy.mockRestore();
}

View file

@ -490,6 +490,7 @@ describe('U4 (#2085 M5) — emitFileCdg', () => {
const unsound: FunctionCfg = {
filePath: 'spin.ts',
functionStartLine: 1,
functionEndLine: 4,
functionStartColumn: 0,
entryIndex: 0,
exitIndex: 3,

View file

@ -68,7 +68,7 @@ describe('clean --stale leftover branch slots (#3331)', () => {
}
vi.spyOn(process, 'cwd').mockReturnValue(repo);
vi.spyOn(console, 'log').mockImplementation(() => {});
return { dir, storagePath };
return { repo, dir, storagePath };
}
it('reclaims a slot after the git branch is deleted', async () => {

View file

@ -3,7 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
import { DATA_ROUTE_TABLE_SOURCE } from '../../src/core/ingestion/route-extractors/data-route-table.js';
import {
loadParseCache,

View file

@ -20,7 +20,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
import { DISPATCH_GUARD_SOURCE } from '../../src/core/ingestion/route-extractors/dispatch-guard.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'dispatch-guard-app');

View file

@ -13,7 +13,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'django-subdir-app');

View file

@ -59,7 +59,6 @@ describe('doc-comment description end-to-end (issue #2270)', () => {
it('surfaces an exported function JSDoc as its node description through the pipeline', async () => {
const result: PipelineResult = await runPipelineFromRepo(createTsRepo(), () => {}, {
skipGraphPhases: true,
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
workerPoolSize: 2,
});
@ -79,7 +78,6 @@ describe('doc-comment description end-to-end (issue #2270)', () => {
async () => {
const result: PipelineResult = await runPipelineFromRepo(createSwiftRepo(), () => {}, {
skipGraphPhases: true,
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
workerPoolSize: 2,
});

View file

@ -1,7 +1,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const EXPO_APP = path.resolve(__dirname, '..', 'fixtures', 'expo-app');

View file

@ -21,7 +21,7 @@ import * as os from 'node:os';
import Parser from 'tree-sitter';
import Python from 'tree-sitter-python';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
import { PYTHON_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/python.js';
import {
loadParseCache,
@ -141,6 +141,7 @@ describe('FastAPI composed route constants — ingestion↔group parity (#2391 R
return p.parse(src);
};
const ctx = PYTHON_HTTP_PLUGIN.prepareRepo?.({
repoPath: '/test-repo',
files: Object.keys(files),
parser,
readFile: (r) => files[r] ?? null,

View file

@ -46,7 +46,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'fastapi-prefix-app');

View file

@ -92,7 +92,7 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
await tmpHandle.cleanup();
});
it.each(['all', 'Function'] as const)(
it.for(['all', 'Function'] as const)(
'a warm session repairs missing %s indexes without restarting',
{ timeout: 60_000 },
async (missing, ctx) => {

View file

@ -32,7 +32,6 @@ function createGoRepo(): string {
async function runWorker(): Promise<PipelineResult> {
return runPipelineFromRepo(createGoRepo(), () => {}, {
skipGraphPhases: true,
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
workerPoolSize: 2,
});
}

View file

@ -32,7 +32,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import { makeScopeId, type ParsedFile, type SymbolDefinition } from 'gitnexus-shared';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import {
emitGoScopeCaptures,
@ -473,13 +473,10 @@ describe('Go scope-capture O(n^2) regression tripwire', () => {
*/
function generateSyntheticInterfaceData(interfaceCount: number, structCount: number): ParsedFile[] {
const defs: SymbolDefinition[] = [];
const ifaceIds: string[] = [];
const structIds: string[] = [];
// Interfaces
for (let i = 0; i < interfaceCount; i++) {
const ifaceId = `iface:Repo${i}`;
ifaceIds.push(ifaceId);
defs.push({
nodeId: ifaceId,
filePath: 'repo.go',
@ -515,37 +512,34 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num
// Structs — each implements all interfaces
for (let s = 0; s < structCount; s++) {
const structId = `struct:Impl${s}`;
structIds.push(structId);
defs.push({
nodeId: structId,
filePath: 'repo.go',
type: 'Struct',
qualifiedName: `Impl${s}`,
});
for (let i = 0; i < interfaceCount; i++) {
defs.push({
nodeId: `struct:Impl${s}.Repo${i}.Find`,
filePath: 'repo.go',
type: 'Method',
qualifiedName: `Impl${s}.Find`,
ownerId: structId,
parameterCount: 1,
requiredParameterCount: 1,
parameterTypes: ['string'],
returnType: 'User',
});
defs.push({
nodeId: `struct:Impl${s}.Repo${i}.Save`,
filePath: 'repo.go',
type: 'Method',
qualifiedName: `Impl${s}.Save`,
ownerId: structId,
parameterCount: 1,
requiredParameterCount: 1,
parameterTypes: ['User'],
returnType: 'error',
});
}
defs.push({
nodeId: `struct:Impl${s}.Find`,
filePath: 'repo.go',
type: 'Method',
qualifiedName: `Impl${s}.Find`,
ownerId: structId,
parameterCount: 1,
requiredParameterCount: 1,
parameterTypes: ['string'],
returnType: 'User',
});
defs.push({
nodeId: `struct:Impl${s}.Save`,
filePath: 'repo.go',
type: 'Method',
qualifiedName: `Impl${s}.Save`,
ownerId: structId,
parameterCount: 1,
requiredParameterCount: 1,
parameterTypes: ['User'],
returnType: 'error',
});
}
// BadStructs — wrong Save signature (string instead of User), should NOT match
@ -557,44 +551,45 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num
type: 'Struct',
qualifiedName: `Bad${b}`,
});
for (let i = 0; i < interfaceCount; i++) {
defs.push({
nodeId: `struct:Bad${b}.Repo${i}.Find`,
filePath: 'repo.go',
type: 'Method',
qualifiedName: `Bad${b}.Find`,
ownerId: badId,
parameterCount: 1,
requiredParameterCount: 1,
parameterTypes: ['string'],
returnType: 'User',
});
// Mismatched Save: string param instead of User
defs.push({
nodeId: `struct:Bad${b}.Repo${i}.Save`,
filePath: 'repo.go',
type: 'Method',
qualifiedName: `Bad${b}.Save`,
ownerId: badId,
parameterCount: 1,
requiredParameterCount: 1,
parameterTypes: ['string'],
returnType: 'error',
});
}
defs.push({
nodeId: `struct:Bad${b}.Find`,
filePath: 'repo.go',
type: 'Method',
qualifiedName: `Bad${b}.Find`,
ownerId: badId,
parameterCount: 1,
requiredParameterCount: 1,
parameterTypes: ['string'],
returnType: 'User',
});
// Mismatched Save: string param instead of User
defs.push({
nodeId: `struct:Bad${b}.Save`,
filePath: 'repo.go',
type: 'Method',
qualifiedName: `Bad${b}.Save`,
ownerId: badId,
parameterCount: 1,
requiredParameterCount: 1,
parameterTypes: ['string'],
returnType: 'error',
});
}
return [
{
filePath: 'repo.go',
language: 'go',
moduleScope: makeScopeId({
filePath: 'repo.go',
range: { startLine: 0, startCol: 0, endLine: 0, endCol: 0 },
kind: 'Module',
}),
scopes: [],
imports: [],
parsedImports: [],
localDefs: defs,
referenceSites: [],
},
] as ParsedFile[];
];
}
/**
@ -603,6 +598,15 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num
* path (structIdsByMethodName intersection) keeps this well under budget.
*/
describe('Go structural interface detection O(n²) regression tripwire', () => {
it('uses legal Go method sets with one declaration per receiver and method name', () => {
const [parsed] = generateSyntheticInterfaceData(3, 3);
const methods = parsed.localDefs.filter((def) => def.type === 'Method');
const identities = methods.map((def) => `${def.ownerId}:${def.qualifiedName}`);
expect(new Set(identities).size).toBe(methods.length);
// Three interfaces, three matching structs and three negative controls.
expect(methods).toHaveLength(18);
});
it('detects implementations for 100 interfaces × 100 structs within budget', () => {
const IFACE_COUNT = 100;
const STRUCT_COUNT = 100;
@ -776,7 +780,7 @@ describe.skipIf(!BENCH_ENABLED)('Go structural interface detection split-phase b
console.log('\nScaling analysis (total time ratio / pair-count ratio):');
// We can't split phases without exporting internals, but we can report
// how total time scales relative to pair count.
// The detection loop is O(I×C×M×O_a) where O_a grows with I in this
// synthetic case, so pair-count ratio alone underestimates expected growth.
// Each receiver declares Find and Save once, as real Go requires.
// Required signatures stay constant while interface/struct pairs grow.
}, 300_000);
});

View file

@ -0,0 +1,765 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import lbug from '@ladybugdb/core';
import * as adapter from '../../src/core/lbug/lbug-adapter.js';
import { executeParameterized } from '../../src/core/lbug/pool-adapter.js';
import { closeQueryResults } from '../../src/core/lbug/query-result-utils.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { retryRename } from '../../src/storage/fs-atomic.js';
import { getStoragePaths, registerRepo, saveMeta } from '../../src/storage/repo-manager.js';
import { createTempDir } from '../helpers/test-db.js';
const REPO = 'query-integrity';
const nodes = {
alpha: { id: 'Function:src/alpha.ts:runSweep', name: 'runSweep', filePath: 'src/alpha.ts' },
alphaCaller: {
id: 'Function:src/α/caller.ts:appelÉ',
name: 'appelÉ',
filePath: 'src/α/caller.ts',
},
alphaOtherCaller: {
id: 'Function:src/other.ts:runOther',
name: 'runOther',
filePath: 'src/other.ts',
},
alphaRoot: { id: 'Function:src/root.ts:startSweep', name: 'startSweep', filePath: 'src/root.ts' },
alphaReader: {
id: 'Function:src/reader.ts:readSweep',
name: 'readSweep',
filePath: 'src/reader.ts',
},
beta: {
id: 'Function:src/beta.ts:extractLeadingNumber',
name: 'extractLeadingNumber',
filePath: 'src/beta.ts',
},
betaCaller: {
id: 'Function:src/number.ts:parseNumber',
name: 'parseNumber',
filePath: 'src/number.ts',
},
betaReader: {
id: 'Function:src/number-view.ts:readNumber',
name: 'readNumber',
filePath: 'src/number-view.ts',
},
} as const;
const processes = {
alpha: {
id: 'process:alpha',
label: 'Sweep flow',
entry: nodes.alphaRoot,
terminal: nodes.alpha,
stepCount: 3,
},
alphaOther: {
id: 'process:alpha-other',
label: 'Other sweep flow',
entry: nodes.alphaOtherCaller,
terminal: nodes.alpha,
stepCount: 2,
},
beta: {
id: 'process:beta',
label: 'Number flow',
entry: nodes.betaCaller,
terminal: nodes.beta,
stepCount: 2,
},
} as const;
type NodeIdentity = { id: string; name: string; filePath: string };
type Membership = { id: string; label: string; processType: string; step: number };
type ImpactRow = NodeIdentity & {
relationType: string;
confidence: number;
processes: Membership[];
};
type ContextRef = { uid: string; name: string; filePath: string };
type Target = 'alpha' | 'beta';
const membership = (
process: (typeof processes)[keyof typeof processes],
step: number,
): Membership => ({
id: process.id,
label: process.label,
processType: 'intra_community',
step,
});
const affectedProcess = (process: (typeof processes)[keyof typeof processes], hits: number) => ({
name: process.entry.name,
type: 'Function',
filePath: process.entry.filePath,
affected_process_count: 1,
total_hits: hits,
earliest_broken_step: 0,
});
const oracle = {
alpha: {
count: 3,
direct: 2,
byDepth: {
1: [
{
...nodes.alphaOtherCaller,
relationType: 'CALLS',
confidence: 1,
processes: [membership(processes.alphaOther, 0)],
},
{
...nodes.alphaCaller,
relationType: 'CALLS',
confidence: 1,
processes: [membership(processes.alpha, 1)],
},
],
2: [
{
...nodes.alphaRoot,
relationType: 'CALLS',
confidence: 1,
processes: [membership(processes.alpha, 0)],
},
],
},
callers: [nodes.alphaOtherCaller, nodes.alphaCaller],
accesses: [nodes.alphaReader],
processes: [
{ id: processes.alpha.id, name: processes.alpha.label, step_index: 2, step_count: 3 },
{
id: processes.alphaOther.id,
name: processes.alphaOther.label,
step_index: 1,
step_count: 2,
},
],
affectedProcesses: [
affectedProcess(processes.alpha, 2),
affectedProcess(processes.alphaOther, 1),
],
},
beta: {
count: 1,
direct: 1,
byDepth: {
1: [
{
...nodes.betaCaller,
relationType: 'CALLS',
confidence: 1,
processes: [membership(processes.beta, 0)],
},
],
},
callers: [nodes.betaCaller],
accesses: [nodes.betaReader],
processes: [
{ id: processes.beta.id, name: processes.beta.label, step_index: 1, step_count: 2 },
],
affectedProcesses: [affectedProcess(processes.beta, 1)],
},
} as const;
// Compare the values returned by the native engine with a hand-written graph
// oracle, rather than accepting a repeated (and potentially wrong) first result.
function expectImpact(
result: Awaited<ReturnType<LocalBackend['callTool']>>,
target: Target,
summaryOnly: boolean,
): void {
const expected = oracle[target];
expect(result).not.toHaveProperty('error');
expect(result).not.toHaveProperty('partial');
expect(result.target).toMatchObject(nodes[target]);
expect(result.direction).toBe('upstream');
expect(result.impactedCount).toBe(expected.count);
expect(result.risk).toBe('LOW');
expect(result.epistemic).toBe('exact');
expect(result.summary).toEqual({
direct: expected.direct,
processes_affected: expected.affectedProcesses.length,
modules_affected: 0,
});
expect(result.byDepthCounts).toEqual(target === 'alpha' ? { 1: 2, 2: 1 } : { 1: 1 });
expect(result.affected_processes).toEqual(expected.affectedProcesses);
expect(result.affected_modules).toEqual([]);
expect(result.affected_routes).toEqual([]);
if (summaryOnly) {
expect(result).not.toHaveProperty('byDepth');
} else {
const byDepth = Object.fromEntries(
Object.entries(result.byDepth).map(([depth, rows]) => [
depth,
(rows as ImpactRow[]).map(
({ id, name, filePath, relationType, confidence, processes: memberships }) => ({
id,
name,
filePath,
relationType,
confidence,
processes: memberships,
}),
),
]),
);
expect(byDepth).toEqual(expected.byDepth);
}
}
function expectContext(
result: Awaited<ReturnType<LocalBackend['callTool']>>,
target: Target,
): void {
const expected = oracle[target];
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('found');
expect(result.symbol).toMatchObject({
uid: nodes[target].id,
name: nodes[target].name,
filePath: nodes[target].filePath,
});
expect(result.epistemic).toBe('exact');
const incoming = Object.fromEntries(
Object.entries(result.incoming).map(([type, refs]) => [
type,
(refs as ContextRef[]).map(({ uid, name, filePath }) => ({ id: uid, name, filePath })),
]),
);
expect(incoming).toEqual({ calls: expected.callers, accesses: expected.accesses });
expect(result.outgoing).toEqual({});
expect(result.processes).toEqual(expected.processes);
}
function edge(source: NodeIdentity, target: NodeIdentity, type: 'CALLS' | 'ACCESSES'): string {
return `MATCH (a:Function {id: '${source.id}'}), (b:Function {id: '${target.id}'}) CREATE (a)-[:CodeRelation {type: '${type}', confidence: 1.0, reason: 'direct', step: 0}]->(b)`;
}
function processStep(
node: NodeIdentity,
process: (typeof processes)[keyof typeof processes],
step: number,
): string {
return `MATCH (n:Function {id: '${node.id}'}), (p:Process {id: '${process.id}'}) CREATE (n)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: 'trace-detection', step: ${step}}]->(p)`;
}
describe('native impact/context result integrity (#3354)', () => {
let temp: Awaited<ReturnType<typeof createTempDir>>;
let backend: LocalBackend;
let lbugPath: string;
beforeAll(async () => {
temp = await createTempDir();
vi.stubEnv('GITNEXUS_HOME', path.join(temp.dbPath, 'home'));
vi.stubEnv('GITNEXUS_STORAGE_PATH', path.join(temp.dbPath, 'index'));
vi.stubEnv('GITNEXUS_SHARED_STORE', 'off');
const paths = getStoragePaths(temp.dbPath);
lbugPath = paths.lbugPath;
// Close the writer before LocalBackend opens its ordinary read pool. No
// mocked registry or injected writable Database bypasses the read path.
await adapter.initLbug(lbugPath);
try {
const seed = [
...Object.values(nodes).map(
(node) =>
`CREATE (:Function {id: '${node.id}', name: '${node.name}', filePath: '${node.filePath}', startLine: 1, endLine: 3})`,
),
...Object.values(processes).map(
(process) =>
`CREATE (:Process {id: '${process.id}', label: '${process.label}', heuristicLabel: '${process.label}', processType: 'intra_community', stepCount: ${process.stepCount}, communities: [], entryPointId: '${process.entry.id}', terminalId: '${process.terminal.id}'})`,
),
edge(nodes.alphaCaller, nodes.alpha, 'CALLS'),
edge(nodes.alphaOtherCaller, nodes.alpha, 'CALLS'),
edge(nodes.alphaRoot, nodes.alphaCaller, 'CALLS'),
edge(nodes.alphaReader, nodes.alpha, 'ACCESSES'),
edge(nodes.betaCaller, nodes.beta, 'CALLS'),
edge(nodes.betaReader, nodes.beta, 'ACCESSES'),
processStep(nodes.alphaRoot, processes.alpha, 0),
processStep(nodes.alphaCaller, processes.alpha, 1),
processStep(nodes.alpha, processes.alpha, 2),
processStep(nodes.alphaOtherCaller, processes.alphaOther, 0),
processStep(nodes.alpha, processes.alphaOther, 1),
processStep(nodes.betaCaller, processes.beta, 0),
processStep(nodes.beta, processes.beta, 1),
];
for (const query of seed) await adapter.executeQuery(query);
await adapter.flushWAL();
} finally {
await adapter.closeLbug();
}
const meta = {
repoPath: temp.dbPath,
storagePath: paths.storagePath,
lastCommit: 'integrity-fixture',
indexedAt: new Date().toISOString(),
scopeExtractionReceipt: 1 as const,
stats: { files: 8, nodes: 11, processes: 3, communities: 0 },
};
await saveMeta(paths.storagePath, meta);
await registerRepo(temp.dbPath, meta, { name: REPO });
backend = new LocalBackend();
expect(await backend.init()).toBe(true);
});
afterAll(async () => {
try {
await backend?.dispose();
} finally {
await adapter.closeLbug();
vi.unstubAllEnvs();
await temp?.cleanup();
}
});
const impact = (target: Target, summaryOnly = false) =>
backend.callTool('impact', {
repo: REPO,
target: nodes[target].name,
direction: 'upstream',
summaryOnly,
});
const context = (target: Target) =>
backend.callTool('context', { repo: REPO, uid: nodes[target].id });
it('returns exact values across identical sequential requests', async () => {
for (let repeat = 0; repeat < 6; repeat++) {
expectImpact(await impact('alpha', true), 'alpha', true);
expectContext(await context('alpha'), 'alpha');
expectImpact(await impact('alpha'), 'alpha', false);
}
});
it('keeps unrelated targets isolated across mixed requests', async () => {
for (const target of ['alpha', 'beta', 'beta', 'alpha'] as const) {
expectImpact(await impact(target, true), target, true);
expectContext(await context(target), target);
expectImpact(await impact(target), target, false);
}
});
it('keeps mixed concurrent prepared reads symbol-specific', async () => {
for (let repeat = 0; repeat < 3; repeat++) {
const results = await Promise.all([
impact('alpha', true),
context('beta'),
impact('beta'),
impact('beta', true),
context('alpha'),
impact('alpha'),
]);
expectImpact(results[0], 'alpha', true);
expectContext(results[1], 'beta');
expectImpact(results[2], 'beta', false);
expectImpact(results[3], 'beta', true);
expectContext(results[4], 'alpha');
expectImpact(results[5], 'alpha', false);
}
});
it('returns exact relation rows directly from the pooled prepared adapter', async () => {
// Warm the pool through the same backend, then check the native row
// boundary independently of the tool's normalization and aggregation.
expectContext(await context('alpha'), 'alpha');
const read = (target: Target) =>
executeParameterized(
lbugPath,
`
MATCH (caller:Function)-[r:CodeRelation]->(target:Function {id: $id})
WHERE r.type IN ['CALLS', 'ACCESSES']
RETURN caller.id AS id, caller.name AS name, caller.filePath AS filePath, r.type AS relationType
ORDER BY id
`,
{ id: nodes[target].id },
);
const expectedRows = (target: Target) =>
[
...oracle[target].callers.map((caller) => ({ ...caller, relationType: 'CALLS' })),
...oracle[target].accesses.map((reader) => ({ ...reader, relationType: 'ACCESSES' })),
].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
for (let repeat = 0; repeat < 4; repeat++) {
expect(await read('alpha')).toEqual(expectedRows('alpha'));
const [beta, alpha] = await Promise.all([read('beta'), read('alpha')]);
expect(beta).toEqual(expectedRows('beta'));
expect(alpha).toEqual(expectedRows('alpha'));
}
});
});
describe('native string projections after checkpointed deletion (#3354)', () => {
it('keeps long symbol identities associated with their source rows across segments', async () => {
const temp = await createTempDir();
const db = new lbug.Database(path.join(temp.dbPath, 'scan.lbug'), 128 * 1024 * 1024);
const conn = new lbug.Connection(db, 4);
const source = Array.from({ length: 10_000 }, (_, startLine) => ({
id: `Function:src/generated/rené-${String(startLine).padStart(5, '0')}.ts:fn${startLine}`,
name: `generated_function_${startLine}_é`,
filePath: `src/generated/rené-${String(startLine).padStart(5, '0')}.ts`,
startLine,
}));
const projection =
'RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine';
const read = async (query: string) => {
const result = await conn.query(query);
try {
const cursor = Array.isArray(result) ? result[0] : result;
return await cursor.getAll();
} finally {
await closeQueryResults(result);
}
};
try {
await read(
'CREATE NODE TABLE Function(id STRING, name STRING, filePath STRING, startLine INT64, PRIMARY KEY(id))',
);
// Separate checkpoints create segment boundaries inside scan vectors.
// LadybugDB 0.18.3's filtered STRING scan could retain another row's
// printable identities here (LadybugDB/ladybug#678, fixed by #737).
for (let batch = 0; batch < 4; batch++) {
const csvPath = path.join(temp.dbPath, `rows-${batch}.csv`);
const csv = source
.slice(batch * 2500, (batch + 1) * 2500)
.map((row) =>
Object.values(row)
.map((value) => JSON.stringify(value))
.join(','),
)
.join('\n');
await fs.writeFile(csvPath, `${csv}\n`);
await read(
`COPY Function FROM ${JSON.stringify(csvPath.replaceAll('\\', '/'))} (HEADER=false)`,
);
await read('CHECKPOINT');
}
expect(await read(`MATCH (n:Function) ${projection} ORDER BY n.startLine`)).toEqual(source);
await read(
'MATCH (n:Function) WHERE n.startLine >= 3000 AND n.startLine < 3400 DETACH DELETE n',
);
await read('CHECKPOINT');
const surviving = source.filter((row) => row.startLine < 3000 || row.startLine >= 3400);
for (let repeat = 0; repeat < 3; repeat++) {
for (const order of ['', ' ORDER BY n.startLine']) {
const rows = await read(`MATCH (n:Function) ${projection}${order}`);
expect(new Set(rows.map((row) => row.id)).size).toBe(surviving.length);
expect(
rows.sort((a, b) => {
if (typeof a.startLine !== 'number' || typeof b.startLine !== 'number') {
throw new Error('Expected numeric startLine values from the database');
}
return a.startLine - b.startLine;
}),
).toEqual(surviving);
}
}
// Point lookups independently verify values in the affected segments;
// a repeatably wrong scan must never become the test's reference answer.
for (const startLine of [1600, 7486]) {
expect(
await read(`MATCH (n:Function {id: '${source[startLine].id}'}) ${projection}`),
).toEqual([source[startLine]]);
}
expect(await read(`MATCH (n:Function {id: '${source[3000].id}'}) ${projection}`)).toEqual([]);
} finally {
try {
await conn.close();
} finally {
try {
await db.close();
} finally {
await temp.cleanup();
}
}
}
});
});
// Windows graph replacement is opt-in in production. The repeated-read
// characterization above remains enabled there; only this POSIX swap is skipped.
describe.skipIf(process.platform === 'win32')('warm backend index replacement (#3354)', () => {
it('reads changed callers, processes and a new symbol through the real freshness window', async () => {
const temp = await createTempDir();
let backend: LocalBackend | undefined;
vi.stubEnv('GITNEXUS_HOME', path.join(temp.dbPath, 'home'));
vi.stubEnv('GITNEXUS_STORAGE_PATH', path.join(temp.dbPath, 'index'));
vi.stubEnv('GITNEXUS_SHARED_STORE', 'off');
const paths = getStoragePaths(temp.dbPath);
const stagedPath = `${paths.lbugPath}.replacement`;
const replacement = {
entry: {
id: 'Function:src/replacement-entry.ts:startReplacement',
name: 'startReplacement',
filePath: 'src/replacement-entry.ts',
},
caller: {
id: 'Function:src/replacement-caller.ts:callReplacement',
name: 'callReplacement',
filePath: 'src/replacement-caller.ts',
},
reader: {
id: 'Function:src/replacement-reader.ts:readReplacement',
name: 'readReplacement',
filePath: 'src/replacement-reader.ts',
},
};
const nextProcess = { id: 'process:replacement', label: 'Replacement flow' };
const oldProcess = processes.alphaOther;
const seed = async (dbPath: string, next: boolean) => {
await adapter.initLbug(dbPath);
try {
const caller = next ? replacement.caller : nodes.alphaOtherCaller;
const reader = next ? replacement.reader : nodes.alphaReader;
const process = next ? nextProcess : oldProcess;
const entry = next ? replacement.entry : caller;
for (const node of [nodes.alpha, caller, reader, ...(next ? [entry] : [])]) {
await adapter.executeQuery(
`CREATE (:Function {id: '${node.id}', name: '${node.name}', filePath: '${node.filePath}', startLine: 1, endLine: 3})`,
);
}
await adapter.executeQuery(
`CREATE (:Process {id: '${process.id}', label: '${process.label}', heuristicLabel: '${process.label}', processType: 'intra_community', stepCount: ${next ? 3 : 2}, communities: [], entryPointId: '${entry.id}', terminalId: '${nodes.alpha.id}'})`,
);
await adapter.executeQuery(edge(caller, nodes.alpha, 'CALLS'));
await adapter.executeQuery(edge(reader, nodes.alpha, 'ACCESSES'));
if (next) await adapter.executeQuery(edge(entry, caller, 'CALLS'));
const steps = next ? [entry, caller, nodes.alpha] : [caller, nodes.alpha];
for (const [step, node] of steps.entries()) {
await adapter.executeQuery(
`MATCH (n:Function {id: '${node.id}'}), (p:Process {id: '${process.id}'}) CREATE (n)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: 'trace-detection', step: ${step}}]->(p)`,
);
}
await adapter.flushWAL();
} finally {
await adapter.closeLbug();
}
};
const processMembership = (next: boolean, step: number) => ({
...(next ? nextProcess : { id: oldProcess.id, label: oldProcess.label }),
processType: 'intra_community',
step,
});
const expectedCaller = (node: NodeIdentity, next: boolean, step: number) => ({
...node,
relationType: 'CALLS',
confidence: 1,
processes: [processMembership(next, step)],
});
const expectGeneration = (
impact: Awaited<ReturnType<LocalBackend['callTool']>>,
context: Awaited<ReturnType<LocalBackend['callTool']>>,
next: boolean,
) => {
const caller = next ? replacement.caller : nodes.alphaOtherCaller;
const reader = next ? replacement.reader : nodes.alphaReader;
const entry = next ? replacement.entry : caller;
const process = next ? nextProcess : oldProcess;
expect(impact).not.toHaveProperty('error');
expect(impact).not.toHaveProperty('partial');
expect(impact.target).toMatchObject(nodes.alpha);
expect(impact.risk).toBe('LOW');
expect(impact.epistemic).toBe('exact');
expect(impact.impactedCount).toBe(next ? 2 : 1);
expect(impact.summary).toEqual({ direct: 1, processes_affected: 1, modules_affected: 0 });
expect(impact.byDepthCounts).toEqual(next ? { 1: 1, 2: 1 } : { 1: 1 });
const byDepth = Object.fromEntries(
Object.entries(impact.byDepth).map(([depth, rows]) => [
depth,
(rows as ImpactRow[]).map(
({ id, name, filePath, relationType, confidence, processes: memberships }) => ({
id,
name,
filePath,
relationType,
confidence,
processes: memberships,
}),
),
]),
);
expect(byDepth).toEqual({
1: [expectedCaller(caller, next, next ? 1 : 0)],
...(next ? { 2: [expectedCaller(entry, true, 0)] } : {}),
});
expect(impact.affected_processes).toEqual([
{
name: entry.name,
type: 'Function',
filePath: entry.filePath,
affected_process_count: 1,
total_hits: next ? 2 : 1,
earliest_broken_step: 0,
},
]);
expect(impact.affected_modules).toEqual([]);
expect(impact.affected_routes).toEqual([]);
expect(context).not.toHaveProperty('error');
expect(context.status).toBe('found');
expect(context.epistemic).toBe('exact');
expect(context.symbol).toMatchObject({
uid: nodes.alpha.id,
name: nodes.alpha.name,
filePath: nodes.alpha.filePath,
});
expect(
Object.fromEntries(
Object.entries(context.incoming).map(([type, refs]) => [
type,
(refs as ContextRef[]).map(({ uid, name, filePath }) => ({ id: uid, name, filePath })),
]),
),
).toEqual({ calls: [caller], accesses: [reader] });
expect(context.outgoing).toEqual({});
expect(context.processes).toEqual([
{
id: process.id,
name: process.label,
step_index: next ? 2 : 1,
step_count: next ? 3 : 2,
},
]);
};
try {
await seed(paths.lbugPath, false);
const meta = {
repoPath: temp.dbPath,
storagePath: paths.storagePath,
lastCommit: 'graph-a',
indexedAt: new Date().toISOString(),
scopeExtractionReceipt: 1 as const,
stats: { files: 3, nodes: 4, processes: 1, communities: 0 },
};
await saveMeta(paths.storagePath, meta);
await registerRepo(temp.dbPath, meta, { name: REPO });
const heldBackend = new LocalBackend();
backend = heldBackend;
expect(await heldBackend.init()).toBe(true);
const impact = () =>
heldBackend.callTool('impact', {
repo: REPO,
target: nodes.alpha.name,
direction: 'upstream',
});
const context = () => heldBackend.callTool('context', { repo: REPO, uid: nodes.alpha.id });
expectGeneration(await impact(), await context(), false);
expect(
await heldBackend.callTool('context', { repo: REPO, uid: replacement.entry.id }),
).toHaveProperty('error');
// Keep this backend and its read pool alive. Publish only after the
// separate staged writer has closed, exactly as run-analyze does.
await seed(stagedPath, true);
for (const suffix of ['.wal', '.shadow', '.wal.checkpoint']) {
await expect(fs.stat(`${stagedPath}${suffix}`)).rejects.toMatchObject({ code: 'ENOENT' });
}
await retryRename(stagedPath, paths.lbugPath);
const nextMeta = {
...meta,
lastCommit: 'graph-b',
indexedAt: new Date(Date.now() + 1).toISOString(),
stats: { files: 4, nodes: 5, processes: 1, communities: 0 },
};
await saveMeta(paths.storagePath, nextMeta);
await registerRepo(temp.dbPath, nextMeta, { name: REPO });
// An independent native read-only Database opens the published path.
// It does not share LocalBackend's pool or trigger its reinitialization.
const freshDb = new lbug.Database(paths.lbugPath, 128 * 1024 * 1024, true, true);
const freshConn = new lbug.Connection(freshDb);
try {
const read = async (query: string) => {
const result = await freshConn.query(query);
try {
const cursor = Array.isArray(result) ? result[0] : result;
return await cursor.getAll();
} finally {
await closeQueryResults(result);
}
};
expect(
await read(`
MATCH (n:Function)
RETURN n.id AS id, n.name AS name, n.filePath AS filePath
ORDER BY id
`),
).toEqual(
[nodes.alpha, ...Object.values(replacement)].sort((a, b) =>
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
),
);
expect(
await read(`
MATCH (n:Function)-[r:CodeRelation]->(target:Function {id: '${nodes.alpha.id}'})
WHERE r.type IN ['CALLS', 'ACCESSES']
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, r.type AS relationType
ORDER BY id
`),
).toEqual([
{ ...replacement.caller, relationType: 'CALLS' },
{ ...replacement.reader, relationType: 'ACCESSES' },
]);
expect(
await read(`
MATCH (n:Function)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN n.id AS id, p.id AS processId, p.heuristicLabel AS label,
r.step AS step, p.stepCount AS stepCount, p.entryPointId AS entryPointId
ORDER BY step
`),
).toEqual(
[replacement.entry, replacement.caller, nodes.alpha].map((node, step) => ({
id: node.id,
processId: nextProcess.id,
label: nextProcess.label,
step,
stepCount: 3,
entryPointId: replacement.entry.id,
})),
);
} finally {
await freshConn.close();
await freshDb.close();
}
// Poll the SAME backend through its unchanged five-second throttle.
// No private watermark override, poolInit, reset or restart is used.
const deadline = Date.now() + 15_000;
let refreshed = await context();
while (refreshed.processes?.[0]?.id !== nextProcess.id && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 300));
refreshed = await context();
}
expectGeneration(await impact(), refreshed, true);
for (let repeat = 0; repeat < 3; repeat++) {
expectGeneration(await impact(), await context(), true);
const introduced = await heldBackend.callTool('context', {
repo: REPO,
name: replacement.entry.name,
});
expect(introduced).not.toHaveProperty('error');
expect(introduced.status).toBe('found');
expect(introduced.symbol).toMatchObject({
uid: replacement.entry.id,
name: replacement.entry.name,
filePath: replacement.entry.filePath,
});
expect(introduced.processes).toEqual([
{ id: nextProcess.id, name: nextProcess.label, step_index: 0, step_count: 3 },
]);
}
} finally {
try {
await backend?.dispose();
} finally {
await adapter.closeLbug();
vi.unstubAllEnvs();
await temp.cleanup();
}
}
});
});

View file

@ -124,12 +124,15 @@ withTestLbugDB(
it('marks impact as a lower bound when scope extraction omitted files', async () => {
vi.mocked(loadMeta).mockResolvedValueOnce({
repoPath: '/test/repo',
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
scopeExtractionReceipt: 1,
scopeExtractionFailures: {
total: 2,
paths: ['src/broken-a.ts', 'src/broken-b.ts'],
},
} as Awaited<ReturnType<typeof loadMeta>>);
});
const result = await backend.callTool('impact', {
target: 'formatDate',
@ -143,12 +146,15 @@ withTestLbugDB(
it('never renders repository-controlled failure paths in boundary prose', async () => {
vi.mocked(loadMeta).mockResolvedValueOnce({
repoPath: '/test/repo',
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
scopeExtractionReceipt: 1,
scopeExtractionFailures: {
total: 1,
paths: ['src/`break`\n\u001b[31m\u202e\u200binject.ts'],
},
} as Awaited<ReturnType<typeof loadMeta>>);
});
const result = await backend.callTool('impact', {
target: 'formatDate',
@ -239,12 +245,15 @@ withTestLbugDB(
it('context() reports persisted scope extraction omissions as a lower bound', async () => {
vi.mocked(loadMeta).mockResolvedValueOnce({
repoPath: '/test/repo',
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
scopeExtractionReceipt: 1,
scopeExtractionFailures: {
total: 2,
paths: ['src/broken-a.ts', 'src/broken-b.ts'],
},
} as Awaited<ReturnType<typeof loadMeta>>);
});
const result = await backend.callTool('context', {
name: 'formatDate',

View file

@ -279,6 +279,7 @@ withTestLbugDB(
const result = await downstream();
const localRisk = String(result.risk);
expect(localRisk).toBe('UNKNOWN');
if (localRisk !== 'UNKNOWN') throw new Error(`Expected UNKNOWN risk, got ${localRisk}`);
// No cross-repo hits → mergeRisk returns localRisk verbatim: 'UNKNOWN',
// NEVER coerced to a confident 'LOW' (the false-safe this guards).
expect(mergeRisk(localRisk, [])).toBe('UNKNOWN');

View file

@ -0,0 +1,288 @@
/**
* Characterize #3423's public workflow against the current analyzer. The
* original private database is unavailable; this fixture does not reproduce
* or establish the cause of that historical incident.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
import * as adapter from '../../src/core/lbug/lbug-adapter.js';
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { getStoragePaths, loadMeta } from '../../src/storage/repo-manager.js';
import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../helpers/cli-entry.js';
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
import { createTempDir } from '../helpers/test-db.js';
const SWIFT_PATH = 'Sources/Numbers.swift';
const TEST_PATH = 'checks/test_numbers.py';
const IMPORTER_PATH = 'checks/runner.py';
const TARGET_NAME = 'extractLeadingNumber';
const PROPERTY_NAME = 'preservedSearchNeedle';
const CALLER_NAMES = ['parsePrimaryNumber', 'parseSecondaryNumber'];
const options = { skipAgentsMd: true, skipSkills: true };
const callbacks = { onProgress: () => {} };
const SWIFT_SOURCE = `func extractLeadingNumber(_ raw: String) -> Int {
return 7
}
func parsePrimaryNumber() -> Int {
return extractLeadingNumber("17")
}
func parseSecondaryNumber() -> Int {
return extractLeadingNumber("23")
}
class NumberSettings {
var preservedSearchNeedle: String = "retained property"
}
`;
type SymbolRef = { uid: string; name: string; filePath: string };
type ContextResult = {
status: string;
symbol: SymbolRef;
incoming: { calls?: SymbolRef[] };
};
type StatusResult = {
repository: string;
status: string;
contentDrift: { status: string; coveredFiles?: number };
index: { incompleteReasons: string[]; runnerIdentityStatus: string };
};
const git = (cwd: string, ...args: string[]): string =>
execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' }).trim();
function runCli<T>(cwd: string, args: string[], prefix = CLI_SPAWN_PREFIX): T {
const stdout = execFileSync(process.execPath, [...prefix, ...args], {
cwd,
env: process.env,
encoding: 'utf8',
timeout: 60_000,
maxBuffer: 4 * 1024 * 1024,
windowsHide: true,
});
// Context prints a human-readable banner before its JSON response.
const start = stdout.indexOf('{');
expect(start, stdout).toBeGreaterThanOrEqual(0);
return JSON.parse(stdout.slice(start)) as T;
}
function expectContext(result: ContextResult, target: SymbolRef, callers: SymbolRef[]): void {
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('found');
expect(result.symbol).toMatchObject(target);
const actual = (result.incoming.calls ?? []).map(({ uid, name, filePath }) => ({
uid,
name,
filePath,
}));
expect(actual.sort((a, b) => a.uid.localeCompare(b.uid))).toEqual(
[...callers].sort((a, b) => a.uid.localeCompare(b.uid)),
);
}
describe.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))(
'unchanged Swift context across a selective linked-worktree refresh (#3423)',
() => {
it('retains CLI/MCP name and UID context, both callers, and native Property FTS', async (ctx) => {
const temp = await createTempDir();
let backend: LocalBackend | undefined;
try {
const root = await fs.realpath(temp.dbPath);
const main = path.join(root, 'main');
const worktree = path.join(root, 'linked');
const nativeHome = path.join(root, 'user-home');
await fs.mkdir(nativeHome);
vi.stubEnv('HOME', nativeHome);
vi.stubEnv('USERPROFILE', nativeHome);
vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home'));
vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined);
vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined);
// Keep a real linked checkout, but avoid the shared-store seed/full
// rebuild path: the behavior under test is selective row replacement.
vi.stubEnv('GITNEXUS_SHARED_STORE', 'off');
vi.stubEnv('GITNEXUS_CONTENT_RETENTION', 'full');
vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '1');
vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only');
await adapter.initLbug(path.join(root, 'fts-probe'));
// Require an existing extension; a network install would make this
// isolated fixture depend on the developer's machine or connectivity.
if (!(await adapter.loadFTSExtension(undefined, { policy: 'load-only' }))) {
if (process.env.GITNEXUS_REQUIRE_FTS === '1') {
throw new Error(
'FTS is required (GITNEXUS_REQUIRE_FTS=1) but could not load in the isolated fixture.',
);
}
ctx.skip('FTS extension unavailable under the load-only policy');
}
await adapter.closeLbug();
await fs.mkdir(path.join(main, 'Sources'), { recursive: true });
await fs.mkdir(path.join(main, 'checks'), { recursive: true });
await fs.mkdir(path.join(main, 'retained'), { recursive: true });
await fs.writeFile(path.join(main, '.gitignore'), '.gitnexus/\n');
await fs.writeFile(path.join(main, SWIFT_PATH), SWIFT_SOURCE);
await fs.writeFile(path.join(main, 'checks/__init__.py'), '');
await fs.writeFile(path.join(main, TEST_PATH), 'def fixture_number():\n return 7\n');
await fs.writeFile(
path.join(main, IMPORTER_PATH),
'from checks.test_numbers import fixture_number\n\ndef run_checks():\n return fixture_number()\n',
);
// Keep the two-file write set below the 50-file / 50% escalation gate.
for (let i = 0; i < 55; i++) {
await fs.writeFile(
path.join(main, `retained/value_${i}.py`),
`def retained_value_${i}():\n return ${i}\n`,
);
}
initGitRepo(main);
git(main, 'config', 'commit.gpgsign', 'false');
commitAll(main, 'baseline mixed-language fixture');
git(main, 'worktree', 'add', '-q', '-b', 'lookup-refresh', worktree);
expect((await fs.stat(path.join(worktree, '.git'))).isFile()).toBe(true);
expect(git(worktree, 'rev-parse', 'HEAD')).toBe(git(main, 'rev-parse', 'HEAD'));
const { storagePath, lbugPath } = getStoragePaths(worktree);
expect(storagePath).toBe(path.join(worktree, '.gitnexus'));
const baseline = await runFullAnalysis(worktree, options, callbacks);
const baselineMeta = await loadMeta(storagePath);
expect(baselineMeta?.runnerIdentity).toBeDefined();
const swiftFunctions = [...baseline.pipelineResult.graph.iterNodes()].filter(
(node) => node.label === 'Function' && node.properties.filePath === SWIFT_PATH,
);
function symbol(name: string): SymbolRef {
const matches = swiftFunctions.filter((node) => node.properties.name === name);
expect(matches, name).toHaveLength(1);
return { uid: matches[0].id, name, filePath: SWIFT_PATH };
}
const target = symbol(TARGET_NAME);
const callers = CALLER_NAMES.map(symbol);
expect(new Set(callers.map(({ uid }) => uid)).size).toBe(2);
// runFullAnalysis is imported from source. Use the same source build
// for status so it actually measures content drift instead of stopping
// at a source/dist runner-identity mismatch. Context may use either.
const statusPrefix = [
'--import',
tsxLoaderUrl(),
fileURLToPath(new URL('../../src/cli/index.ts', import.meta.url)),
];
const readStatus = (): StatusResult =>
runCli<StatusResult>(worktree, ['status', '--json'], statusPrefix);
const expectCurrentStatus = (): void => {
const status = readStatus();
expect(status.repository).toBe(worktree);
expect(status.index.runnerIdentityStatus).toBe('current');
expect(status.contentDrift.status).toBe('current');
expect(status.contentDrift.coveredFiles).toBeGreaterThan(50);
expect(status.index.incompleteReasons).toEqual([]);
expect(status.status).toBe('up-to-date');
};
const readPublicContexts = async (): Promise<void> => {
// CLI and MCP own separate native readers, released before writes.
expectContext(runCli(worktree, ['context', TARGET_NAME]), target, callers);
expectContext(runCli(worktree, ['context', '--uid', target.uid]), target, callers);
backend = new LocalBackend();
try {
expect(await backend.init()).toBe(true);
for (let repeat = 0; repeat < 2; repeat++) {
expectContext(
await backend.callTool('context', { name: TARGET_NAME }),
target,
callers,
);
expectContext(
await backend.callTool('context', { uid: target.uid }),
target,
callers,
);
}
} finally {
await backend.dispose();
backend = undefined;
}
};
const readPropertyFts = async (): Promise<unknown[]> => {
await adapter.initLbug(lbugPath);
try {
const rows = await adapter.executeQuery(
`CALL QUERY_FTS_INDEX('Property', 'property_fts', '${PROPERTY_NAME}')
RETURN node.id AS uid, node.name AS name, node.filePath AS filePath`,
);
expect(rows).toEqual([
{ uid: expect.any(String), name: PROPERTY_NAME, filePath: SWIFT_PATH },
]);
return rows;
} finally {
await adapter.closeLbug();
}
};
await readPublicContexts();
expectCurrentStatus();
const propertyBefore = await readPropertyFts();
await fs.writeFile(path.join(worktree, TEST_PATH), 'def fixture_number():\n return 8\n');
expect(git(worktree, 'diff', '--name-only')).toBe(TEST_PATH);
const dirtyStatus = readStatus();
expect(dirtyStatus.contentDrift.status).toBe('drifted');
expect(dirtyStatus.index.incompleteReasons).toEqual([]);
// A call-through spy observes the exact native deletion boundary; it
// neither supplies graph rows nor substitutes for the incremental write.
const deleteNodes = vi.spyOn(adapter, 'deleteNodesForFiles');
const refreshed = await runFullAnalysis(worktree, options, callbacks);
expect(refreshed.incrementalStats).toMatchObject({
changedFiles: 1,
writeMode: 'incremental',
});
expect(refreshed.incrementalStats?.affectedDependents).toBeGreaterThan(0);
expect(deleteNodes).toHaveBeenCalledTimes(1);
const writePaths = [...deleteNodes.mock.calls[0][0]].sort();
// Effective write-set expansion also refreshes the containing Folder
// row through graph edges; no other source file enters the write set.
expect(writePaths).toEqual(['checks', TEST_PATH, IMPORTER_PATH].sort());
expect(writePaths.filter((filePath) => filePath.endsWith('.py'))).toEqual(
[TEST_PATH, IMPORTER_PATH].sort(),
);
expect(writePaths).not.toContain(SWIFT_PATH);
expect((await loadMeta(storagePath))?.runnerIdentity).toEqual(baselineMeta?.runnerIdentity);
expect(await fs.readFile(path.join(worktree, SWIFT_PATH), 'utf8')).toBe(SWIFT_SOURCE);
await readPublicContexts();
expectCurrentStatus();
expect(await readPropertyFts()).toEqual(propertyBefore);
deleteNodes.mockClear();
const noop = await runFullAnalysis(worktree, options, callbacks);
// The edit remains uncommitted, so clean-porcelain early return is
// unavailable. Identical content instead takes a zero-file refresh.
expect(noop.incrementalStats).toMatchObject({
changedFiles: 0,
affectedDependents: 0,
deletedFiles: 0,
writeMode: 'incremental',
});
expect(deleteNodes.mock.calls.flatMap(([filePaths]) => filePaths)).toEqual([]);
expect((await loadMeta(storagePath))?.runnerIdentity).toEqual(baselineMeta?.runnerIdentity);
await readPublicContexts();
expectCurrentStatus();
expect(await readPropertyFts()).toEqual(propertyBefore);
} finally {
await backend?.dispose();
await adapter.closeLbug();
vi.restoreAllMocks();
vi.unstubAllEnvs();
await temp.cleanup();
}
}, 180_000);
},
);

View file

@ -10,7 +10,7 @@ import os from 'node:os';
import path from 'node:path';
import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
import {
loadParseCache,
PARSE_CACHE_VERSION,

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const reproducer = fileURLToPath(
new URL('../../bench/incremental-write-integrity/reproduce-search.cjs', import.meta.url),
);
interface Check {
name: string;
ok: boolean;
error?: string;
rows?: number;
wrong?: number;
missing?: number;
ids?: string[];
indexes?: number;
}
describe('native incremental search (#3421, #3423)', () => {
for (const scenario of ['context', 'property'] as const) {
it(`preserves retained ${scenario} strings and search through COPY, checkpoint and reopen`, () => {
// Own process: isolate native lifetimes and allow old-engine diagnostic runs.
const result = spawnSync(process.execPath, [reproducer, scenario], {
encoding: 'utf8',
timeout: 120_000,
maxBuffer: 2 * 1024 * 1024,
});
expect(result.error, result.stderr).toBeUndefined();
expect(result.signal, result.stderr).toBeNull();
expect(result.status, result.stdout + result.stderr).toBe(0);
const report = JSON.parse(result.stdout.trim()) as { checks: Check[] };
expect(report.checks.filter((check) => !check.ok)).toEqual([]);
const check = (name: string) => report.checks.find((entry) => entry.name === name);
expect(check('after-delete:scan')).toMatchObject({ rows: 8128, wrong: 0, missing: 0 });
const stages = ['baseline', 'after-copy', 'after-checkpoint', 'reopened'];
if (scenario === 'property') stages.push('repair');
for (const stage of stages) {
expect(check(`${stage}:scan`)).toMatchObject({ rows: 8192, wrong: 0, missing: 0 });
if (scenario === 'context') {
for (const lookup of ['primary-key', 'uid', 'name']) {
expect(check(`${stage}:${lookup}`)?.ids).toEqual([
'Function:src/owner2.ts:unchangedSwiftTarget64',
]);
}
} else {
expect(check(`${stage}:catalog`)?.indexes).toBe(1);
expect(check(`${stage}:fts-query`)?.ids).toEqual([
'Property:src/owner2.swift:property64',
]);
for (const column of ['name', 'content', 'description']) {
expect(check(`${stage}:lower-${column}`)?.rows).toBe(8192);
}
}
}
if (scenario === 'property') {
for (const name of ['baseline:build', 'after-copy:build', 'repair:drop', 'repair:build']) {
expect(check(name)?.ok).toBe(true);
}
}
}, 150_000);
}
});

View file

@ -356,7 +356,9 @@ withTestLbugDB(
const result = await backend.callTool('tool_map', {});
expect(result).not.toHaveProperty('error');
const tools = new Map(result.tools.map((tool: any) => [tool.name, tool]));
const toolEntries: Array<{ name: string; description: string; flows: string[] }> =
result.tools;
const tools = new Map(toolEntries.map((tool) => [tool.name, tool]));
expect(tools.get('alpha')?.description).toBe('Calls chain A.');
expect(tools.get('beta')?.description).toBe('Calls chain B.');
expect(tools.get('alpha')?.flows).toEqual(['AlphaFlow']);

View file

@ -18,7 +18,7 @@ interface FakeChecker {
}
interface FakeLogger {
info: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn<(bindings: Record<string, unknown>, message: string) => unknown>>;
}
function checker(initial: UpdateState | null): {

View file

@ -21,7 +21,7 @@ import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { generateId } from '../../src/lib/utils.js';
import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'multi-verb-route-app');

View file

@ -134,7 +134,7 @@ withTestLbugDB(
// An MCP client may send {"name":"pdg_query"} with no `arguments` field;
// the dispatch then hands `params: undefined` to the impl. It must
// default to {} and surface the mode-validation error, not a TypeError.
const result = await backend.callTool('pdg_query');
const result = await backend.callTool('pdg_query', undefined);
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/mode/i);
});

View file

@ -323,7 +323,12 @@ function callableTargetQualifiedNames(
): string[] {
return getRelationships(result, 'CALLS')
.filter((edge) => edge.source === source && edge.rel.reason === 'callable-value-flow')
.map((edge) => result.graph.getNode(edge.rel.targetId)?.properties.qualifiedName ?? edge.target)
.map((edge) => {
const qualifiedName = result.graph.getNode(edge.rel.targetId)?.properties.qualifiedName;
if (qualifiedName == null) return edge.target;
if (typeof qualifiedName !== 'string') throw new Error('Expected a string qualified name');
return qualifiedName;
})
.sort();
}
@ -347,7 +352,10 @@ describe('callable value flow', () => {
it('does not overstate Objective-C callable-value-flow coverage', () => {
expect(CALLABLE_FLOW_PROVIDER_COVERAGE[SupportedLanguages.ObjectiveC]).toBe('not-applicable');
expect(
PROVIDER_FLOW_CASES.some(({ language }) => language === SupportedLanguages.ObjectiveC),
PROVIDER_FLOW_CASES.some(
({ language }: { language: SupportedLanguages }) =>
language === SupportedLanguages.ObjectiveC,
),
).toBe(false);
});

View file

@ -1757,7 +1757,11 @@ describe('C++ template specialization disambiguation across files', () => {
expect(targetOwnerEdge).toBeDefined();
expect(sourceOwnerEdge!.rel.sourceId).toBe(targetOwnerEdge!.rel.sourceId);
const ownerNode = result.graph.getNode(sourceOwnerEdge!.rel.sourceId);
const fp = ownerNode?.properties.templateArguments?.join(',');
const templateArguments = ownerNode?.properties.templateArguments;
if (templateArguments != null && !Array.isArray(templateArguments)) {
throw new Error('Expected template arguments to be an array');
}
const fp = Array.isArray(templateArguments) ? templateArguments.join(',') : undefined;
if (fp) ownerFingerprints.add(fp);
}
expect(ownerFingerprints).toEqual(new Set(['User', 'Order']));
@ -2125,14 +2129,15 @@ describe('C++ ambiguous integer-width overloads', () => {
});
it('records a structured suppression reason for normalization ambiguity', () => {
const outcomes = getResolutionOutcomes(result).filter(
(o) =>
o.kind === 'suppressed' &&
o.name === 'process' &&
o.phase === 'receiver-bound-calls' &&
o.filePath.endsWith('caller.cpp') &&
o.reason === 'overload-ambiguous-normalization',
);
const outcomes = getResolutionOutcomes(result)
.filter((o) => o.kind === 'suppressed')
.filter(
(o) =>
o.name === 'process' &&
o.phase === 'receiver-bound-calls' &&
o.filePath.endsWith('caller.cpp') &&
o.reason === 'overload-ambiguous-normalization',
);
expect(outcomes.length).toBeGreaterThan(0);
expect(outcomes[0]?.candidateIds.length).toBe(2);
@ -2214,13 +2219,12 @@ describe('C++ overload resolution — conversion-rank disambiguation (#1578)', (
});
it('records a structured suppression reason for conversion-rank ties', () => {
const outcomes = getResolutionOutcomes(result).filter(
(o) =>
o.kind === 'suppressed' &&
o.name === 'h' &&
o.phase === 'free-call-fallback' &&
o.reason === 'conversion-rank-tied',
);
const outcomes = getResolutionOutcomes(result)
.filter((o) => o.kind === 'suppressed')
.filter(
(o) =>
o.name === 'h' && o.phase === 'free-call-fallback' && o.reason === 'conversion-rank-tied',
);
expect(outcomes.length).toBeGreaterThan(0);
expect(outcomes[0]?.candidateIds.length).toBe(2);
@ -3256,13 +3260,14 @@ describe('C++ ADL — non-function ordinary lookup suppresses ADL', () => {
});
it('records a structured suppression reason for ADL blocker lookup', () => {
const outcomes = getResolutionOutcomes(result).filter(
(o) =>
o.kind === 'suppressed' &&
o.name === 'record' &&
o.phase === 'free-call-fallback' &&
o.reason === 'adl-ordinary-lookup-blocked',
);
const outcomes = getResolutionOutcomes(result)
.filter((o) => o.kind === 'suppressed')
.filter(
(o) =>
o.name === 'record' &&
o.phase === 'free-call-fallback' &&
o.reason === 'adl-ordinary-lookup-blocked',
);
expect(outcomes.length).toBeGreaterThan(0);
expect(outcomes[0]?.candidateIds.length).toBe(0);
@ -3552,13 +3557,14 @@ describe('C++ inline namespace — ambiguous same-name across inline children (#
});
it('records a structured suppression reason for inline namespace ambiguity', () => {
const outcomes = getResolutionOutcomes(result).filter(
(o) =>
o.kind === 'suppressed' &&
o.name === 'foo' &&
o.phase === 'receiver-bound-calls' &&
o.reason === 'inline-ns-ambiguous',
);
const outcomes = getResolutionOutcomes(result)
.filter((o) => o.kind === 'suppressed')
.filter(
(o) =>
o.name === 'foo' &&
o.phase === 'receiver-bound-calls' &&
o.reason === 'inline-ns-ambiguous',
);
expect(outcomes.length).toBeGreaterThan(0);
expect(outcomes[0]?.candidateIds.length).toBe(0);

View file

@ -413,6 +413,7 @@ describe('JavaScript plain-object property access (A1/A5)', () => {
});
interface PropNode {
readonly id: string;
readonly label: string;
readonly properties: Record<string, unknown>;
}

View file

@ -1146,6 +1146,7 @@ describe('Python mixin self-dispatch', () => {
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.filePath === 'mixins.py' &&
outcome.name === 'class_only' &&
outcome.reason === 'receiver-unresolved',
@ -1202,11 +1203,9 @@ describe('Python mixin self-dispatch', () => {
it('records only the expected mixin dispatch gaps and partial coverage', () => {
const unresolvedSites = getResolutionOutcomes(result)
.filter((outcome) => outcome.kind === 'suppressed')
.filter(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.reason === 'receiver-unresolved' &&
outcome.filePath === 'mixins.py',
(outcome) => outcome.reason === 'receiver-unresolved' && outcome.filePath === 'mixins.py',
)
.map((outcome) => `${outcome.range.startLine}:${outcome.name}`)
.sort();
@ -1325,6 +1324,7 @@ describe('Python mixin self-dispatch', () => {
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.filePath === 'mixins.py' &&
outcome.name === 'duplicate_hook' &&
outcome.reason === 'member-lookup-ambiguous',
@ -1378,12 +1378,9 @@ describe('Python unproven subtype methods', () => {
expect(calls.map((call) => call.rel.targetId)).toEqual([
expect.stringContaining('Concrete.hook'),
]);
const unresolved = getResolutionOutcomes(result).filter(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.name === 'hook' &&
outcome.reason === 'receiver-unresolved',
);
const unresolved = getResolutionOutcomes(result)
.filter((outcome) => outcome.kind === 'suppressed')
.filter((outcome) => outcome.name === 'hook' && outcome.reason === 'receiver-unresolved');
expect(unresolved.flatMap((outcome) => outcome.candidateIds).sort()).toEqual([
// AbstractWorker.hook (line 10) and Receiverless.hook (line 13).
'def:worker.py#10:4:Method:hook',
@ -1419,6 +1416,7 @@ describe('Python unproven subtype methods', () => {
]);
expect(
getResolutionOutcomes(result)
.filter((outcome) => outcome.kind === 'suppressed')
.filter((outcome) => outcome.name === 'hook' && outcome.reason === 'receiver-unresolved')
.flatMap((outcome) => outcome.candidateIds),
).toEqual([expect.stringMatching(/:Class:Base$/)]);
@ -1459,6 +1457,7 @@ class MissingHook(Mixin):
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.name === 'hook' &&
outcome.reason === 'receiver-unresolved' &&
outcome.candidateIds.some((id) => id.endsWith(':Class:MissingHook')),
@ -1547,7 +1546,10 @@ class Mixin:
);
expect(
getResolutionOutcomes(result).some(
(outcome) => outcome.name === target && outcome.reason === 'receiver-unresolved',
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.name === target &&
outcome.reason === 'receiver-unresolved',
),
).toBe(true);
}
@ -1647,7 +1649,10 @@ class ReboundProperty:
);
expect(
getResolutionOutcomes(result).some(
(outcome) => outcome.name === target && outcome.reason === 'receiver-unresolved',
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.name === target &&
outcome.reason === 'receiver-unresolved',
),
).toBe(true);
}
@ -1660,13 +1665,17 @@ class ReboundProperty:
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.name === 'wrapped_helper' && outcome.reason === 'receiver-unresolved',
outcome.kind === 'suppressed' &&
outcome.name === 'wrapped_helper' &&
outcome.reason === 'receiver-unresolved',
),
).toBe(true);
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.name === 'rebound_helper' && outcome.reason === 'receiver-unresolved',
outcome.kind === 'suppressed' &&
outcome.name === 'rebound_helper' &&
outcome.reason === 'receiver-unresolved',
),
).toBe(true);
} finally {
@ -1752,6 +1761,7 @@ class Worker(Mixin):
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.filePath === 'case.py' &&
outcome.name === 'hook' &&
outcome.reason === 'receiver-unresolved',
@ -1760,6 +1770,7 @@ class Worker(Mixin):
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.filePath === 'case.py' &&
outcome.name === 'own_hook' &&
outcome.reason === 'receiver-unresolved',
@ -1768,6 +1779,7 @@ class Worker(Mixin):
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.filePath === 'case.py' &&
outcome.name === 'unicode_helper' &&
outcome.reason === 'receiver-unresolved',
@ -1776,6 +1788,7 @@ class Worker(Mixin):
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.filePath === 'case.py' &&
outcome.name === 'nested_hook' &&
outcome.reason === 'receiver-unresolved',
@ -1855,6 +1868,7 @@ class DirectWorker(HookMixin, external.Parent, First, Second):
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.name === 'hook' &&
outcome.reason === 'receiver-unresolved' &&
outcome.candidateIds.some((id) => id.endsWith(':Class:Worker')),
@ -1896,6 +1910,7 @@ class DirectWorker(HookMixin):
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.name === 'hook' &&
outcome.reason === 'receiver-unresolved' &&
outcome.candidateIds.some((id) => id.endsWith(':Class:Worker')),

View file

@ -47,7 +47,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'node:path';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../../types/pipeline.js';
import type { PipelineResult } from '../../../src/types/pipeline.js';
const LANG_FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'lang-resolution');

View file

@ -186,9 +186,9 @@ describe('shared sibling store analyze (#3352)', () => {
const conn = new lbug.Connection(db);
let rows: { p: string }[];
try {
rows = (await (
await conn.query('MATCH (f:File) RETURN f.filePath AS p ORDER BY p')
).getAll()) as { p: string }[];
const result = await conn.query('MATCH (f:File) RETURN f.filePath AS p ORDER BY p');
if (Array.isArray(result)) throw new Error('Expected a single query result');
rows = (await result.getAll()) as { p: string }[];
} finally {
await conn.close();
await db.close();

View file

@ -41,9 +41,9 @@ const queryNames = async (graph: string): Promise<string[]> => {
const db = new lbug.Database(graph, 0, true, true);
const conn = new lbug.Connection(db);
try {
const rows = (await (
await conn.query('MATCH (f:Function) RETURN f.name AS n ORDER BY n')
).getAll()) as { n: string }[];
const result = await conn.query('MATCH (f:Function) RETURN f.name AS n ORDER BY n');
if (Array.isArray(result)) throw new Error('Expected a single query result');
const rows = (await result.getAll()) as { n: string }[];
return rows.map((r) => r.n);
} finally {
await conn.close();

View file

@ -367,8 +367,14 @@ describe('FTS opt-out analysis lifecycle (#3091)', () => {
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body.warning).toContain('FTS disabled for this index');
expect(body.warning).not.toMatch(/failed to load|indexes missing|repair-fts/);
expect(body).toHaveProperty(
'warning',
expect.stringContaining('FTS disabled for this index'),
);
expect(body).toHaveProperty(
'warning',
expect.not.stringMatching(/failed to load|indexes missing|repair-fts/),
);
}
} finally {
if (server.exitCode === null) server.kill('SIGTERM');

View file

@ -1,7 +1,7 @@
import { beforeAll, describe, expect, it } from 'vitest';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-bean-app');

View file

@ -4,7 +4,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest';
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { SPRING_CONFIG_DESCRIPTION } from '../../src/core/ingestion/frameworks/spring/config-bindings.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
import { createTempDir } from '../helpers/test-db.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-config-app');

View file

@ -10,7 +10,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const ORDER_API = `package com.example;
import org.springframework.web.bind.annotation.*;

View file

@ -13,7 +13,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-route-app');

View file

@ -0,0 +1,208 @@
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import {
disposeEmbeddingSpill,
materializeCachedEmbeddings,
type CachedEmbeddingsSnapshot,
} from '../../src/core/embeddings/embedding-restore-spill.js';
import { recoverStagedEmbeddings } from '../../src/core/embeddings/staged-embedding-recovery.js';
describe('isolated native staged embedding recovery', () => {
let tmp: string | undefined;
let recovered: CachedEmbeddingsSnapshot | undefined;
afterEach(() => {
disposeEmbeddingSpill(recovered?.spill);
recovered = undefined;
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
tmp = undefined;
});
function stagePath() {
tmp ??= fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-native-'));
return path.join(tmp, `lbug.staging.${randomUUID()}`);
}
function seed(dbPath: string, mode = 'clean') {
const result = spawnSync(
process.execPath,
[
'--import',
'tsx',
fileURLToPath(new URL('../fixtures/staged-embedding-recovery/seed.mjs', import.meta.url)),
dbPath,
mode,
],
{
encoding: 'utf8',
timeout: 20_000,
env: { ...process.env, GITNEXUS_LBUG_BUFFER_POOL_SIZE: String(128 * 1024 * 1024) },
},
);
expect(result.error, result.stderr).toBeUndefined();
if (mode === 'hard-kill') expect(result.signal, result.stderr).toBe('SIGKILL');
else expect(result.status, result.stderr).toBe(0);
}
function snapshotSourceFamily(dbPath: string) {
const basename = path.basename(dbPath);
return Object.fromEntries(
fs
.readdirSync(path.dirname(dbPath))
.filter((name) => name === basename || name.startsWith(`${basename}.`))
.sort()
.map((name) => [name, fs.readFileSync(path.join(path.dirname(dbPath), name))]),
);
}
it('streams complete same-hash groups and rejects malformed whole nodes', async () => {
const dbPath = stagePath();
seed(dbPath);
recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 2 });
expect([...recovered.embeddingNodeIds].sort()).toEqual(['complete', 'other']);
expect(recovered.rows).toHaveLength(3);
expect(recovered.embeddings).toEqual([]);
expect(materializeCachedEmbeddings(recovered, recovered.rows)).toEqual(
expect.arrayContaining([
expect.objectContaining({
nodeId: 'complete',
chunkIndex: 0,
embedding: [1, 2],
contentHash: 'same',
}),
expect.objectContaining({
nodeId: 'complete',
chunkIndex: 1,
embedding: [1, 2],
contentHash: 'same',
}),
]),
);
expect(fs.existsSync(dbPath)).toBe(true);
}, 30_000);
it('replays a hard-killed native writer strictly and excludes the incomplete active window', async () => {
const dbPath = stagePath();
seed(dbPath, 'hard-kill');
const sourceBefore = snapshotSourceFamily(dbPath);
recovered = await recoverStagedEmbeddings(dbPath, {
dimensions: 2,
excludedNodeIds: ['unsafe-prefix', 'other'],
});
expect([...recovered.embeddingNodeIds]).toEqual(['complete']);
expect(recovered.rows).toHaveLength(2);
expect(
materializeCachedEmbeddings(recovered, recovered.rows)
.map((row) => row.chunkIndex)
.sort(),
).toEqual([0, 1]);
expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore);
}, 30_000);
it('rejects vectors from a different dimension instead of coercing them', async () => {
const dbPath = stagePath();
seed(dbPath);
recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 3 });
expect(recovered.rows).toEqual([]);
}, 30_000);
it('strictly replays an interrupted checkpoint copy with both checkpoint locks retained', async (ctx) => {
const version = JSON.parse(
fs.readFileSync(
new URL('../../node_modules/@ladybugdb/core/package.json', import.meta.url),
'utf8',
),
).version as string;
const [major, minor] = version.split('.').map(Number);
// Older pins do not support the deterministic interrupted-checkpoint plant.
if (Number.isFinite(major) && Number.isFinite(minor) && major === 0 && minor < 19) ctx.skip();
const dbPath = stagePath();
seed(dbPath, 'interrupted-checkpoint');
const sourceBefore = snapshotSourceFamily(dbPath);
expect(fs.statSync(`${dbPath}.wal.checkpoint`).size).toBeGreaterThan(0);
expect(fs.existsSync(`${dbPath}.checkpoint.intent.lock`)).toBe(true);
expect(fs.existsSync(`${dbPath}.checkpoint.apply.lock`)).toBe(true);
recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 2 });
expect([...recovered.embeddingNodeIds].sort()).toEqual([
'checkpoint-only',
'complete',
'other',
]);
expect(recovered.rows).toHaveLength(4);
expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore);
}, 30_000);
it('contains a malformed native source in a subprocess and preserves it', async () => {
const dbPath = stagePath();
fs.writeFileSync(dbPath, 'not a ladybug database');
await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow(
/extraction failed/,
);
expect(fs.readFileSync(dbPath, 'utf8')).toBe('not a ladybug database');
}, 30_000);
it('rejects a malformed WAL without deleting or quarantining it to reopen the source', async () => {
const dbPath = stagePath();
seed(dbPath, 'hard-kill');
const walPath = `${dbPath}.wal`;
fs.writeFileSync(walPath, Buffer.alloc(128, 0xff));
const sourceBefore = snapshotSourceFamily(dbPath);
await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow(
/extraction failed/,
);
expect(fs.existsSync(walPath)).toBe(true);
expect(fs.readdirSync(path.dirname(dbPath)).some((name) => /bad|quarantine/i.test(name))).toBe(
false,
);
expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore);
}, 30_000);
it('does not create a missing source and refuses a symlink', async () => {
const dbPath = stagePath();
await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow(/ENOENT/);
expect(fs.existsSync(dbPath)).toBe(false);
const realPath = path.join(path.dirname(dbPath), 'real');
fs.writeFileSync(realPath, 'fixture');
fs.symlinkSync(realPath, dbPath);
await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow(
/regular file/,
);
});
it('can kill a timed out native subprocess without aborting analyze', async () => {
const dbPath = stagePath();
seed(dbPath);
const sourceBefore = snapshotSourceFamily(dbPath);
await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2, timeoutMs: 1 })).rejects.toThrow(
/timeout/,
);
expect(fs.existsSync(dbPath)).toBe(true);
expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore);
}, 30_000);
it('loads the source child when analyze runs in another repository directory', () => {
const dbPath = stagePath();
seed(dbPath);
const result = spawnSync(
process.execPath,
[
'--import',
import.meta.resolve('tsx'),
'--input-type=module',
'-e',
'const { recoverStagedEmbeddings } = await import(process.argv[1]); const { disposeEmbeddingSpill } = await import(process.argv[2]); const recovered = await recoverStagedEmbeddings(process.argv[3], {dimensions: 2}); process.stdout.write(String(recovered.rows.length)); disposeEmbeddingSpill(recovered.spill);',
new URL('../../src/core/embeddings/staged-embedding-recovery.ts', import.meta.url).href,
new URL('../../src/core/embeddings/embedding-restore-spill.ts', import.meta.url).href,
dbPath,
],
{ cwd: path.dirname(dbPath), encoding: 'utf8', timeout: 20_000 },
);
expect(result.error, result.stderr).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toBe('3');
}, 30_000);
});

View file

@ -76,7 +76,7 @@ export function handle(req: any, db: any, map: Map<string, string>, task: any, r
.map((rel) => {
const sink = result.graph.getNode(rel.targetId);
const decoded = decodeTaintPath(rel.reason);
if (!decoded.ok) {
if (decoded.ok === false) {
throw new Error(`invalid TAINTED reason for ${rel.id}: ${decoded.error}`);
}
return {

View file

@ -81,7 +81,9 @@ describe('watch filesystem integration', () => {
const loop = await startWatchFileLoop(
repo,
30,
async (paths) => batches.push([...paths]),
async (paths) => {
batches.push([...paths]);
},
(error) => {
throw error;
},
@ -166,7 +168,9 @@ describe('watch filesystem integration', () => {
const loop = await startWatchFileLoop(
repo,
25,
async (paths) => batches.push([...paths]),
async (paths) => {
batches.push([...paths]);
},
(error) => {
throw error;
},
@ -224,7 +228,9 @@ describe('watch filesystem integration', () => {
const loop = await startWatchFileLoop(
repo,
25,
async (paths) => batches.push([...paths]),
async (paths) => {
batches.push([...paths]);
},
(_error, paths) => errors.push([...paths]),
);
loops.push(loop);
@ -252,7 +258,9 @@ describe('watch filesystem integration', () => {
const loop = await startWatchFileLoop(
repo,
25,
async (paths) => batches.push([...paths]),
async (paths) => {
batches.push([...paths]);
},
(error) => {
throw error;
},

View file

@ -1367,7 +1367,7 @@ describe('worker pool integration', () => {
]);
// Deterministically: a.ts crashes round 1, b.ts crashes round 2.
const quarantine = (pool.getQuarantinedPaths?.() ?? []).sort();
const quarantine = [...(pool.getQuarantinedPaths?.() ?? [])].sort();
expect(quarantine).toEqual(['a.ts', 'b.ts']);
// All non-quarantined files eventually parsed by the survivor slot.
const allPaths = results.flatMap((r) => r.paths).sort();

View file

@ -1667,7 +1667,7 @@ describe('count-only reindex does not churn the committed block (#2907)', () =>
describe('--no-stats drops the per-cluster symbol counts too (#2907)', () => {
const stats = { nodes: 10, edges: 20, processes: 3 };
const skills = [{ label: 'ingestion', name: 'p-ingestion', symbolCount: 120 }];
const skills = [{ label: 'ingestion', name: 'p-ingestion', symbolCount: 120, fileCount: 10 }];
it('omits the count under --no-stats and keeps it otherwise', () => {
const lean = generateGitNexusContent('P', stats, { generatedSkills: skills, noStats: true });

View file

@ -152,7 +152,11 @@ const invokeDelete = (route: string, jobId: string): { statusCode: number; body:
return this;
},
};
handler({ params: { jobId } }, res);
handler(
{ params: { jobId } } as unknown as express.Request,
res as unknown as express.Response,
vi.fn(),
);
return res;
};

View file

@ -22,8 +22,12 @@ const {
getDefaultBranchMock,
} = vi.hoisted(() => ({
runFullAnalysisMock: vi.fn(),
generateAIContextFilesMock: vi.fn(async () => ({ files: [] as string[] })),
refreshBaseRefLineMock: vi.fn(async () => ({ files: [] as string[] })),
generateAIContextFilesMock: vi.fn<
typeof import('../../src/cli/ai-context.js').generateAIContextFiles
>(async () => ({ files: [] as string[] })),
refreshBaseRefLineMock: vi.fn<typeof import('../../src/cli/ai-context.js').refreshBaseRefLine>(
async () => ({ files: [] as string[] }),
),
generateSkillFilesMock: vi.fn(async () => ({
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
outputPath: '/repo/.claude/skills',

View file

@ -30,12 +30,15 @@ const resolveEmbeddingRuntimeMock = vi.fn<() => { source: string } | null>(() =>
source: 'package',
}));
const isPrefixRuntimeLoadableMock = vi.fn(() => true);
const installEmbeddingRuntimeMock = vi.fn(async () => undefined);
const installEmbeddingRuntimeMock = vi.fn<
typeof import('../../src/core/embeddings/runtime-install.js').installEmbeddingRuntime
>(async () => undefined);
vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/core/embeddings/runtime-install.js')>()),
resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(),
isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(),
installEmbeddingRuntime: (...args: unknown[]) => installEmbeddingRuntimeMock(...args),
installEmbeddingRuntime: (...args: Parameters<typeof installEmbeddingRuntimeMock>) =>
installEmbeddingRuntimeMock(...args),
getEmbeddingRuntimeDir: () => '/fake/embedding-runtime',
}));

View file

@ -38,12 +38,15 @@ const resolveEmbeddingRuntimeMock = vi.fn<() => { source: string } | null>(() =>
source: 'package',
}));
const isPrefixRuntimeLoadableMock = vi.fn(() => true);
const installEmbeddingRuntimeMock = vi.fn(async () => undefined);
const installEmbeddingRuntimeMock = vi.fn<
typeof import('../../src/core/embeddings/runtime-install.js').installEmbeddingRuntime
>(async () => undefined);
vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/core/embeddings/runtime-install.js')>()),
resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(),
isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(),
installEmbeddingRuntime: (...args: unknown[]) => installEmbeddingRuntimeMock(...args),
installEmbeddingRuntime: (...args: Parameters<typeof installEmbeddingRuntimeMock>) =>
installEmbeddingRuntimeMock(...args),
getEmbeddingRuntimeDir: () => '/fake/embedding-runtime',
}));

Some files were not shown because too many files have changed in this diff Show more