mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-13 03:58:33 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
020d480633
268 changed files with 12779 additions and 2207 deletions
3
.github/workflows/ci-quality.yml
vendored
3
.github/workflows/ci-quality.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
28
.github/workflows/ci-report.yml
vendored
28
.github/workflows/ci-report.yml
vendored
|
|
@ -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[]
|
||||
|
|
|
|||
200
.github/workflows/ci-tests.yml
vendored
200
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
9
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
48
TESTING.md
48
TESTING.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
121
eval/check_test_execution.py
Normal file
121
eval/check_test_execution.py
Normal 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())
|
||||
142
eval/tests/test_check_test_execution.py
Normal file
142
eval/tests/test_check_test_execution.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
275
eval/uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
6
gitnexus-web/package-lock.json
generated
6
gitnexus-web/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
251
gitnexus/bench/incremental-write-integrity/reproduce-search.cjs
Normal file
251
gitnexus/bench/incremental-write-integrity/reproduce-search.cjs
Normal 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;
|
||||
});
|
||||
100
gitnexus/bench/incremental-write-integrity/search-root-cause.md
Normal file
100
gitnexus/bench/incremental-write-integrity/search-root-cause.md
Normal 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.
|
||||
599
gitnexus/package-lock.json
generated
599
gitnexus/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
38
gitnexus/scripts/execution-reporter.ts
Normal file
38
gitnexus/scripts/execution-reporter.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
32
gitnexus/scripts/run-benchmarks.ts
Normal file
32
gitnexus/scripts/run-benchmarks.ts
Normal 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' },
|
||||
},
|
||||
);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
203
gitnexus/scripts/test-completeness.ts
Normal file
203
gitnexus/scripts/test-completeness.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => []);
|
||||
|
|
|
|||
181
gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts
Normal file
181
gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts
Normal 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;
|
||||
});
|
||||
244
gitnexus/src/core/embeddings/staged-embedding-recovery.ts
Normal file
244
gitnexus/src/core/embeddings/staged-embedding-recovery.ts
Normal 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()}` : ''}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
89
gitnexus/src/core/ingestion/route-extractors/go-shared.ts
Normal file
89
gitnexus/src/core/ingestion/route-extractors/go-shared.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] ?? ''))
|
||||
|
|
|
|||
57
gitnexus/src/mcp/local/query-result-integrity.ts
Normal file
57
gitnexus/src/mcp/local/query-result-integrity.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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, () => {}))) {
|
||||
|
|
|
|||
167
gitnexus/src/storage/embedding-recovery.ts
Normal file
167
gitnexus/src/storage/embedding-recovery.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
52
gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs
vendored
Normal file
52
gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs
vendored
Normal 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();
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
38
gitnexus/test/helpers/leiden-timeout-child.ts
Normal file
38
gitnexus/test/helpers/leiden-timeout-child.ts
Normal 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.
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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] ?? [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -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"'));
|
||||
});
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ function createGoRepo(): string {
|
|||
async function runWorker(): Promise<PipelineResult> {
|
||||
return runPipelineFromRepo(createGoRepo(), () => {}, {
|
||||
skipGraphPhases: true,
|
||||
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
|
||||
workerPoolSize: 2,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
765
gitnexus/test/integration/impact-context-integrity.test.ts
Normal file
765
gitnexus/test/integration/impact-context-integrity.test.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
288
gitnexus/test/integration/incremental-worktree-context.test.ts
Normal file
288
gitnexus/test/integration/incremental-worktree-context.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
62
gitnexus/test/integration/lbug-incremental-search.test.ts
Normal file
62
gitnexus/test/integration/lbug-incremental-search.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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): {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.*;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
208
gitnexus/test/integration/staged-embedding-recovery.test.ts
Normal file
208
gitnexus/test/integration/staged-embedding-recovery.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue