mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-14 04:08:05 +00:00
Merge upstream/main into R language support
This commit is contained in:
commit
084d4f158e
375 changed files with 19144 additions and 2816 deletions
|
|
@ -123,7 +123,7 @@ jobs:
|
|||
matrix: ${{ steps.decide.outputs.matrix }}
|
||||
release_app: ${{ steps.relapp.outputs.configured }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0 # need base history to diff recorded versions
|
||||
persist-credentials: false
|
||||
|
|
@ -392,7 +392,7 @@ jobs:
|
|||
# and compiling them under emulation on the arm runners is slow.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false # this job uploads artifacts (artipacked)
|
||||
|
||||
|
|
@ -565,7 +565,7 @@ jobs:
|
|||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# On a (non-fork) PR, check out the PR's HEAD branch — not the merge ref —
|
||||
|
|
|
|||
4
.github/workflows/ci-devcontainer.yml
vendored
4
.github/workflows/ci-devcontainer.yml
vendored
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
# persist-credentials: false — this job only reads (tests and syntax
|
||||
# checks) and never pushes. The setting keeps GITHUB_TOKEN out of
|
||||
# .git/config, which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
# persist-credentials: false — this is a read-only build smoke that
|
||||
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
|
||||
# which zizmor flags as the "artipacked" issue.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
|
|
|||
4
.github/workflows/ci-e2e.yml
vendored
4
.github/workflows/ci-e2e.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
outputs:
|
||||
web_changed: ${{ steps.filter.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v3
|
||||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
13
.github/workflows/ci-quality.yml
vendored
13
.github/workflows/ci-quality.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
# canceled prettier at the 5-minute job cap; lint needed 7m41s the same run.
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# tsc --noEmit reads source + gitnexus-shared/dist. Skip prepare/postinstall
|
||||
|
|
@ -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
|
||||
|
|
@ -61,7 +64,7 @@ jobs:
|
|||
# run is cold again.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus-web
|
||||
|
|
@ -84,7 +87,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate workflow concurrency convention
|
||||
|
|
|
|||
30
.github/workflows/ci-report.yml
vendored
30
.github/workflows/ci-report.yml
vendored
|
|
@ -125,7 +125,7 @@ jobs:
|
|||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: gitnexus/vitest.config.ts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
226
.github/workflows/ci-tests.yml
vendored
226
.github/workflows/ci-tests.yml
vendored
|
|
@ -32,11 +32,12 @@ 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
|
||||
# credential-persistence / artipacked audit). The job never pushes.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
|
@ -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
|
||||
|
|
@ -99,7 +100,7 @@ jobs:
|
|||
env:
|
||||
GITNEXUS_REQUIRE_FTS: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
|
@ -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
|
||||
|
|
@ -223,7 +225,7 @@ jobs:
|
|||
steps:
|
||||
# persist-credentials: false — runs tests only, never pushes (zizmor
|
||||
# credential-persistence / artipacked audit).
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
|
@ -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
|
||||
|
|
@ -273,7 +315,7 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
|
@ -317,7 +359,7 @@ jobs:
|
|||
# from a tarball and never pushes back; the token in .git/config would
|
||||
# be at risk of leaking through any future artifact-upload step
|
||||
# (zizmor artipacked audit). Disable upfront.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Skip prepare/postinstall/build here. `npm pack` runs prepack, which
|
||||
|
|
@ -430,7 +472,7 @@ jobs:
|
|||
steps:
|
||||
# persist-credentials: false — builds and import-links only, never pushes
|
||||
# (zizmor credential-persistence / artipacked audit).
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
|
@ -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
|
||||
|
|
@ -496,7 +535,7 @@ jobs:
|
|||
# and never pushes; the default-persisted token in .git/config would be at
|
||||
# risk of leaking through an artifact upload (zizmor credential-persistence
|
||||
# / artipacked audit). Mirrors the packaged-install-smoke job below.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
|
@ -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.
|
||||
|
|
@ -907,17 +936,97 @@ jobs:
|
|||
timeout-minutes: 15
|
||||
steps:
|
||||
# persist-credentials: false — runs tests only, never pushes.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: '0.11.23'
|
||||
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.
|
||||
|
|
@ -934,7 +1043,7 @@ jobs:
|
|||
GITNEXUS_REQUIRE_FULL_SWEEP: '1'
|
||||
GITNEXUS_REQUIRE_CLAUDE_CANARY: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
|
@ -944,7 +1053,7 @@ jobs:
|
|||
cache-dependency-path: |
|
||||
gitnexus/package-lock.json
|
||||
gitnexus-shared/package-lock.json
|
||||
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
|
|
@ -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.
|
||||
|
|
@ -1002,10 +1122,10 @@ jobs:
|
|||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
|
|
@ -129,7 +129,7 @@ jobs:
|
|||
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
|
||||
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
|
||||
|
|
|
|||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
|
|
@ -42,13 +42,13 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
|
@ -87,6 +87,6 @@ jobs:
|
|||
- '.github/scripts/fetch-lbug-fts-artifacts.mjs'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
|
|
|||
4
.github/workflows/commit-fork-prebuilds.yml
vendored
4
.github/workflows/commit-fork-prebuilds.yml
vendored
|
|
@ -145,7 +145,7 @@ jobs:
|
|||
# checkout (the same trust anchor as this workflow file).
|
||||
- name: Checkout identity verifier
|
||||
if: steps.meta.outputs.deliver == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts/verify-workflow-run-pr-identity.cjs
|
||||
|
|
@ -171,7 +171,7 @@ jobs:
|
|||
# never written to .git/config on disk.
|
||||
- name: Checkout fork PR head
|
||||
if: steps.meta.outputs.deliver == 'true' && steps.verify.outcome == 'success'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: ${{ steps.verify.outputs.head_repo }}
|
||||
ref: ${{ steps.verify.outputs.head_sha }}
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -28,7 +28,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
|
|
@ -103,7 +103,7 @@ jobs:
|
|||
# When triggered by workflow_call the caller passes the RC tag as an input;
|
||||
# we check out that tag so the Dockerfile and package.json match the built image.
|
||||
# For tag-push events github.ref is already the tag ref — no override needed.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
|
|
|
|||
2
.github/workflows/gitleaks.yml
vendored
2
.github/workflows/gitleaks.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Full history needed for the on-push full-history scan; on PRs the
|
||||
# action diffs against the base ref so the cost is bounded by the PR.
|
||||
|
|
|
|||
8
.github/workflows/gitnexus-review-agent.yml
vendored
8
.github/workflows/gitnexus-review-agent.yml
vendored
|
|
@ -303,7 +303,7 @@ jobs:
|
|||
- name: Checkout trusted workflow control plane
|
||||
id: checkout-control
|
||||
if: steps.context.outputs.ready == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ steps.context.outputs.control_sha }}
|
||||
|
|
@ -315,7 +315,7 @@ jobs:
|
|||
- name: Checkout exact PR head as passive data
|
||||
id: checkout-head
|
||||
if: steps.context.outputs.ready == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: ${{ steps.context.outputs.head_repo }}
|
||||
ref: ${{ steps.context.outputs.head_sha }}
|
||||
|
|
@ -1294,7 +1294,7 @@ jobs:
|
|||
steps.claude-recheck.outcome == 'success'
|
||||
# Use the low-level base action: the high-level GitHub action can restore
|
||||
# project configuration from a moving base branch before invoking Claude.
|
||||
uses: anthropics/claude-code-action/base-action@3553f84341b92da26052e28acf1aa898f9511f32 # v1
|
||||
uses: anthropics/claude-code-action/base-action@e0cf66d1d257526b5d07f141838c338921cb8455 # v1
|
||||
env:
|
||||
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1'
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0'
|
||||
|
|
@ -1447,7 +1447,7 @@ jobs:
|
|||
if: >-
|
||||
steps.precheck.outputs.repair_reason != '' &&
|
||||
steps.repair-recheck.outcome == 'success'
|
||||
uses: anthropics/claude-code-action/base-action@3553f84341b92da26052e28acf1aa898f9511f32 # v1
|
||||
uses: anthropics/claude-code-action/base-action@e0cf66d1d257526b5d07f141838c338921cb8455 # v1
|
||||
env:
|
||||
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1'
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0'
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
|
@ -265,7 +265,7 @@ jobs:
|
|||
gitnexus/package-lock.json
|
||||
gitnexus-shared/package-lock.json
|
||||
|
||||
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
|
|
|
|||
2
.github/workflows/grammar-update-monitor.yml
vendored
2
.github/workflows/grammar-update-monitor.yml
vendored
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
# artifact and never pushes; the default-persisted token in .git/config
|
||||
# must not be capturable through that upload (zizmor credential-persistence
|
||||
# / artipacked audit). Mirrors ci-tests.yml.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
2
.github/workflows/pr-autofix-apply.yml
vendored
2
.github/workflows/pr-autofix-apply.yml
vendored
|
|
@ -336,7 +336,7 @@ jobs:
|
|||
# Push auth is provided inline at push time via the URL.
|
||||
- name: Checkout PR head
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v5.0.4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: ${{ steps.locate.outputs.head_repo }}
|
||||
ref: ${{ steps.locate.outputs.head_sha }}
|
||||
|
|
|
|||
2
.github/workflows/pr-autofix-publish.yml
vendored
2
.github/workflows/pr-autofix-publish.yml
vendored
|
|
@ -131,7 +131,7 @@ jobs:
|
|||
# check-run SHA cannot be an unverified artifact field.
|
||||
# Mismatch => fail loud BEFORE any sticky/check-run side effect.
|
||||
- name: Checkout identity verifier
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts/verify-workflow-run-pr-identity.cjs
|
||||
|
|
|
|||
2
.github/workflows/pr-autofix.yml
vendored
2
.github/workflows/pr-autofix.yml
vendored
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# PR head commit (not the synthetic merge ref) — we need the
|
||||
# exact tree the contributor pushed so suggestions line up.
|
||||
|
|
|
|||
6
.github/workflows/publish.yml
vendored
6
.github/workflows/publish.yml
vendored
|
|
@ -162,7 +162,7 @@ jobs:
|
|||
should_run: ${{ steps.decide.outputs.should_run }}
|
||||
head_sha: ${{ steps.decide.outputs.head_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
|
@ -332,7 +332,7 @@ jobs:
|
|||
# on the RC path.
|
||||
- name: Checkout (RC)
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
|
@ -349,7 +349,7 @@ jobs:
|
|||
|
||||
- name: Checkout (stable)
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
|
||||
# path performs no git pushes; the default scope is sufficient.
|
||||
with:
|
||||
|
|
|
|||
4
.github/workflows/scorecard.yml
vendored
4
.github/workflows/scorecard.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
@ -53,6 +53,6 @@ jobs:
|
|||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
2
.github/workflows/skill-sync.yml
vendored
2
.github/workflows/skill-sync.yml
vendored
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
timeout-minutes: 15
|
||||
steps:
|
||||
# persist-credentials: false — runs a read-only test, never pushes.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
report: ${{ steps.readiness.outputs.report }}
|
||||
exit_code: ${{ steps.readiness.outputs.exit_code }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
2
.github/workflows/triage-sweep.yml
vendored
2
.github/workflows/triage-sweep.yml
vendored
|
|
@ -59,7 +59,7 @@ jobs:
|
|||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: .github/scripts/triage
|
||||
sparse-checkout-cone-mode: false
|
||||
|
|
|
|||
6
.github/workflows/trivy.yml
vendored
6
.github/workflows/trivy.yml
vendored
|
|
@ -45,7 +45,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Build image (load locally for scan)
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.image.dockerfile }}
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
|
|
|||
6
.github/workflows/workflow-lint.yml
vendored
6
.github/workflows/workflow-lint.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -611,11 +611,13 @@ 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. |
|
||||
| `GITNEXUS_EMBEDDING_RETRY_TIMEOUTS` | unset | When truthy (`1`/`true`/`yes`), per-attempt HTTP embedding timeouts (`TimeoutError` on fetch or body read) go through the bounded `GITNEXUS_EMBEDDING_MAX_ATTEMPTS` retry loop instead of failing the job. Any other value leaves it off, so cloud/default timeouts remain terminal. | Local accelerators that drop a device lock when the client disconnects and succeed on the next request (observed with FastFlowLM on Ryzen AI). |
|
||||
| `GITNEXUS_EMBEDDING_SIDECAR_TIMEOUT_MS` | `180000` (3 minutes) | Per-request IPC timeout for local embedding sidecar embed batches. On overrun the parent SIGKILLs the sidecar child and rejects the batch. Init still uses the HF download budget (`HF_DOWNLOAD_TIMEOUT_MS` × attempts), not this knob. | Large embed batches or slow local ONNX inference cause sidecar request timeouts during `analyze --embeddings`, `embeddings sync`, serve, or MCP. |
|
||||
| `GITNEXUS_VECTOR_MAX_DISTANCE` | `0.6` for CLI/MCP `query`; `0.5` for standalone semantic search | Maximum cosine distance for vector hits, applied to both indexed search and exact-scan fallback. Hits must have `distance < cutoff`. Unset/blank uses the default; non-numeric, non-finite, zero, or negative values warn and use the default; finite values above `2` warn and clamp to `2`. | Paraphrased queries have weak semantic recall with your embedding model. See [Vector cutoff tuning](#vector-cutoff-tuning). |
|
||||
| `GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS` | unset | When truthy (`1`/`true`/`yes`), forces in-process cache-guard validation once a batch has ≥128 requests. In-process mode also auto-selects when `packageRoot`/`buildRoot` fail `W_OK` with `EACCES`/`EROFS`. Otherwise those large batches use a Node subprocess probe. Batches under 128 always stay in-process. | Trusted or read-only installs where two identity subprocess spawns per analyze dominate wall time; leave unset to keep the default isolation path on writable trees. |
|
||||
| `GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO` | on (unset) | Memoizes `resolveDefGraphId` per `nodeLookup` instance (WeakMap). Enabled by default. Set to `0`/`false`/`off`/`no` to disable and recompute on every call (debug / bisect memo bugs). | Suspecting stale graph-id resolution after a lookup rebuild, or comparing memo vs uncached cost on a large index. |
|
||||
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
|
||||
|
|
@ -655,6 +657,22 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
|||
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
|
||||
| `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. |
|
||||
|
||||
### Vector cutoff tuning
|
||||
|
||||
`GITNEXUS_VECTOR_MAX_DISTANCE` controls which vector candidates enter hybrid search. Cosine distance is lower for more similar vectors; the appropriate cutoff depends on the embedding model and repository. A higher cutoff can recover useful semantic matches, but can also admit less relevant hits and change the fused ranking. The default is `0.6` for CLI and MCP `query`, and `0.5` for the standalone semantic-search function.
|
||||
|
||||
For an index that already has embeddings, compare the default with `0.8` and `1.0` on representative queries, including paraphrases, exact symbol names, and queries that should have no relevant result:
|
||||
|
||||
```bash
|
||||
GITNEXUS_VECTOR_MAX_DISTANCE=0.8 gitnexus query "how are expired sessions removed" --limit 10
|
||||
```
|
||||
|
||||
Check both whether the expected symbols appear and where they rank. In [#3457](https://github.com/abhigyanpatwari/GitNexus/issues/3457), the reporter's `voyage-code-4` sample found 20 of 32 paraphrased targets at `0.6`, 25 at `0.8`, and 28 at `1.0`. Those values are a tuning starting point for that model, not universal recommendations: one paraphrased target fell from first to twelfth at `1.0`. The sample covered eight repositories, recorded no raw distances, used agent-written queries after reading the code, and did not send Voyage's `input_type`.
|
||||
|
||||
Set the variable in the process that runs the search. For MCP, add it to the server's launch environment and restart the server; for `gitnexus serve`, set it in that process's environment and restart it. Setting it only during `analyze`, or exporting it in another shell, does not configure an existing server. Changing the cutoff requires no reindex.
|
||||
|
||||
The cutoff only filters available vector candidates. It cannot add missing embeddings or repair a mismatch between the indexing and query-time embedding configuration; see the [embeddings runbook](RUNBOOK.md#embeddings). A cutoff of `2` is very permissive, but still rejects distance exactly `2` and retains the search candidate limits.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
|
|||
|
|
@ -87,6 +87,14 @@ npx gitnexus analyze --force
|
|||
|
||||
If it recurs, the cause is almost always environmental rather than a code defect: check free disk space on the volume holding `.gitnexus/`, make sure no second `analyze` is running against the same repo (both use `.gitnexus/csv` for staging), then run `npx gitnexus doctor`. The check compares in-memory relationship totals (including streamed rows) against what the DB hands back, and is deliberately skipped on incremental runs, where the two counts are not comparable.
|
||||
|
||||
**Weak semantic recall despite existing embeddings:** If paraphrased queries look like keyword-only search, the distance cutoff may be too strict for the embedding model. CLI and MCP `query` default to `0.6`. From the indexed repository, try a broader cutoff:
|
||||
|
||||
```bash
|
||||
GITNEXUS_VECTOR_MAX_DISTANCE=0.8 npx gitnexus query "how are expired sessions removed" --limit 10
|
||||
```
|
||||
|
||||
Compare target inclusion and ranking against the default; a broader cutoff also admits less relevant hits. Set the variable in the MCP/serve launch environment and restart that process when tuning a server. A cutoff change needs no reindex and setting it only during `analyze` does not persist it. If the index has no vectors, generate embeddings first; if the embedding model or dimensions differ between indexing and querying, align that configuration and regenerate vectors. See [Vector cutoff tuning](README.md#vector-cutoff-tuning) for validation rules and the limited `voyage-code-4` evidence from #3457.
|
||||
|
||||
**Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output.
|
||||
|
||||
---
|
||||
|
|
|
|||
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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
|
|||
step for step in containment["steps"] if str(step.get("uses", "")).startswith("actions/setup-node@")
|
||||
)
|
||||
claude_lock = json.loads((repo_root / ".github" / "claude-canary-runtime" / "package-lock.json").read_text())
|
||||
setup_uv = "astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990"
|
||||
setup_uv = "astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9"
|
||||
assert workflow.count(setup_uv) >= 3
|
||||
assert workflow.count("version: '0.11.23'") >= 3
|
||||
assert workflow.count("uv run --locked --extra dev python -m pytest") >= 3
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
293
eval/uv.lock
generated
293
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]]
|
||||
|
|
@ -1992,11 +2033,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "oauthlib"
|
||||
version = "3.3.1"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/d8/a1bcc8ba112a627f8ffbdc212a78ce18d3ac07e91a5ca65d27918eee25a1/oauthlib-4.0.0.tar.gz", hash = "sha256:efb274799819440f95b4ab3b818869f1ce9ae26c5beacba0201d1a1b76b54f86", size = 187232, upload-time = "2026-09-28T06:01:18.77Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/f4/78229a1066068ca14fc60fb26cf7381cabe4382261392b90e5f9552722d4/oauthlib-4.0.0-py3-none-any.whl", hash = "sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd", size = 159715, upload-time = "2026-09-28T06:01:17.008Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2522,11 +2563,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.13.0"
|
||||
version = "2.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -3306,11 +3347,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
82
gitnexus-web/package-lock.json
generated
82
gitnexus-web/package-lock.json
generated
|
|
@ -9,16 +9,16 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.5.8",
|
||||
"@langchain/core": "^1.2.8",
|
||||
"@langchain/core": "^1.2.13",
|
||||
"@langchain/google-genai": "^2.3.1",
|
||||
"@langchain/langgraph": "^1.4.14",
|
||||
"@langchain/langgraph": "^1.4.18",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.13",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "^1.20.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.15",
|
||||
"dompurify": "^3.4.16",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
|
|
@ -26,9 +26,9 @@
|
|||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.5.11",
|
||||
"langchain": "^1.5.14",
|
||||
"lru-cache": "^11.5.3",
|
||||
"lucide-react": "^1.46.0",
|
||||
"mermaid": "^11.17.2",
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
|
|
@ -1083,9 +1083,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.11.tgz",
|
||||
"integrity": "sha512-8yuWLLloTSYA453akm2JSadOVa8kGDY8v+kTzQ6kTY6aIETTEIxgysjZWyKrWQLo3UazctsSoGJ8JrdCGFL4/w==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.13.tgz",
|
||||
"integrity": "sha512-ADGTxZ84n3civruUX1LlTOdua/PDGoni2U6TmKTX7hvRU2Jlepu8vLJI4Wnwj45KUKhUCrW94Il12Q2bxE3e8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
|
|
@ -1116,13 +1116,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
"version": "1.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.14.tgz",
|
||||
"integrity": "sha512-uWAdRYTllfKCnTrlyovExPJCHJwcf3Wl2LzUlnaqsT7Rmoo3aCeYtq/7MV/Pw4q11motG8pR8bjr6T6V8Pe1gQ==",
|
||||
"version": "1.4.18",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.18.tgz",
|
||||
"integrity": "sha512-yrMMJ9hk2NVMD2xU2WoVrgFawAU6s/RzEl/dX9BhlyxZHazo1wHY35z4K2BIBzTUh4z3giCzrUoqRAqW2CWpWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph-checkpoint": "^1.1.5",
|
||||
"@langchain/langgraph-sdk": "~1.10.2",
|
||||
"@langchain/langgraph-sdk": "~1.12.0",
|
||||
"@langchain/protocol": "^0.0.19",
|
||||
"@standard-schema/spec": "1.1.0"
|
||||
},
|
||||
|
|
@ -1147,9 +1147,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.10.2.tgz",
|
||||
"integrity": "sha512-86qsfdBZWu1ZgywLN8AThU/jXi9rjPDZPWcTJp4SA1A/L62ypTNoSXbvtiwZt1odokXccYTxK1XWS8tmVdvEmw==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.12.0.tgz",
|
||||
"integrity": "sha512-F3AOZjKZRUGmE3FzY+RaVZI6WzXOkwnuF7jqgto6rDPSnO9OdVdYGvwGDi3jjRGf5xLoDtX2dpsR9z5KptU+5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/protocol": "^0.0.19",
|
||||
|
|
@ -1194,9 +1194,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
|
||||
"integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz",
|
||||
"integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
@ -2064,9 +2064,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2438,9 +2438,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
|
||||
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
|
||||
"version": "26.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
|
||||
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3410,9 +3410,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -4291,9 +4291,9 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.15",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
|
||||
"integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
|
||||
"version": "3.4.16",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
|
||||
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
|
|
@ -5108,9 +5108,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -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": {
|
||||
|
|
@ -5513,9 +5513,9 @@
|
|||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langchain": {
|
||||
"version": "1.5.11",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.5.11.tgz",
|
||||
"integrity": "sha512-6Sx9N5ylAJ11WrP1QnJLSIo75UABZbshzTJgG28H4mXuGcDk+w+7ZaNLkmGIh9sy/3PZcYS8UrI2rvH6A8NYIg==",
|
||||
"version": "1.5.14",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.5.14.tgz",
|
||||
"integrity": "sha512-/orHDk5xbNSIJc9UhwmxFYHXbr/3RaBeQ3zX0xovEWfPg7Lmj8EJCMOXIUTCe2UtsFgfbyWtlwh51NM562LnZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph": "^1.4.13",
|
||||
|
|
@ -5527,7 +5527,7 @@
|
|||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.2.10"
|
||||
"@langchain/core": "^1.2.13"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.5.8",
|
||||
"@langchain/core": "^1.2.8",
|
||||
"@langchain/core": "^1.2.13",
|
||||
"@langchain/google-genai": "^2.3.1",
|
||||
"@langchain/langgraph": "^1.4.14",
|
||||
"@langchain/langgraph": "^1.4.18",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.13",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "^1.20.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.15",
|
||||
"dompurify": "^3.4.16",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
|
|
@ -36,9 +36,9 @@
|
|||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.5.11",
|
||||
"langchain": "^1.5.14",
|
||||
"lru-cache": "^11.5.3",
|
||||
"lucide-react": "^1.46.0",
|
||||
"mermaid": "^11.17.2",
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
|
|
|
|||
|
|
@ -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,6 @@
|
|||
{
|
||||
"fingerprint": "dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55",
|
||||
"fingerprint": "9408aad13feb6a65c6ce031a3264b72d1afdb1fdd1e984666fe30c3ecc24d66f",
|
||||
"_rebaselined_3161_static_gated_column": "Same change as baselines.json's `_rebaselined_3161_static_gated_column`: every relationship row now ends in a `staticGated` cell (`0` here, no PDG edge is a gated call), so each of the 36,000 `rel_BasicBlock_BasicBlock.csv` data rows grew by 2 bytes (`...,\"T\",0` -> `...,\"T\",0,0`) while the 7,200 BasicBlock rows are unchanged. Evidence is the inverse operation: stripping the trailing `,0` from the rel rows and re-hashing the sorted bb + rel rows gives EXACTLY the prior baseline 381de8dede253140953775c290bf9a25f82ebf3cc8ecb5250ae06a63f648b089. byte_identical_nodes / byte_identical_edges stayed true and resident_basic_blocks stayed 0 while the fingerprint gate was red, so the streamed sink still matches the whole-graph emit byte for byte and the RSS bound holds. Prior 381de8dede253140953775c290bf9a25f82ebf3cc8ecb5250ae06a63f648b089 -> dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55.",
|
||||
"_note": "Byte-identity + bounded-retention gate for streaming/chunked PDG emit (#2202). fingerprint = sha256 of the sorted, header-stripped BasicBlock + PDG-edge data rows of the canonical synthetic set. --check also asserts the streamed PdgEmitSink output is byte-identical to the whole-graph streamAllCSVsToDisk emit (byte_identical_nodes/edges) and that the in-memory graph retains 0 BasicBlocks (resident_basic_blocks === 0, the O(chunk) RSS bound). Regenerate via `node --import tsx bench/emit-persistence/measure-streaming.mjs`."
|
||||
"_note": "Byte-identity + bounded-retention gate for streaming/chunked PDG emit (#2202). fingerprint = sha256 of the sorted, header-stripped BasicBlock + PDG-edge data rows of the canonical synthetic set. --check also asserts the streamed PdgEmitSink output is byte-identical to the whole-graph streamAllCSVsToDisk emit (byte_identical_nodes/edges) and that the in-memory graph retains 0 BasicBlocks (resident_basic_blocks === 0, the O(chunk) RSS bound). Regenerate via `node --import tsx bench/emit-persistence/measure-streaming.mjs`.",
|
||||
"_rebaselined_3442_ladybug_null_cells": "Same NULL serialization change as baselines.json (#3442). Compared the whole-graph output with the parent writer: file set, row order and all other bytes match. The fingerprinted BasicBlock and PDG-edge files contain exactly 38,400 quoted empty cells changed to bare cells (14,400 BasicBlock cells and 24,000 edge cells); restoring their quotes reproduces the exact prior fingerprint dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55. The new fingerprint is 9408aad13feb6a65c6ce031a3264b72d1afdb1fdd1e984666fe30c3ecc24d66f. Both streamed/whole byte-identity flags remain true and resident_basic_blocks remains 0."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"fingerprint": "d3c3a53ead47663816344369dc0e501f31d60c462039de190ab6f5361037bd79",
|
||||
"fingerprint": "2f5c46506cb8dfb79a65a276646ace766cc7645fabab8b383c89f5f0270cc30b",
|
||||
"_rebaselined_objective_c_node_tables": "Objective-C support adds Protocol and Category node tables to csv-generator.ts's MULTI_LANG_TYPES, so this deterministic 2,400-entity synthetic emit now creates 38 CSV files rather than 36. The two additions, category.csv and protocol.csv, are both expected 55-byte header-only files because the synthetic graph contains no Objective-C nodes. Re-emitting the graph and recomputing the fingerprint after excluding exactly those two files yields the exact prior baseline 011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2, proving all 36 pre-existing files retain their bytes, routing, and within-file order. Prior 011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2 -> d3c3a53ead47663816344369dc0e501f31d60c462039de190ab6f5361037bd79. The timing guard remains within budget: local scaling_ratio 0.965 against 1.8 and elapsed_ms_large 69.24ms against 1000ms.",
|
||||
"_rebaselined_3161_static_gated_column": "Every relationship row gained a trailing `staticGated` BOOLEAN column (RELATION_SCHEMA in src/core/lbug/schema.ts, REL_CSV_HEADER + buildRelRow in csv-generator.ts, the fallback CREATE in lbug-adapter.ts), written as `0` for every edge that does not carry the flag and `1` for a Zig call site inside a comptime-false branch (PR #3161). Unlike the earlier header-only rebaselines this one touches ROWS, so the evidence is the inverse operation rather than a file-set diff: re-emitting this bench's 2,400-entity graph on the branch produces the same 36 CSV files; exactly 3 of them differ from a copy with the new column stripped (`rel_File_Class.csv` 156228 -> 151416 bytes, `rel_File_Function.csv` 334008 -> 324396, `rel_Function_Function.csv` 185028 -> 180216: one header field plus `,0` per row, 4,800 / 9,600 / 4,800 rows, 2 bytes each), the other 33 files are byte-identical, and the per-file fingerprint over the stripped copies is EXACTLY the prior baseline 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a. So no row moved between pair files and no row reordered; the only bytes that changed are the appended column. Prior 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a -> 011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2. Timing gates passed while the guard was red: scaling_ratio 0.82 against the 1.8 budget, elapsed_ms_large 185.54ms against the 1000ms backstop, so no throughput claim is being rebaselined away.",
|
||||
"scaling_budget": 1.8,
|
||||
|
|
@ -9,5 +9,6 @@
|
|||
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row \u2014 csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
|
||||
"_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.",
|
||||
"_rebaselined_3107_route_runtime_evidence": "Route gained trailing runtimeConfirmed BOOLEAN, runtimeSource STRING, and runtimeStatus STRING columns in its schema, CSV header/rows, COPY statement, and graph API projection. The deterministic emit still produces the same 35 CSV files; the synthetic benchmark graph has no Route rows, so the only byte drift is the Route CSV header and no row moved or reordered. Prior c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e -> 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2. While the guard was red, scaling_ratio was 1.044 against the 1.8 budget and elapsed_ms_large was 121.08ms against the 1000ms backstop.",
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted \u2014 binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside \u2014 bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted \u2014 binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside \u2014 bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`.",
|
||||
"_rebaselined_3442_ladybug_null_cells": "LadybugDB 0.21.1 preserves quoted empty strings, so escapeCSVField now emits bare empty cells to retain the SQL NULL meaning of the 0.18.3 writer (#3442). Compared the canonical emit with the parent writer: all 38 file names and every row order match; exactly 31,200 quoted empty cells became bare cells in six files, with no other byte changes. Restoring quotes around those cells reproduces the exact prior fingerprint d3c3a53ead47663816344369dc0e501f31d60c462039de190ab6f5361037bd79. The new fingerprint is 2f5c46506cb8dfb79a65a276646ace766cc7645fabab8b383c89f5f0270cc30b. Timing budgets remain unchanged."
|
||||
}
|
||||
|
|
|
|||
86
gitnexus/bench/incremental-write-integrity/README.md
Normal file
86
gitnexus/bench/incremental-write-integrity/README.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# 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
|
||||
node --import tsx bench/incremental-write-integrity/measure.cjs --check
|
||||
node bench/incremental-write-integrity/reproduce.cjs
|
||||
```
|
||||
|
||||
The first command measures the production reconciliation, including every
|
||||
single-label scan and comparison of ID, name, path and source range. Construction,
|
||||
CSV loading, checkpoints and correctness controls are outside the timed region.
|
||||
Two warm-ups precede seven measured samples. Exact counts and SHA-256 fixture
|
||||
fingerprints reject empty or incomplete work. `--check` gates normalized scaling
|
||||
against the committed budget, rather than a machine-specific time limit.
|
||||
|
||||
On Node v24.19.0, Linux x64, Xeon Platinum 8370C, the initial measurement was
|
||||
583 ms for 16,384 nodes and 2,810 ms for 65,536 nodes per reconciliation. The
|
||||
normalized scaling was 1.206 (budget 2). Analyze performs two reconciliations:
|
||||
after graph COPY/checkpoint and after FTS/embedding work plus a final checkpoint,
|
||||
before registration, freshness metadata or staging publication.
|
||||
|
||||
The verifier covers retained nodes as well as the write set. A path/range filter
|
||||
would let a corrupted field hide its own row; a count would miss swapped fields
|
||||
or blank IDs. Folder lifecycle, preserved Community/Process layers and streamed
|
||||
BasicBlock rows require other oracles and are excluded. Relationships and source
|
||||
content are outside this identity check.
|
||||
|
||||
## Native-only reduction
|
||||
|
||||
`reproduce.cjs` uses only `@ladybugdb/core`, its native schema/query/COPY API, and
|
||||
synthetic ASCII CSV files. There is no parser, parse cache, graph adapter, FTS,
|
||||
relationship table, periodic checkpoint driver or concurrent writer. Compression
|
||||
is disabled and COPY is serial, matching the production settings.
|
||||
|
||||
It loads 8,192 Function rows, checkpoints, deletes 64 rows belonging to the first
|
||||
and last owners, then copies back those same 64 tuples. An independent tuple-set
|
||||
oracle validates the complete single-label scan before and after each operation.
|
||||
It also compares affected scan rows with primary-key lookups after reopening.
|
||||
Use `--keep` to retain the synthetic database and CSV files; the JSON output gives
|
||||
their directory. `--require-corruption` is a diagnostic assertion, deliberately
|
||||
not a CI requirement: it should fail when the native defect is fixed.
|
||||
|
||||
With the original `@ladybugdb/core` 0.18.3, this reduction produced:
|
||||
|
||||
| Phase | Rows | Incorrect tuples |
|
||||
| ------------------------- | ----: | ---------------: |
|
||||
| Initial COPY + checkpoint | 8,192 | 0 |
|
||||
| DELETE | 8,128 | 0 |
|
||||
| Incremental COPY | 8,192 | 3,936 |
|
||||
| Explicit checkpoint | 8,192 | 3,936 |
|
||||
| Read-only reopen | 8,192 | 3,936 |
|
||||
|
||||
With `@ladybugdb/core` 0.21.1, the same reduction returns zero incorrect
|
||||
tuples in every phase. The production reconciliation and negative controls
|
||||
remain required; this result covers the reduced fixture, not a replay of the
|
||||
original incident.
|
||||
|
||||
For example, the scan returned an empty `id` at native offset 64, while a
|
||||
primary-key lookup at that same offset returned
|
||||
`Function:src/owner2.ts:fn64` with the correct name/path/range. Smaller 1,024,
|
||||
2,048 and 4,096-row fixtures remained healthy in the same three-cycle adapter
|
||||
probe. The affected tuple count varies with fixture size and scan shape.
|
||||
|
||||
This isolates a native scan/lookup discrepancy introduced by incremental COPY
|
||||
into a previously populated table. It survives checkpoint and reopen, but does
|
||||
not establish physical byte loss or the precise C++ defect. The reported Unicode
|
||||
change and FTS build are unnecessary for this reduction; this does not prove
|
||||
that the original incident has exactly the same native cause.
|
||||
|
||||
The measurement harness runs selective native writes too. An independent oracle
|
||||
requires the production verifier to reject any inconsistent scan, both before
|
||||
and after checkpoint/reopen; healthy scans must certify their complete identity
|
||||
set. It does not quietly treat the native discrepancy as a successful graph.
|
||||
|
||||
Missing-row and swapped-range negative controls separately verify that the
|
||||
check fails closed. Publication tests inject damage after COPY and after FTS,
|
||||
assert unchanged registry/freshness, preserve the live staged baseline, and
|
||||
exercise automatic dirty-index recovery followed by a no-op run.
|
||||
An in-place verification failure keeps a non-FTS dirty phase: an FTS-only
|
||||
repair cannot clear the marker and recertify the inconsistent graph.
|
||||
The same protection applies when FTS returns but analyzer finalization fails
|
||||
before the final identity scan: recovery still rebuilds the graph.
|
||||
14
gitnexus/bench/incremental-write-integrity/baseline.json
Normal file
14
gitnexus/bench/incremental-write-integrity/baseline.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"version": 1,
|
||||
"linear_scaling_budget": 2,
|
||||
"sizes": [
|
||||
{
|
||||
"nodes": 16384,
|
||||
"fingerprint": "17aa2ded124af9cbd15a9fe13013410e92a478f88e63a2ad9859a05aacef8efe"
|
||||
},
|
||||
{
|
||||
"nodes": 65536,
|
||||
"fingerprint": "a6ce82aa1d07804d16b916892dfb6ca0e6f5ce5ed606f47cb1321eb124c6ac44"
|
||||
}
|
||||
]
|
||||
}
|
||||
194
gitnexus/bench/incremental-write-integrity/measure.cjs
Normal file
194
gitnexus/bench/incremental-write-integrity/measure.cjs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Native identity reconciliation overhead and scaling.
|
||||
* node --import tsx bench/incremental-write-integrity/measure.cjs [--check]
|
||||
*
|
||||
* COPY, graph construction, fingerprints and correctness controls are untimed.
|
||||
* Each timed sample includes all single-label scans and exact tuple checks.
|
||||
* Counts/fingerprints reject empty output; timing gates use normalized scaling.
|
||||
*/
|
||||
const assert = require('node:assert/strict');
|
||||
const { mkdtemp, rm, readFile } = require('node:fs/promises');
|
||||
const { tmpdir, cpus } = require('node:os');
|
||||
const { join, resolve } = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { createHash } = require('node:crypto');
|
||||
const ROOT = resolve(__dirname, '../..');
|
||||
const source = (file) => import(pathToFileURL(join(ROOT, file)).href);
|
||||
|
||||
(async () => {
|
||||
const { createKnowledgeGraph } = await source('src/core/graph/graph.ts');
|
||||
const { reconcileGraphNodeIdentities } = await source(
|
||||
'src/core/incremental/write-reconciliation.ts',
|
||||
);
|
||||
const adapter = await source('src/core/lbug/lbug-adapter.ts');
|
||||
const baseline = JSON.parse(await readFile(join(__dirname, 'baseline.json'), 'utf8'));
|
||||
assert.equal(baseline.version, 1);
|
||||
assert.ok(Number.isFinite(baseline.linear_scaling_budget) && baseline.linear_scaling_budget > 0);
|
||||
const results = [];
|
||||
for (const size of baseline.sizes) {
|
||||
assert.ok(Number.isSafeInteger(size.nodes) && size.nodes > 0);
|
||||
assert.match(size.fingerprint, /^[a-f0-9]{64}$/);
|
||||
const graph = createKnowledgeGraph();
|
||||
const hash = createHash('sha256');
|
||||
for (let i = 0; i < size.nodes; i++) {
|
||||
const id = `Function:src/owner${Math.floor(i / 32)}.ts:fn${i}`;
|
||||
const properties = {
|
||||
name: `fn${i}`,
|
||||
filePath: `src/owner${Math.floor(i / 32)}.ts`,
|
||||
startLine: (i % 32) * 4,
|
||||
endLine: (i % 32) * 4 + 2,
|
||||
};
|
||||
graph.addNode({ id, label: 'Function', properties });
|
||||
hash.update(
|
||||
JSON.stringify([
|
||||
id,
|
||||
properties.name,
|
||||
properties.filePath,
|
||||
properties.startLine,
|
||||
properties.endLine,
|
||||
]) + '\n',
|
||||
);
|
||||
}
|
||||
assert.equal(hash.digest('hex'), size.fingerprint);
|
||||
const directory = await mkdtemp(join(tmpdir(), 'gnx-reconciliation-bench-'));
|
||||
try {
|
||||
await adapter.initLbug(join(directory, 'lbug'), { skipFts: true });
|
||||
await adapter.loadGraphToLbug(
|
||||
graph,
|
||||
directory,
|
||||
directory,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'none',
|
||||
);
|
||||
await adapter.tryFlushWAL();
|
||||
const samples = [];
|
||||
let calls = 0;
|
||||
const query = async (sql) => {
|
||||
calls++;
|
||||
return adapter.executeQuery(sql);
|
||||
};
|
||||
for (let run = 0; run < 9; run++) {
|
||||
calls = 0;
|
||||
const start = performance.now();
|
||||
const receipt = await reconcileGraphNodeIdentities(graph, query, 'benchmark');
|
||||
const elapsed = performance.now() - start;
|
||||
assert.equal(receipt.nodes, size.nodes);
|
||||
assert.equal(calls, receipt.tables);
|
||||
if (run >= 2) samples.push(elapsed);
|
||||
}
|
||||
const first = graph.iterNodes().next().value;
|
||||
const alteredQuery = async (sql) => {
|
||||
const rows = await adapter.executeQuery(sql);
|
||||
if (sql.includes('(n:`Function`)')) rows.find((r) => r.id === first.id).startLine++;
|
||||
return rows;
|
||||
};
|
||||
await assert.rejects(
|
||||
reconcileGraphNodeIdentities(graph, alteredQuery, 'negative-control'),
|
||||
/startLine/,
|
||||
);
|
||||
await assert.rejects(
|
||||
reconcileGraphNodeIdentities(graph, async () => [], 'negative-control'),
|
||||
/missing ID/,
|
||||
);
|
||||
|
||||
// Native 0.18.3 can corrupt scan projections after this real write, even
|
||||
// without FTS. Use an independent tuple-set oracle to require rejection
|
||||
// whenever the scan is wrong; a future native fix can pass normally.
|
||||
const { extractChangedSubgraph } = await source('src/core/incremental/subgraph-extract.ts');
|
||||
const files = new Set(['src/owner0.ts', `src/owner${Math.floor((size.nodes - 1) / 32)}.ts`]);
|
||||
const wanted = new Set(
|
||||
[...graph.iterNodes()].map((node) =>
|
||||
JSON.stringify([
|
||||
node.id,
|
||||
node.properties.name,
|
||||
node.properties.filePath,
|
||||
node.properties.startLine,
|
||||
node.properties.endLine,
|
||||
]),
|
||||
),
|
||||
);
|
||||
const nativePhases = [];
|
||||
const audit = async (phase) => {
|
||||
const rows = await adapter.executeQuery(
|
||||
'MATCH (n:Function) RETURN n.id AS id, n.name AS name, ' +
|
||||
'n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine',
|
||||
);
|
||||
const actual = new Set(
|
||||
rows.map((r) => JSON.stringify([r.id, r.name, r.filePath, r.startLine, r.endLine])),
|
||||
);
|
||||
const mismatched = [...wanted].filter((tuple) => !actual.has(tuple)).length;
|
||||
const rejected =
|
||||
rows.length !== size.nodes || mismatched > 0 || actual.size !== wanted.size;
|
||||
if (rejected) {
|
||||
await assert.rejects(
|
||||
reconcileGraphNodeIdentities(graph, adapter.executeQuery, phase),
|
||||
/Graph identity reconciliation/,
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
(await reconcileGraphNodeIdentities(graph, adapter.executeQuery, phase)).nodes,
|
||||
size.nodes,
|
||||
);
|
||||
}
|
||||
nativePhases.push({
|
||||
phase,
|
||||
rows: rows.length,
|
||||
missing_tuples: mismatched,
|
||||
verdict: rejected ? 'rejected' : 'certified',
|
||||
});
|
||||
};
|
||||
await adapter.deleteNodesForFiles([...files]);
|
||||
await adapter.loadGraphToLbug(
|
||||
extractChangedSubgraph(graph, files),
|
||||
directory,
|
||||
directory,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'none',
|
||||
);
|
||||
await audit('post-COPY');
|
||||
await adapter.tryFlushWAL();
|
||||
await audit('post-checkpoint');
|
||||
await adapter.closeLbug();
|
||||
await adapter.initLbug(join(directory, 'lbug'), { readOnly: true, skipFts: true });
|
||||
await audit('reopened');
|
||||
samples.sort((a, b) => a - b);
|
||||
results.push({
|
||||
nodes: size.nodes,
|
||||
queries: calls,
|
||||
min_ms: +samples[0].toFixed(3),
|
||||
median_ms: +samples[Math.floor(samples.length / 2)].toFixed(3),
|
||||
fingerprint: size.fingerprint,
|
||||
native_phases: nativePhases,
|
||||
});
|
||||
} finally {
|
||||
await adapter.closeLbug();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
const scaling =
|
||||
results[1].median_ms / results[0].median_ms / (results[1].nodes / results[0].nodes);
|
||||
const report = {
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
cpu: cpus()[0].model,
|
||||
results,
|
||||
normalized_scaling: +scaling.toFixed(3),
|
||||
negative_controls: 'missing/swapped fields rejected',
|
||||
native_sequences:
|
||||
'selective delete/COPY, checkpoint and read-only reopen checked against independent tuple oracle',
|
||||
};
|
||||
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
||||
if (process.argv.includes('--check'))
|
||||
assert.ok(
|
||||
scaling <= baseline.linear_scaling_budget,
|
||||
`normalized scaling ${scaling} exceeds ${baseline.linear_scaling_budget}`,
|
||||
);
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
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;
|
||||
});
|
||||
187
gitnexus/bench/incremental-write-integrity/reproduce.cjs
Normal file
187
gitnexus/bench/incremental-write-integrity/reproduce.cjs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
#!/usr/bin/env node
|
||||
/** Native-only selective COPY scan discrepancy; no parser, graph adapter or FTS. */
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const lbug = require('@ladybugdb/core');
|
||||
|
||||
(async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ladybug-copy-identity-'));
|
||||
let database;
|
||||
let connection;
|
||||
const close = async () => {
|
||||
// Retire this session before closing so failures or reopening cannot close it twice.
|
||||
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 === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, 'Native resource cleanup failed');
|
||||
};
|
||||
const errors = [];
|
||||
try {
|
||||
const dbPath = path.join(directory, 'lbug');
|
||||
const count = 8192;
|
||||
const tuple = (i) => [
|
||||
`Function:src/owner${Math.floor(i / 32)}.ts:fn${i}`,
|
||||
`fn${i}`,
|
||||
`src/owner${Math.floor(i / 32)}.ts`,
|
||||
(i % 32) * 4,
|
||||
(i % 32) * 4 + 2,
|
||||
];
|
||||
const indices = Array.from({ length: count }, (_, i) => i);
|
||||
const expected = new Set(indices.map((i) => JSON.stringify(tuple(i))));
|
||||
const csv = (rows) =>
|
||||
'id,name,filePath,startLine,endLine\n' +
|
||||
rows
|
||||
.map((i) =>
|
||||
tuple(i)
|
||||
.map((value) => JSON.stringify(value))
|
||||
.join(','),
|
||||
)
|
||||
.join('\n') +
|
||||
'\n';
|
||||
await fs.writeFile(path.join(directory, 'full.csv'), csv(indices));
|
||||
await fs.writeFile(
|
||||
path.join(directory, 'delta.csv'),
|
||||
csv(indices.filter((i) => i < 32 || i >= count - 32)),
|
||||
);
|
||||
|
||||
// Match GitNexus's uncompressed native constructor and serial COPY options.
|
||||
const open = (readOnly) =>
|
||||
new lbug.Database(
|
||||
dbPath,
|
||||
256 * 1024 * 1024,
|
||||
false,
|
||||
readOnly,
|
||||
4 * 1024 ** 3,
|
||||
true,
|
||||
64 * 1024 * 1024,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
database = open(false);
|
||||
connection = new lbug.Connection(database);
|
||||
const query = async (cypher, params) => {
|
||||
const result = params
|
||||
? await connection.execute(await connection.prepare(cypher), params)
|
||||
: await connection.query(cypher);
|
||||
try {
|
||||
return await result.getAll();
|
||||
} finally {
|
||||
await result.close();
|
||||
}
|
||||
};
|
||||
const copy = (file) =>
|
||||
query(
|
||||
`COPY Function FROM "${path.join(directory, file).replace(/\\/g, '/')}" ` +
|
||||
`(HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`,
|
||||
);
|
||||
const phases = [];
|
||||
const scan = async (phase) => {
|
||||
const rows = await query(
|
||||
'MATCH (n:Function) RETURN id(n) AS internalID, n.id AS id, n.name AS name, ' +
|
||||
'n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine',
|
||||
);
|
||||
const key = (r) => JSON.stringify([r.id, r.name, r.filePath, r.startLine, r.endLine]);
|
||||
const bad = rows.filter((r) => !expected.has(key(r)));
|
||||
const actual = new Set(rows.map(key));
|
||||
const missing = [...expected].filter((value) => !actual.has(value)).length;
|
||||
phases.push({
|
||||
phase,
|
||||
rows: rows.length,
|
||||
wrong_tuples: bad.length,
|
||||
missing_tuples: missing,
|
||||
examples: bad.slice(0, 2),
|
||||
});
|
||||
return { rows, bad, missing };
|
||||
};
|
||||
await query(
|
||||
'CREATE NODE TABLE Function(id STRING, name STRING, filePath STRING, ' +
|
||||
'startLine INT64, endLine INT64, PRIMARY KEY(id))',
|
||||
);
|
||||
await copy('full.csv');
|
||||
await query('CHECKPOINT');
|
||||
const before = await scan('baseline');
|
||||
assert.equal(before.rows.length, count);
|
||||
assert.equal(before.missing, 0);
|
||||
const baselineIdsByOffset = new Map(before.rows.map((r) => [r.internalID.offset, r.id]));
|
||||
await query(
|
||||
"MATCH (n:Function) WHERE n.filePath IN ['src/owner0.ts', 'src/owner255.ts'] DETACH DELETE n",
|
||||
);
|
||||
const deleted = await scan('after-delete');
|
||||
assert.equal(deleted.rows.length, count - 64);
|
||||
assert.equal(deleted.bad.length, 0);
|
||||
await copy('delta.csv');
|
||||
const copied = await scan('after-copy');
|
||||
await query('CHECKPOINT');
|
||||
await scan('after-checkpoint');
|
||||
await close();
|
||||
database = open(true);
|
||||
connection = new lbug.Connection(database);
|
||||
const reopened = await scan('reopened');
|
||||
const pointLookups = [];
|
||||
for (const row of reopened.bad.slice(0, 2)) {
|
||||
const id = baselineIdsByOffset.get(row.internalID.offset);
|
||||
if (!id) continue;
|
||||
pointLookups.push({
|
||||
scan: row,
|
||||
lookup: await query(
|
||||
'MATCH (n:Function {id: $id}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, ' +
|
||||
'n.startLine AS startLine, n.endLine AS endLine',
|
||||
{ id },
|
||||
),
|
||||
});
|
||||
}
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{
|
||||
native_version: lbug.VERSION,
|
||||
node: process.version,
|
||||
phases,
|
||||
point_lookups: pointLookups,
|
||||
...(process.argv.includes('--keep') ? { directory } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
if (process.argv.includes('--require-corruption')) {
|
||||
assert.ok(
|
||||
copied.bad.length > 0 || copied.missing > 0 || copied.rows.length !== count,
|
||||
'Native failure did not reproduce; this is a diagnostic, not a passing CI invariant',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
} finally {
|
||||
try {
|
||||
await close();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
if (!process.argv.includes('--keep'))
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, 'Native benchmark failed');
|
||||
})().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.
|
||||
|
|
@ -119,7 +119,8 @@
|
|||
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: ruby-callable-alternatives/app.rb (+1 file, +58 groups). Emitter delta: +5 / -0: seeds for single-statement `if` / `elsif` branches (run_then, run_else, run_sweep, run_a, run_b); the multi-statement branch contributes nothing. capture_groups_fp 1358 -> 1416, fixture_count 91 -> 92; synthetic counts unchanged; scaling 1.04 < 1.5. Prior 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57 -> 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a."
|
||||
},
|
||||
"swift": {
|
||||
"fingerprint": "d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3",
|
||||
"fingerprint": "2507ac75ba6fb47c272a5caa97b5e8b9f6ebc5098c15295d05c7bff3cabc321a",
|
||||
"_rebaselined_3425_injected_closure": "Swift capture fixture growth: 83 to 85 fixtures, 1436 to 1553 capture groups; scaling budget unchanged.",
|
||||
"_rebaselined_3355_xcode_and_import_fixtures": "#3355 follow-up: new swift-xcode-targets fixture (four sources) and two sources added to swift-nested-packages (a Docs/Net folder decoy and an `import Net` caller). The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1393 -> 1436 and fixture_count 77 -> 83; synthetic scale counts remain 5012/16012. Prior aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853 -> d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3; measured scaling 1.036 < 1.5.",
|
||||
"_rebaselined_3355_nested_packages": "#3355: new swift-nested-packages fixture (three nested Package.swift manifests, six sources) for nested-package module grouping. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1333 -> 1393 and fixture_count 68 -> 77; synthetic scale counts remain 5012/16012. Prior c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8 -> aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853; measured scaling 1.023 < 1.5.",
|
||||
"scaling_budget": 1.5,
|
||||
|
|
@ -187,7 +188,8 @@
|
|||
"capture_groups_fp": 680
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467",
|
||||
"fingerprint": "a7972d87abd253583dafa37443bee8ddf5c635d41b4dfbb0c831efda71d82b47",
|
||||
"_rebaselined_3446_mcp_tools_fixture": "#3446 adds typescript-mcp-tools/src/{handlers,server,tools}.ts: corpus growth only, with fixture_count 171 -> 174 and capture_groups_fp 2753 -> 2894. Excluding only that fixture directory restores the prior fingerprint 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467 exactly. The scope emitter, synthetic capture counts (4503/14403), scaling budget, and other language baselines are unchanged.",
|
||||
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
|
||||
|
|
|
|||
688
gitnexus/package-lock.json
generated
688
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",
|
||||
|
|
@ -66,7 +68,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@eagleoutice/tree-sitter-r": "^1.1.2",
|
||||
"@ladybugdb/core": "0.18.3",
|
||||
"@ladybugdb/core": "0.21.1",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
|
|
@ -127,11 +129,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;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { t } from './i18n/index.js';
|
||||
import { formatSymbolLine } from './format-symbol.js';
|
||||
import { formatPathForTerminal } from './format-path.js';
|
||||
|
||||
type DetectChangesSummary = {
|
||||
changed_files?: number;
|
||||
|
|
@ -32,6 +33,7 @@ type DetectChangesResult = {
|
|||
summary?: DetectChangesSummary;
|
||||
changed_symbols?: ChangedSymbol[];
|
||||
affected_processes?: AffectedProcess[];
|
||||
unmapped_files?: string[];
|
||||
};
|
||||
|
||||
export function formatDetectChangesResult(result: unknown): string {
|
||||
|
|
@ -47,6 +49,13 @@ export function formatDetectChangesResult(result: unknown): string {
|
|||
// Both lead the output — a caveat printed after the summary is read too late.
|
||||
const notes: string[] = [];
|
||||
if (payload.partial) notes.push(t('tool.detectChanges.partial'));
|
||||
if (payload.unmapped_files?.length) {
|
||||
notes.push(
|
||||
t('tool.detectChanges.unmappedSource', {
|
||||
files: payload.unmapped_files.map(formatPathForTerminal).join(', '),
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The plain truncation note reassures that the counts are whole. That is only
|
||||
// true when the run did NOT also degrade — `changed_count` sums the batches
|
||||
// that succeeded — so the two flags together get a different sentence.
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
8
gitnexus/src/cli/format-path.ts
Normal file
8
gitnexus/src/cli/format-path.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** Quote control-bearing paths for terminal output; structured results retain raw paths. */
|
||||
export const formatPathForTerminal = (filePath: string): string =>
|
||||
/[\u0000-\u001f\u007f-\u009f]/.test(filePath)
|
||||
? JSON.stringify(filePath).replace(
|
||||
/[\u007f-\u009f]/g,
|
||||
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
||||
)
|
||||
: filePath;
|
||||
|
|
@ -4,16 +4,18 @@
|
|||
* against a real group, which is how `STALE (-1 commits behind)` went unnoticed
|
||||
* (#3256).
|
||||
*/
|
||||
import type { StalenessStatus } from '../core/staleness-status.js';
|
||||
|
||||
/** The fields of a `groupStatus` repo row the index column reads. */
|
||||
export interface GroupRepoIndexRow {
|
||||
indexStale: boolean;
|
||||
commitsBehind?: number;
|
||||
status?: StalenessStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* The index column of a `group status` row. The output is unchanged except
|
||||
* for one case: a count that is not a real count renders as `?`.
|
||||
* The index column of a `group status` row. Diverged indexes differ from HEAD
|
||||
* without a forward commit count; an unavailable count renders as `?`.
|
||||
*
|
||||
* `group/service.ts` has always reported a repo with no recorded commit as
|
||||
* `{ indexStale: true, commitsBehind: -1 }`. The previous `?? '?'` fallback
|
||||
|
|
@ -21,6 +23,7 @@ export interface GroupRepoIndexRow {
|
|||
*/
|
||||
export const formatIndexStatusCell = (row: GroupRepoIndexRow): string => {
|
||||
if (!row.indexStale) return 'OK ';
|
||||
if (row.status === 'diverged') return 'STALE (index differs from HEAD)';
|
||||
const n = row.commitsBehind;
|
||||
const count = typeof n === 'number' && n >= 0 ? String(n) : '?';
|
||||
return `STALE (${count} commits behind)`;
|
||||
|
|
|
|||
|
|
@ -137,7 +137,9 @@ export const en = {
|
|||
'tool.detectChanges.noOverlappingSymbols':
|
||||
'Diff touched {{files}} file(s) but no indexed symbols overlap those hunks — not a clean tree.',
|
||||
'tool.detectChanges.partial':
|
||||
'PARTIAL RESULT: a graph query failed, so changed symbols may be missing. Do not read this as a clean pre-commit check.',
|
||||
'PARTIAL RESULT: changed-symbol or process mapping is incomplete. Do not read this as a clean pre-commit check.',
|
||||
'tool.detectChanges.unmappedSource':
|
||||
'No symbols mapped for changed source files: {{files}}. Rebuild the index and inspect the diff; retry alone may not resolve missing or out-of-range symbols.',
|
||||
'tool.detectChanges.truncated':
|
||||
'LISTING CAPPED: the changed-symbol list was capped, so it does not name every changed symbol. The counts and risk level still cover all of them.',
|
||||
// The reassurance above is only true on its own. When the run also degraded,
|
||||
|
|
@ -428,5 +430,5 @@ export const en = {
|
|||
'help.identityCache.environment':
|
||||
'\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.',
|
||||
'help.analyze.environment':
|
||||
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated <repo-basename>-<canonical-path-hash>/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n GITNEXUS_MAX_PROCESSES=N Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Distinct from query-time IMPACT_MAX_CHUNKS.\n GITNEXUS_MAX_PROCESS_BRANCHING=N Process-detection per-node branching cap. Default 4.\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N Process-detection DFS depth cap. Default 10.\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N Ranked entry-point candidate pool. Default 200. Raise when the warning names this knob; doubling is the usual first raise.\n\nCLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated <repo-basename>-<canonical-path-hash>/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for CLI/MCP query, 0.5 for standalone semantic search. Tune for your embedding model: higher values can improve recall but may reduce relevance. Set in the query/server environment; no reindex needed.\n GITNEXUS_MAX_PROCESSES=N Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Distinct from query-time IMPACT_MAX_CHUNKS.\n GITNEXUS_MAX_PROCESS_BRANCHING=N Process-detection per-node branching cap. Default 4.\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N Process-detection DFS depth cap. Default 10.\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N Ranked entry-point candidate pool. Default 200. Raise when the warning names this knob; doubling is the usual first raise.\n\nCLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ export const zhCN = {
|
|||
'tool.detectChanges.noOverlappingSymbols':
|
||||
'diff 触及 {{files}} 个文件,但没有索引符号与这些 hunk 重叠 — 并非干净工作区。',
|
||||
'tool.detectChanges.partial':
|
||||
'结果不完整:图查询失败,可能遗漏已变更符号。请勿将其视为通过的提交前检查。',
|
||||
'结果不完整:变更符号或流程映射不完整。请勿将其视为通过的提交前检查。',
|
||||
'tool.detectChanges.unmappedSource':
|
||||
'变更源码文件未映射到符号:{{files}}。请重建索引并检查 diff;仅重试可能无法解决符号缺失或源码范围不匹配。',
|
||||
'tool.detectChanges.truncated':
|
||||
'列表已截断:已变更符号列表被截断,未列出全部变更符号。计数与风险等级仍涵盖全部符号。',
|
||||
'tool.detectChanges.truncatedDegraded':
|
||||
|
|
@ -390,5 +392,5 @@ export const zhCN = {
|
|||
'help.identityCache.environment':
|
||||
'\n分析器身份缓存:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n 由操作员明确信任的持久缓存,用于跨进程快速查询状态。目录必须预先存在、位于 GitNexus 包/构建根目录之外,且路径中不得包含符号链接或 junction。缺少 POSIX 所有权 API 的平台默认保持故障关闭。',
|
||||
'help.analyze.environment':
|
||||
'\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_STORAGE_PATH=/absolute/index 完整外部索引目录。保留既有配置语义;与 GITNEXUS_STORAGE_ROOT 同时设置时优先使用。\n GITNEXUS_STORAGE_ROOT=/absolute/root 外部索引根目录;每个仓库使用独立的 <仓库名>-<规范路径哈希>/ 子目录。\n GITNEXUS_CONTENT_RETENTION=full 源码文本保留策略:full、symbol 或 none。默认 full。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n GITNEXUS_MAX_PROCESSES=N 流程检测的流程数量上限(正整数)。覆盖动态的 max(20, round(symbols/10)) 公式。与查询时的 IMPACT_MAX_CHUNKS 无关。\n GITNEXUS_MAX_PROCESS_BRANCHING=N 流程检测的单节点分支上限。默认 4。\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N 流程检测的 DFS 深度上限。默认 10。\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N 排序后的入口点候选池。默认 200。仅在警告点名该上限时提高;那时通常先翻倍。\n\nCLI 参数优先于 `.gitnexusrc`,后者优先于环境变量,环境变量优先于内置默认值。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
|
||||
'\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_STORAGE_PATH=/absolute/index 完整外部索引目录。保留既有配置语义;与 GITNEXUS_STORAGE_ROOT 同时设置时优先使用。\n GITNEXUS_STORAGE_ROOT=/absolute/root 外部索引根目录;每个仓库使用独立的 <仓库名>-<规范路径哈希>/ 子目录。\n GITNEXUS_CONTENT_RETENTION=full 源码文本保留策略:full、symbol 或 none。默认 full。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。CLI/MCP query 默认 0.6,独立语义搜索默认 0.5。应根据嵌入模型调节:提高阈值可增加召回,但可能降低相关性。在查询/服务器进程的环境中设置,无需重新索引。\n GITNEXUS_MAX_PROCESSES=N 流程检测的流程数量上限(正整数)。覆盖动态的 max(20, round(symbols/10)) 公式。与查询时的 IMPACT_MAX_CHUNKS 无关。\n GITNEXUS_MAX_PROCESS_BRANCHING=N 流程检测的单节点分支上限。默认 4。\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N 流程检测的 DFS 深度上限。默认 10。\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N 排序后的入口点候选池。默认 200。仅在警告点名该上限时提高;那时通常先翻倍。\n\nCLI 参数优先于 `.gitnexusrc`,后者优先于环境变量,环境变量优先于内置默认值。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
|
||||
} satisfies EnglishMessages;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -114,21 +114,21 @@ export const generateSkillFiles = async (
|
|||
}
|
||||
}
|
||||
|
||||
if (!communityResult || !communityResult.memberships.length) {
|
||||
const memberships = communityResult?.rawMemberships ?? communityResult?.memberships ?? [];
|
||||
if (!communityResult || !memberships.length) {
|
||||
console.log('\n Skills: no communities detected, skipping skill generation');
|
||||
return { skills: [], outputPath: outputDir };
|
||||
}
|
||||
|
||||
console.log('\n Generating repo-specific skills...');
|
||||
|
||||
// Step 1: Build communities from memberships (not the filtered communities array).
|
||||
// The community processor skips singletons from its communities array but memberships
|
||||
// include ALL assignments. For repos with sparse CALLS edges, the communities array
|
||||
// can be empty while memberships still has useful groupings.
|
||||
// Step 1: Use raw assignments for the fallback when all communities were
|
||||
// filtered as singletons. Same-folder aggregation can still produce skills
|
||||
// for these sparse graphs without emitting dangling MEMBER_OF edges.
|
||||
const communities =
|
||||
communityResult.communities.length > 0
|
||||
? communityResult.communities
|
||||
: buildCommunitiesFromMemberships(communityResult.memberships, graph, repoPath);
|
||||
: buildCommunitiesFromMemberships(memberships, graph, repoPath);
|
||||
|
||||
const aggregated = aggregateCommunities(communities);
|
||||
|
||||
|
|
@ -145,11 +145,8 @@ export const generateSkillFiles = async (
|
|||
}
|
||||
|
||||
// Step 3: Build lookup maps
|
||||
const membershipsByComm = buildMembershipMap(communityResult.memberships);
|
||||
const nodeIdToCommunityLabel = buildNodeCommunityLabelMap(
|
||||
communityResult.memberships,
|
||||
communities,
|
||||
);
|
||||
const membershipsByComm = buildMembershipMap(memberships);
|
||||
const nodeIdToCommunityLabel = buildNodeCommunityLabelMap(memberships, communities);
|
||||
|
||||
// Step 4: Ensure the shared project-skill root exists. Never clear it: it
|
||||
// also contains user-authored and standard GitNexus skills.
|
||||
|
|
@ -185,7 +182,7 @@ export const generateSkillFiles = async (
|
|||
const entryPoints = gatherEntryPoints(members);
|
||||
|
||||
// Gather execution flows
|
||||
const flows = gatherFlows(community.rawIds, processResult?.processes || []);
|
||||
const flows = gatherFlows(community.rawIds, members, processResult?.processes || []);
|
||||
|
||||
// Gather cross-community connections
|
||||
const connections = gatherCrossConnections(
|
||||
|
|
@ -529,14 +526,26 @@ const gatherEntryPoints = (members: MemberSymbol[]): MemberSymbol[] => {
|
|||
/**
|
||||
* @brief Gather execution flows touching this community
|
||||
* @param {string[]} rawIds - Raw community IDs for this aggregated community
|
||||
* @param {MemberSymbol[]} members - Member symbols, including raw singleton assignments
|
||||
* @param {ProcessNode[]} processes - All detected processes
|
||||
* @returns {ProcessNode[]} Processes whose communities intersect rawIds, sorted by stepCount
|
||||
* @returns {ProcessNode[]} Processes matching the community IDs or member symbols, sorted by stepCount
|
||||
*/
|
||||
const gatherFlows = (rawIds: string[], processes: ProcessNode[]): ProcessNode[] => {
|
||||
const gatherFlows = (
|
||||
rawIds: string[],
|
||||
members: MemberSymbol[],
|
||||
processes: ProcessNode[],
|
||||
): ProcessNode[] => {
|
||||
const rawIdSet = new Set(rawIds);
|
||||
const memberIds = new Set(members.map((member) => member.id));
|
||||
|
||||
return processes
|
||||
.filter((proc) => proc.communities.some((cid) => rawIdSet.has(cid)))
|
||||
.filter(
|
||||
(proc) =>
|
||||
proc.communities.some((cid) => rawIdSet.has(cid)) ||
|
||||
// Filtered singleton communities are absent from process metadata,
|
||||
// but their symbols still participate in detected execution traces.
|
||||
proc.trace.some((nodeId) => memberIds.has(nodeId)),
|
||||
)
|
||||
.sort((a, b) => b.stepCount - a.stepCount);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
isFullSourceAvailable,
|
||||
} from '../core/content-retention.js';
|
||||
import { t } from './i18n/index.js';
|
||||
import { formatPathForTerminal as formatDriftPath } from './format-path.js';
|
||||
|
||||
/** How many drifted paths the report names before summarizing the rest. */
|
||||
const DRIFT_SAMPLE_LIMIT = 10;
|
||||
|
|
@ -84,9 +85,6 @@ const describeContentDrift = (drift: IndexContentDrift | undefined) => {
|
|||
};
|
||||
};
|
||||
|
||||
/** Escape control characters in repo-relative paths before printing. */
|
||||
const formatDriftPath = (rel: string): string =>
|
||||
/[\u0000-\u001f\u007f]/.test(rel) ? JSON.stringify(rel) : rel;
|
||||
const printDriftDetail = (drift: Extract<IndexContentDrift, { kind: 'drifted' }>): void => {
|
||||
console.log(
|
||||
t('status.indexContentDrifted', {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import ignore, { type Ignore } from 'ignore';
|
||||
import { existsSync } from 'fs';
|
||||
import {
|
||||
closeSync,
|
||||
constants as fsConstants,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
} from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import nodePath from 'path';
|
||||
import type { Path } from 'path-scurry';
|
||||
|
|
@ -531,9 +539,163 @@ const hasExplicitUnignore = (ig: Ignore, rel: string): boolean => {
|
|||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read a nested `.gitignore` only if it is a regular file, not a symlink.
|
||||
*
|
||||
* git does not follow a symlinked `.gitignore` in the working tree, and
|
||||
* reading one could pull rules from outside the repository. Where the
|
||||
* platform supports it, the file is opened with O_NOFOLLOW (a symlink fails
|
||||
* with ELOOP). Windows has no O_NOFOLLOW, so there the path is lstat'ed after
|
||||
* opening and must be the same regular file as the open descriptor. Either
|
||||
* way the content is read through the descriptor that was checked, never by
|
||||
* path, so the file cannot be swapped between the check and the read.
|
||||
*
|
||||
* The open also passes O_NONBLOCK where it exists. Opening a FIFO for reading
|
||||
* blocks in open(2) until a writer appears, so a `.gitignore` that is a FIFO
|
||||
* would hang the scan before the isFile() check could reject it (glob's
|
||||
* ignore callback is synchronous). The flag makes that open return at once
|
||||
* and changes nothing for a regular file. Same reasoning as readBoundedFile
|
||||
* in src/core/ingestion/asyncapi/document.ts.
|
||||
*/
|
||||
const readNestedGitignore = (filePath: string): string | null => {
|
||||
const noFollow = fsConstants.O_NOFOLLOW;
|
||||
const nonBlock = fsConstants.O_NONBLOCK;
|
||||
const fd = openSync(filePath, fsConstants.O_RDONLY | (noFollow ?? 0) | (nonBlock ?? 0));
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (!stat.isFile()) return null;
|
||||
if (noFollow === undefined) {
|
||||
const link = lstatSync(filePath);
|
||||
if (!link.isFile() || link.ino !== stat.ino || link.dev !== stat.dev) return null;
|
||||
}
|
||||
return readFileSync(fd, 'utf-8');
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve `.gitignore` files below the repository root (#2675).
|
||||
*
|
||||
* `loadIgnoreRules` only reads the root `.gitignore`, so a monorepo package
|
||||
* or checked-out submodule with its own `.gitignore` had its generated
|
||||
* output indexed anyway. Each nested file is read lazily (glob's filter is
|
||||
* synchronous) and cached per directory, and its patterns are matched
|
||||
* against the path relative to that directory, like git does.
|
||||
*
|
||||
* Returns the effective decision when nested rules affect the path or an
|
||||
* ancestor, and `undefined` otherwise. Root rules participate so a directory
|
||||
* negation does not erase independent root exclusions for its children.
|
||||
* The caller still gives `.gitnexusignore` its higher precedence.
|
||||
*/
|
||||
const createNestedGitignoreMatcher = (
|
||||
repoPath: string,
|
||||
rootRules: Ignore | null,
|
||||
): ((rel: string, isDirectory: boolean) => boolean | undefined) => {
|
||||
const rulesFor = (dirRel: string): Ignore | null => {
|
||||
let rules: Ignore | null = null;
|
||||
const filePath = nodePath.join(repoPath, dirRel, '.gitignore');
|
||||
try {
|
||||
const content = readNestedGitignore(filePath);
|
||||
if (content !== null) rules = ignore().add(content);
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ELOOP') {
|
||||
logger.warn(` Warning: could not read ${filePath}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
};
|
||||
|
||||
interface Scope {
|
||||
base: string;
|
||||
rules: Ignore;
|
||||
}
|
||||
interface DirectoryContext {
|
||||
scopes: Scope[];
|
||||
ignored: boolean;
|
||||
nested: boolean;
|
||||
}
|
||||
|
||||
const relativeTo = (base: string, rel: string): string =>
|
||||
base ? rel.slice(base.length + 1) : rel;
|
||||
const match = (scopes: Scope[], rel: string, isDirectory: boolean) => {
|
||||
for (let i = scopes.length - 1; i >= 0; i--) {
|
||||
const { base, rules } = scopes[i];
|
||||
const sub = relativeTo(base, rel);
|
||||
const result = rules.test(isDirectory ? `${sub}/` : sub);
|
||||
if (result.ignored || result.unignored) {
|
||||
return { ignored: result.ignored, nested: base !== '' };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const contexts = new Map<string, DirectoryContext>([
|
||||
[
|
||||
'',
|
||||
{
|
||||
scopes: rootRules ? [{ base: '', rules: rootRules }] : [],
|
||||
ignored: false,
|
||||
nested: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const contextFor = (dir: string): DirectoryContext => {
|
||||
const cached = contexts.get(dir);
|
||||
if (cached) return cached;
|
||||
const parentDir = nodePath.posix.dirname(dir);
|
||||
const parent = contextFor(parentDir === '.' ? '' : parentDir);
|
||||
// A .gitignore inside an excluded directory cannot bring that directory
|
||||
// back. Do not read rules below a parent that traversal would prune.
|
||||
if (parent.ignored) {
|
||||
contexts.set(dir, parent);
|
||||
return parent;
|
||||
}
|
||||
|
||||
const result = match(parent.scopes, dir, true);
|
||||
const context: DirectoryContext = {
|
||||
scopes: parent.scopes,
|
||||
ignored: result?.ignored ?? false,
|
||||
nested: parent.nested || (result?.nested ?? false),
|
||||
};
|
||||
if (!context.ignored) {
|
||||
context.scopes = parent.scopes.map(({ base, rules }) => {
|
||||
const sub = relativeTo(base, dir);
|
||||
if (!rules.test(`${sub}/`).ignored) return { base, rules };
|
||||
// A deeper rule let us enter this directory. Clear only its inherited
|
||||
// exclusion in the shallower layer; child rules must still be tested.
|
||||
// Keep patterns in their original scope, and escape this literal path.
|
||||
const literal = sub.replace(/[\\*?\[\]]/g, '\\$&');
|
||||
return {
|
||||
base,
|
||||
rules: ignore()
|
||||
.add(rules)
|
||||
.add({ pattern: `!/${literal}/` }),
|
||||
};
|
||||
});
|
||||
const rules = rulesFor(dir);
|
||||
if (rules) context.scopes.push({ base: dir, rules });
|
||||
}
|
||||
contexts.set(dir, context);
|
||||
return context;
|
||||
};
|
||||
|
||||
return (rel: string, isDirectory: boolean): boolean | undefined => {
|
||||
const parentDir = nodePath.posix.dirname(rel);
|
||||
const parent = contextFor(parentDir === '.' ? '' : parentDir);
|
||||
if (parent.ignored) return parent.nested ? true : undefined;
|
||||
const result = match(parent.scopes, rel, isDirectory);
|
||||
if (parent.nested || result?.nested) return result?.ignored ?? false;
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a glob-compatible ignore filter combining:
|
||||
* - .gitignore / .gitnexusignore patterns (via `ignore` package)
|
||||
* - nested .gitignore files, scoped to their own directory (#2675)
|
||||
* - Hardcoded DEFAULT_IGNORE_LIST, IGNORED_EXTENSIONS, IGNORED_FILES
|
||||
*
|
||||
* Returns an IgnoreLike object for glob's `ignore` option,
|
||||
|
|
@ -550,6 +712,13 @@ const hasExplicitUnignore = (ig: Ignore, rel: string): boolean => {
|
|||
*/
|
||||
export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptions) => {
|
||||
const ig = await loadIgnoreRules(repoPath, options);
|
||||
const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE;
|
||||
const nestedIgnores = skipGitignore ? null : createNestedGitignoreMatcher(repoPath, ig);
|
||||
// A nested negation outranks the root .gitignore, as in git, but not the
|
||||
// user's .gitnexusignore, so keep a matcher for that file on its own.
|
||||
const nexusIgnore = nestedIgnores
|
||||
? await loadIgnoreRules(repoPath, { ...options, noGitignore: true, noGlobalIgnore: true })
|
||||
: null;
|
||||
|
||||
return {
|
||||
ignored(p: Path): boolean {
|
||||
|
|
@ -557,6 +726,23 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio
|
|||
// native separators on Windows when called through glob.
|
||||
const rel = p.relative().replace(/\\/g, '/');
|
||||
if (!rel) return false;
|
||||
// Nested .gitignore files below the root (#2675). .gitnexusignore
|
||||
// comes first, then the deepest nested .gitignore, which outranks the
|
||||
// root .gitignore as in git. The nested matcher preserves independent
|
||||
// root exclusions; a nested negation never rescues a hardcoded default.
|
||||
// With no nested opinion the original order below applies unchanged.
|
||||
if (nestedIgnores) {
|
||||
if (nexusIgnore) {
|
||||
if (hasExplicitUnignore(nexusIgnore, rel) && !ig?.ignores(rel)) return false;
|
||||
if (nexusIgnore.ignores(rel)) return true;
|
||||
}
|
||||
const nested = nestedIgnores(rel, false);
|
||||
if (nested === true) return true;
|
||||
if (nested === false) {
|
||||
if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(rel)) return false;
|
||||
return shouldIgnorePath(rel);
|
||||
}
|
||||
}
|
||||
// User's .gitnexusignore negation takes precedence over hardcoded
|
||||
// rules (#771). If any ancestor or the path itself was explicitly
|
||||
// unignored AND no more-specific rule re-ignores this exact path,
|
||||
|
|
@ -576,6 +762,22 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio
|
|||
// list check below is defense-in-depth — do not remove `dot: false`
|
||||
// assuming this covers it.
|
||||
const rel = p.relative().replace(/\\/g, '/');
|
||||
// Nested .gitignore files below the root (#2675), same precedence as in
|
||||
// `ignored` above.
|
||||
if (nestedIgnores && rel) {
|
||||
if (nexusIgnore) {
|
||||
if (hasExplicitUnignore(nexusIgnore, rel) && !ig?.ignores(rel + '/')) {
|
||||
return false;
|
||||
}
|
||||
if (nexusIgnore.ignores(rel + '/')) return true;
|
||||
}
|
||||
const nested = nestedIgnores(rel, true);
|
||||
if (nested === true) return true;
|
||||
if (nested === false) {
|
||||
if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/')) return false;
|
||||
return isHardcodedIgnoredDirectoryAtPath(repoPath, nodePath.join(repoPath, rel));
|
||||
}
|
||||
}
|
||||
// User's .gitnexusignore negation takes precedence (#771) — if the
|
||||
// user explicitly unignored this directory or any ancestor via a
|
||||
// !pattern rule, allow descent even if the directory name is in
|
||||
|
|
|
|||
|
|
@ -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()}` : ''}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@ export type { StalenessInfo, StalenessStatus } from './staleness-status.js';
|
|||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Ceiling for one `git rev-list` staleness probe. Generous for the local
|
||||
* history walk this is, and short enough that an unresponsive working tree
|
||||
* Per-command ceiling for async `git rev-list` and both HEAD probes.
|
||||
* Generous for local Git queries, and short enough that an unresponsive working tree
|
||||
* degrades to "not stale" quickly rather than holding a request open.
|
||||
*/
|
||||
const STALENESS_TIMEOUT_MS = 5_000;
|
||||
|
|
@ -33,12 +33,23 @@ const behindHint = (n: number): string =>
|
|||
const DIVERGED_HINT =
|
||||
"⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update.";
|
||||
|
||||
// `rev-list --count lastCommit..HEAD` answering 0 does NOT mean "HEAD is the
|
||||
// indexed commit" — it means "HEAD has no commits lastCommit lacks", which is
|
||||
// also true when HEAD is an *ancestor* of lastCommit (the working tree checked
|
||||
// out an older commit than the one indexed, or switched to a line of history
|
||||
// behind it). That read a rollback as `current` until this hint existed.
|
||||
const REGRESSED_HINT =
|
||||
'⚠️ Index is not at HEAD — the indexed commit is not reachable from the checked-out commit (the working tree may have checked out an older commit, or a different line of history). Run analyze tool to update.';
|
||||
|
||||
const unknown = (): StalenessInfo => ({ isStale: false, commitsBehind: 0, status: 'unknown' });
|
||||
|
||||
const fromCount = (commitsBehind: number): StalenessInfo =>
|
||||
commitsBehind > 0
|
||||
? { isStale: true, commitsBehind, hint: behindHint(commitsBehind), status: 'behind' }
|
||||
: { isStale: false, commitsBehind: 0, status: 'current' };
|
||||
// Called only once a positive HEAD-only count is in hand.
|
||||
const behind = (commitsBehind: number): StalenessInfo => ({
|
||||
isStale: true,
|
||||
commitsBehind,
|
||||
hint: behindHint(commitsBehind),
|
||||
status: 'behind',
|
||||
});
|
||||
|
||||
/**
|
||||
* `rev-list` could not answer. Asking for HEAD alone needs no history walk and
|
||||
|
|
@ -53,6 +64,26 @@ const fromHead = (head: string | null, lastCommit: string): StalenessInfo => {
|
|||
return { isStale: false, commitsBehind: 0, hint: DIVERGED_HINT, status: 'diverged' };
|
||||
};
|
||||
|
||||
/**
|
||||
* `rev-list --left-right --count lastCommit...HEAD` measures both sides in
|
||||
* one process, so a later HEAD change cannot mix two snapshots. The left
|
||||
* count identifies a rollback even when the HEAD-only (right) count is 0.
|
||||
* Positive right counts keep the existing `behind` behavior, including when
|
||||
* both sides have commits (divergent branches or a re-shallowed clone).
|
||||
*/
|
||||
const fromCounts = (output: string): StalenessInfo => {
|
||||
const counts = /^(\d+)\s+(\d+)$/.exec(output.trim());
|
||||
if (!counts) return unknown();
|
||||
const indexedOnly = Number(counts[1]);
|
||||
const headOnly = Number(counts[2]);
|
||||
if (!Number.isSafeInteger(indexedOnly) || !Number.isSafeInteger(headOnly)) return unknown();
|
||||
if (headOnly > 0) return behind(headOnly);
|
||||
if (indexedOnly > 0) {
|
||||
return { isStale: true, commitsBehind: 0, hint: REGRESSED_HINT, status: 'diverged' };
|
||||
}
|
||||
return { isStale: false, commitsBehind: 0, status: 'current' };
|
||||
};
|
||||
|
||||
const readHeadSync = (repoPath: string): string | null => {
|
||||
try {
|
||||
return (
|
||||
|
|
@ -61,6 +92,7 @@ const readHeadSync = (repoPath: string): string | null => {
|
|||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
timeout: STALENESS_TIMEOUT_MS,
|
||||
}).trim() || null
|
||||
);
|
||||
} catch {
|
||||
|
|
@ -89,14 +121,18 @@ export function checkStaleness(repoPath: string, lastCommit: string): StalenessI
|
|||
// No recorded commit is not "at HEAD": there is nothing to measure against.
|
||||
if (!lastCommit) return unknown();
|
||||
try {
|
||||
const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
const result = execFileSync(
|
||||
'git',
|
||||
['rev-list', '--left-right', '--count', `${lastCommit}...HEAD`],
|
||||
{
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
return fromCount(parseInt(result, 10) || 0);
|
||||
return fromCounts(result);
|
||||
} catch {
|
||||
return fromHead(readHeadSync(repoPath), lastCommit);
|
||||
}
|
||||
|
|
@ -115,21 +151,25 @@ export async function checkStalenessAsync(
|
|||
try {
|
||||
// Note: promisified execFile captures stdout/stderr by default (no stdio option needed,
|
||||
// unlike the sync variant which requires explicit stdio: ['pipe','pipe','pipe']).
|
||||
const { stdout } = await execFileAsync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
// The catch below fails closed on every git ERROR, but a hang is not an
|
||||
// error — it is silence, and without a bound this await never settles.
|
||||
// A working tree on a disconnected network mount or behind a stuck lock
|
||||
// does exactly that, and `/api/repos` fans this out once per registered
|
||||
// repo, so one unreachable mount could hold the whole listing open
|
||||
// (#3232 review). The timeout kills the child and rejects, and the catch
|
||||
// below reports it as `unknown` — still the fail-closed `isStale: false`.
|
||||
timeout: STALENESS_TIMEOUT_MS,
|
||||
});
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['rev-list', '--left-right', '--count', `${lastCommit}...HEAD`],
|
||||
{
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
// The catch below fails closed on every git ERROR, but a hang is not an
|
||||
// error — it is silence, and without a bound this await never settles.
|
||||
// A working tree on a disconnected network mount or behind a stuck lock
|
||||
// does exactly that, and `/api/repos` fans this out once per registered
|
||||
// repo, so one unreachable mount could hold the whole listing open
|
||||
// (#3232 review). The timeout kills the child and rejects, and the catch
|
||||
// below reports it as `unknown` — still the fail-closed `isStale: false`.
|
||||
timeout: STALENESS_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
|
||||
return fromCount(parseInt(stdout.trim(), 10) || 0);
|
||||
return fromCounts(stdout);
|
||||
} catch (err) {
|
||||
// A rev-list that timed out means the working tree is not answering. Asking
|
||||
// it again for HEAD would only double the bound #3232 put on a hung mount.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
107
gitnexus/src/core/incremental/write-reconciliation.ts
Normal file
107
gitnexus/src/core/incremental/write-reconciliation.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { NODE_TABLES, type NodeTableName } from '../lbug/schema.js';
|
||||
import { sanitizeUTF8 } from '../lbug/csv-generator.js';
|
||||
|
||||
// These layers need a different oracle: folders can outlive their last file,
|
||||
// derived nodes may be preserved without recomputation, and PDG can be streamed.
|
||||
const EXCLUDED = new Set<NodeTableName>(['Folder', 'Community', 'Process', 'BasicBlock']);
|
||||
const WITHOUT_RANGE = new Set<NodeTableName>(['File', 'Route', 'Tool']);
|
||||
|
||||
interface IdentityRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
filePath: string | null;
|
||||
startLine?: number | null;
|
||||
endLine?: number | null;
|
||||
}
|
||||
|
||||
const storedString = (value: string | undefined): string => sanitizeUTF8(value || '');
|
||||
|
||||
const storedIdentityField = (node: GraphNode, field: string): unknown => {
|
||||
const value = node.properties[field];
|
||||
if (field === 'name' || field === 'filePath') {
|
||||
return storedString(value as string | undefined);
|
||||
}
|
||||
if (node.label === 'Destination') {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
return value ?? -1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Certify the identity fields used by context/impact/detect_changes, including
|
||||
* retained rows. A write-set-only probe misses corruption of an unchanged row.
|
||||
* Scan one label at a time, without path/range predicates: a corrupt field must
|
||||
* not be able to hide its own row. No multi-label scans (#3139), N+1 ID lookups,
|
||||
* source-content copies, or whole-graph row materialization.
|
||||
*
|
||||
* This certifies node identity, not relationships or native storage internals.
|
||||
* Call after COPY/checkpoint and after FTS/embedding/checkpoint, before metadata.
|
||||
*/
|
||||
export async function reconcileGraphNodeIdentities(
|
||||
graph: KnowledgeGraph,
|
||||
query: (cypher: string) => Promise<IdentityRow[]>,
|
||||
phase: string,
|
||||
): Promise<{ nodes: number; tables: number }> {
|
||||
const expected = new Map<NodeTableName, Map<string, GraphNode>>();
|
||||
for (const table of NODE_TABLES) {
|
||||
if (!EXCLUDED.has(table)) expected.set(table, new Map());
|
||||
}
|
||||
for (const node of graph.iterNodes()) {
|
||||
const table = expected.get(node.label as NodeTableName);
|
||||
if (!table) continue;
|
||||
const id = storedString(node.id);
|
||||
if (!id || table.has(id)) {
|
||||
throw new Error(`Graph identity reconciliation (${phase}): invalid or duplicate ID ${id}`);
|
||||
}
|
||||
table.set(id, node);
|
||||
}
|
||||
|
||||
let nodes = 0;
|
||||
for (const [table, remaining] of expected) {
|
||||
const fields = WITHOUT_RANGE.has(table)
|
||||
? ['id', 'name', 'filePath']
|
||||
: ['id', 'name', 'filePath', 'startLine', 'endLine'];
|
||||
const identityFields = fields.slice(1);
|
||||
const fail: (detail: string, cause?: unknown) => never = (detail, cause) => {
|
||||
throw new Error(
|
||||
`Graph identity reconciliation failed (${phase}, ${table}): ${detail}. ` +
|
||||
'Freshness was not advanced; run `gitnexus analyze --force` to rebuild the graph.',
|
||||
{ cause },
|
||||
);
|
||||
};
|
||||
let rows: IdentityRow[];
|
||||
try {
|
||||
rows = await query(
|
||||
`MATCH (n:\`${table}\`) RETURN ${fields.map((f) => `n.${f} AS ${f}`).join(', ')}`,
|
||||
);
|
||||
} catch (error) {
|
||||
fail(
|
||||
`could not read identity fields: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
for (const row of rows) {
|
||||
const node = remaining.get(row.id);
|
||||
if (!node) fail(`unexpected or duplicate ID ${JSON.stringify(row.id)}`);
|
||||
for (const field of identityFields) {
|
||||
const wanted = storedIdentityField(node, field);
|
||||
const actual = field === 'name' || field === 'filePath' ? (row[field] ?? '') : row[field];
|
||||
if (actual !== wanted) {
|
||||
fail(
|
||||
`${row.id}.${field}: expected ${JSON.stringify(wanted)}, read ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
remaining.delete(row.id);
|
||||
nodes++;
|
||||
}
|
||||
if (remaining.size) {
|
||||
fail(
|
||||
`${remaining.size} missing ID(s), including ${JSON.stringify(remaining.keys().next().value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { nodes, tables: expected.size };
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ import {
|
|||
import { expandCopies } from './cobol/cobol-copy-expander.js';
|
||||
import { processJclFiles } from './cobol/jcl-processor.js';
|
||||
import { resolveCobolCopyTarget } from './languages/cobol/copy-target.js';
|
||||
import { COBOL_EXTENSIONS, JCL_EXTENSIONS } from './cobol/file-types.js';
|
||||
export { isCobolFile, isJclFile } from './cobol/file-types.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
|
|
@ -33,10 +35,6 @@ import { logger } from '../logger.js';
|
|||
// File detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COBOL_EXTENSIONS = new Set(['.cob', '.cbl', '.cobol', '.cpy', '.copybook']);
|
||||
|
||||
const JCL_EXTENSIONS = new Set(['.jcl', '.job', '.proc']);
|
||||
|
||||
const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']);
|
||||
|
||||
interface CobolFile {
|
||||
|
|
@ -67,16 +65,6 @@ export interface CobolProcessResult {
|
|||
arithmeticOps: number;
|
||||
}
|
||||
|
||||
/** Returns true if the file is a COBOL or copybook file. */
|
||||
export function isCobolFile(filePath: string): boolean {
|
||||
return COBOL_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
/** Returns true if the file is a JCL file. */
|
||||
export function isJclFile(filePath: string): boolean {
|
||||
return JCL_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
/** Returns true if the file is a COBOL copybook. */
|
||||
function isCopybook(filePath: string): boolean {
|
||||
return COPYBOOK_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
||||
|
|
|
|||
15
gitnexus/src/core/ingestion/cobol/file-types.ts
Normal file
15
gitnexus/src/core/ingestion/cobol/file-types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import path from 'node:path';
|
||||
|
||||
// Keep detection shared without loading the analyze-only language providers.
|
||||
export const COBOL_EXTENSIONS = new Set(['.cob', '.cbl', '.cobol', '.cpy', '.copybook']);
|
||||
export const JCL_EXTENSIONS = new Set(['.jcl', '.job', '.proc']);
|
||||
|
||||
/** Includes COBOL programs and copybooks accepted by ingestion. */
|
||||
export function isCobolFile(filePath: string): boolean {
|
||||
return COBOL_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
/** Includes job and procedure aliases accepted by JCL ingestion. */
|
||||
export function isJclFile(filePath: string): boolean {
|
||||
return JCL_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
|
@ -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';
|
||||
/**
|
||||
|
|
@ -176,7 +164,10 @@ export interface CommunityMembership {
|
|||
|
||||
export interface CommunityDetectionResult {
|
||||
communities: CommunityNode[];
|
||||
/** Assignments to retained communities, safe to emit as MEMBER_OF edges. */
|
||||
memberships: CommunityMembership[];
|
||||
/** All Leiden assignments, including filtered singletons. Optional for legacy producers. */
|
||||
rawMemberships?: CommunityMembership[];
|
||||
stats: {
|
||||
totalCommunities: number;
|
||||
modularity: number;
|
||||
|
|
@ -235,6 +226,7 @@ export const processCommunities = async (
|
|||
return {
|
||||
communities: [],
|
||||
memberships: [],
|
||||
rawMemberships: [],
|
||||
stats: {
|
||||
totalCommunities: 0,
|
||||
modularity: 0,
|
||||
|
|
@ -267,13 +259,16 @@ export const processCommunities = async (
|
|||
|
||||
onProgress?.('Creating membership edges...', 80);
|
||||
|
||||
// Step 4: Create membership mappings
|
||||
// Step 4: Preserve all assignments for skill generation, but only emit
|
||||
// memberships for retained communities with a corresponding graph node.
|
||||
const retainedCommunityIds = new Set(communityNodes.map((community) => community.id));
|
||||
const memberships: CommunityMembership[] = [];
|
||||
const rawMemberships: CommunityMembership[] = [];
|
||||
Object.entries(details.communities).forEach(([nodeId, communityNum]) => {
|
||||
memberships.push({
|
||||
nodeId,
|
||||
communityId: `comm_${communityNum}`,
|
||||
});
|
||||
const communityId = `comm_${communityNum}`;
|
||||
const membership = { nodeId, communityId };
|
||||
rawMemberships.push(membership);
|
||||
if (retainedCommunityIds.has(communityId)) memberships.push(membership);
|
||||
});
|
||||
|
||||
onProgress?.('Community detection complete!', 100);
|
||||
|
|
@ -281,6 +276,7 @@ export const processCommunities = async (
|
|||
return {
|
||||
communities: communityNodes,
|
||||
memberships,
|
||||
rawMemberships,
|
||||
stats: {
|
||||
totalCommunities: details.count,
|
||||
modularity: details.modularity,
|
||||
|
|
@ -466,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') {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ import type {
|
|||
RepoConstants,
|
||||
} from './route-extractors/constant-resolver.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
import type { ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js';
|
||||
import type { SemanticModel } from './model/semantic-model.js';
|
||||
|
||||
/** What a provider's {@link LanguageProviderConfig.resolveRouteHandler} can see. */
|
||||
|
|
@ -568,6 +568,15 @@ interface LanguageProviderConfig {
|
|||
*/
|
||||
readonly extractTextRoutes?: (filePath: string, content: string) => ExtractedRoute[];
|
||||
|
||||
/** Extract tool registrations after captures, using only emitted callable identities.
|
||||
* The map keys are declaration-name AST node IDs, local to this parsed tree. */
|
||||
readonly extractToolDefinitions?: (
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
lineOffset: number,
|
||||
callableBindings: ReadonlyMap<number, string>,
|
||||
) => ExtractedToolDef[];
|
||||
|
||||
/**
|
||||
* Extract routes that a parsed file declares in its own AST.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import type { ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { findClassBindingInScope } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
export function swiftIsCallableVisibleFromCaller(ctx: {
|
||||
readonly candidate: SymbolDefinition;
|
||||
readonly callerParsed?: ParsedFile;
|
||||
readonly callArity?: number;
|
||||
readonly callerScope?: ScopeId;
|
||||
readonly scopes?: ScopeResolutionIndexes;
|
||||
}): boolean {
|
||||
const indexes = ctx.scopes;
|
||||
if (ctx.callerScope === undefined || indexes === undefined || ctx.callArity !== 0) return true;
|
||||
|
||||
const name = ctx.candidate.qualifiedName?.split('.').at(-1);
|
||||
if (name === undefined) return true;
|
||||
|
||||
let scopeId: ScopeId | null = ctx.callerScope;
|
||||
let selfType: TypeRef | undefined;
|
||||
while (scopeId !== null) {
|
||||
const scope = indexes.scopeTree.getScope(scopeId);
|
||||
if (scope === undefined) break;
|
||||
// A local function selected inside the caller wins before member lookup.
|
||||
if (
|
||||
scope.kind !== 'Class' &&
|
||||
scope.ownedDefs.some((def) => def.nodeId === ctx.candidate.nodeId)
|
||||
)
|
||||
return true;
|
||||
selfType ??= scope.typeBindings.get('self');
|
||||
if (scope.kind === 'Class') {
|
||||
const classDef =
|
||||
scope.ownedDefs.find((def) => def.type === 'Class') ??
|
||||
(selfType === undefined
|
||||
? undefined
|
||||
: findClassBindingInScope(ctx.callerScope, selfType.rawName, indexes, undefined, {
|
||||
uniqueQualifiedNameFallback: false,
|
||||
}));
|
||||
const propertyOnOwner = (ownerId: string, ownerName: string): boolean =>
|
||||
indexes.qualifiedNames.get(`${ownerName}.${name}`).some((defId) => {
|
||||
const def = indexes.defs.get(defId);
|
||||
return def?.type === 'Property' && def.ownerId === ownerId;
|
||||
});
|
||||
const ownProperty =
|
||||
scope.ownedDefs.some(
|
||||
(def) => def.type === 'Property' && def.qualifiedName?.split('.').at(-1) === name,
|
||||
) ||
|
||||
(classDef !== undefined &&
|
||||
classDef.qualifiedName !== undefined &&
|
||||
propertyOnOwner(classDef.nodeId, classDef.qualifiedName));
|
||||
const inheritedProperty =
|
||||
classDef !== undefined &&
|
||||
indexes.methodDispatch.mroFor(classDef.nodeId).some((ownerId) => {
|
||||
const ownerName = indexes.defs.get(ownerId)?.qualifiedName;
|
||||
return ownerName !== undefined && propertyOnOwner(ownerId, ownerName);
|
||||
});
|
||||
if (!ownProperty && !inheritedProperty) return true;
|
||||
|
||||
// Swift's nested function is owned by its own Function scope. A
|
||||
// same-file sibling must not count as a nearer lexical binding.
|
||||
const declarationScope = ctx.callerParsed?.scopes.find((candidateScope) =>
|
||||
candidateScope.ownedDefs.some((def) => def.nodeId === ctx.candidate.nodeId),
|
||||
);
|
||||
if (
|
||||
declarationScope !== undefined &&
|
||||
declarationScope.kind === 'Function' &&
|
||||
(declarationScope.id === ctx.callerScope ||
|
||||
indexes.scopeTree.getAncestors(declarationScope.id).includes(ctx.callerScope))
|
||||
)
|
||||
return true;
|
||||
|
||||
// Scope defs do not carry Swift access modifiers. A method positively
|
||||
// selected on this type must not be vetoed by an uncertain ancestor.
|
||||
return classDef !== undefined && ctx.candidate.ownerId === classDef.nodeId;
|
||||
}
|
||||
scopeId = scope.parent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ import {
|
|||
import { stripSwiftTypePreservingDecoration } from './interpret.js';
|
||||
import { groupSwiftFilesByModule } from './target-grouping.js';
|
||||
import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
import { swiftIsCallableVisibleFromCaller } from './callable-visibility.js';
|
||||
|
||||
const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const;
|
||||
|
||||
|
|
@ -155,6 +156,7 @@ const swiftScopeResolver: ScopeResolver = {
|
|||
// no-`new` constructor + cross-file free-call shape).
|
||||
allowGlobalFreeCallFallback: true,
|
||||
isGlobalNameFallbackPlausible: swiftIsGlobalNameFallbackPlausible,
|
||||
isCallableVisibleFromCaller: swiftIsCallableVisibleFromCaller,
|
||||
|
||||
// Swift's call graph models `Type(...)` as a reference to the type
|
||||
// itself, not its `init` — both the legacy DAG and this test suite link
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ import { extractDataRouteTableRoutes } from '../route-extractors/data-route-tabl
|
|||
import { extractNestRoutes } from '../route-extractors/nest.js';
|
||||
import { extractTrpcRoutes, shouldScanForTrpcRoutes } from '../route-extractors/trpc.js';
|
||||
import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js';
|
||||
import { extractToolDefinitions } from './typescript/tool-definitions.js';
|
||||
|
||||
const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes>) => [
|
||||
...extractDispatchGuardRoutes(...args),
|
||||
|
|
@ -494,6 +495,7 @@ export const typescriptProvider = defineLanguage({
|
|||
// Content-based (not AST): tRPC procedure routers are scanned from source text.
|
||||
// Path-gate lives here (language provider), not in the shared parse worker.
|
||||
extractTextRoutes: extractJsTsTextRoutes,
|
||||
extractToolDefinitions,
|
||||
});
|
||||
|
||||
export const javascriptProvider = defineLanguage({
|
||||
|
|
@ -577,4 +579,5 @@ export const javascriptProvider = defineLanguage({
|
|||
extractDecoratorRoutes: extractJsTsRoutes,
|
||||
// Content-based (not AST): tRPC procedure routers are scanned from source text.
|
||||
extractTextRoutes: extractJsTsTextRoutes,
|
||||
extractToolDefinitions,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,365 @@
|
|||
import type Parser from 'tree-sitter';
|
||||
import type { SyntaxNode } from 'tree-sitter';
|
||||
import type { ExtractedToolDef } from '../../workers/parse-worker.js';
|
||||
import { plainString, propertyName } from '../../route-extractors/data-route-table.js';
|
||||
|
||||
interface Scope {
|
||||
parent?: Scope;
|
||||
functionScope: boolean;
|
||||
bindings: Map<string, Binding>;
|
||||
}
|
||||
|
||||
interface Binding {
|
||||
name: SyntaxNode;
|
||||
scope: Scope;
|
||||
kind: 'unknown' | 'sdk' | 'sdk-namespace' | 'variable' | 'parameter' | 'function';
|
||||
value?: SyntaxNode;
|
||||
type?: SyntaxNode;
|
||||
typeOnly?: boolean;
|
||||
immutable?: boolean;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
const FUNCTIONS = new Set([
|
||||
'function_declaration',
|
||||
'generator_function_declaration',
|
||||
'function_expression',
|
||||
'generator_function',
|
||||
'arrow_function',
|
||||
'method_definition',
|
||||
]);
|
||||
const BLOCKS = new Set([
|
||||
'statement_block',
|
||||
'for_statement',
|
||||
'for_in_statement',
|
||||
'switch_body',
|
||||
'catch_clause',
|
||||
'class_body',
|
||||
]);
|
||||
const SDK_MODULES = new Set([
|
||||
'@modelcontextprotocol/sdk/server/mcp.js',
|
||||
'@modelcontextprotocol/sdk/server/mcp',
|
||||
]);
|
||||
|
||||
function lookup(scope: Scope, name: string): Binding | undefined {
|
||||
for (let current: Scope | undefined = scope; current; current = current.parent) {
|
||||
const binding = current.bindings.get(name);
|
||||
if (binding) return binding;
|
||||
}
|
||||
}
|
||||
|
||||
/** Only binding/assignment patterns: never descend into keys, types or defaults.
|
||||
* Member assignment targets are reported separately from binding names. */
|
||||
function patternNames(pattern: SyntaxNode, onMember?: (member: SyntaxNode) => void): SyntaxNode[] {
|
||||
const names: SyntaxNode[] = [];
|
||||
const pending = [pattern];
|
||||
while (pending.length) {
|
||||
const node = pending.pop()!;
|
||||
if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') {
|
||||
names.push(node);
|
||||
} else if (node.type === 'member_expression' || node.type === 'subscript_expression') {
|
||||
onMember?.(node);
|
||||
} else if (node.type === 'pair_pattern') {
|
||||
const value = node.childForFieldName('value');
|
||||
if (value) pending.push(value);
|
||||
} else if (node.type === 'assignment_pattern' || node.type === 'object_assignment_pattern') {
|
||||
const left = node.childForFieldName('left');
|
||||
if (left) pending.push(left);
|
||||
} else if (
|
||||
node.type === 'array_pattern' ||
|
||||
node.type === 'object_pattern' ||
|
||||
node.type === 'rest_pattern'
|
||||
) {
|
||||
pending.push(...node.namedChildren);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function declare(scope: Scope, name: SyntaxNode, details: Partial<Binding> = {}): void {
|
||||
const previous = scope.bindings.get(name.text);
|
||||
if (previous) {
|
||||
previous.invalid = true;
|
||||
} else {
|
||||
scope.bindings.set(name.text, { name, scope, kind: 'unknown', ...details });
|
||||
}
|
||||
}
|
||||
|
||||
function variableScope(scope: Scope): Scope {
|
||||
while (!scope.functionScope && scope.parent) scope = scope.parent;
|
||||
return scope;
|
||||
}
|
||||
|
||||
function collectBindings(root: SyntaxNode) {
|
||||
const moduleScope: Scope = { functionScope: true, bindings: new Map() };
|
||||
const scopes = new Map<number, Scope>();
|
||||
const calls: SyntaxNode[] = [];
|
||||
const writes: SyntaxNode[] = [];
|
||||
const pending = [{ node: root, scope: moduleScope }];
|
||||
while (pending.length) {
|
||||
const entry = pending.pop()!;
|
||||
const node = entry.node;
|
||||
let scope = entry.scope;
|
||||
const isFunction = FUNCTIONS.has(node.type);
|
||||
const name = node.childForFieldName('name');
|
||||
if (node.type === 'function_declaration' || node.type === 'generator_function_declaration') {
|
||||
if (name) declare(scope, name, { kind: 'function', value: node, immutable: true });
|
||||
} else if (
|
||||
[
|
||||
'class_declaration',
|
||||
'interface_declaration',
|
||||
'type_alias_declaration',
|
||||
'enum_declaration',
|
||||
].includes(node.type)
|
||||
) {
|
||||
if (name) declare(scope, name);
|
||||
}
|
||||
if (
|
||||
isFunction ||
|
||||
BLOCKS.has(node.type) ||
|
||||
node.type === 'class' ||
|
||||
node.type === 'class_declaration'
|
||||
) {
|
||||
scope = { parent: scope, functionScope: isFunction, bindings: new Map() };
|
||||
}
|
||||
scopes.set(node.id, scope);
|
||||
|
||||
if (node.type === 'class' && name) declare(scope, name);
|
||||
|
||||
if (isFunction) {
|
||||
if (name && (node.type === 'function_expression' || node.type === 'generator_function')) {
|
||||
declare(scope, name, { kind: 'function', value: node, immutable: true });
|
||||
}
|
||||
const parameters = node.childForFieldName('parameters');
|
||||
const single = node.childForFieldName('parameter');
|
||||
for (const parameter of parameters?.namedChildren ?? (single ? [single] : [])) {
|
||||
const pattern = parameter.childForFieldName('pattern') ?? parameter;
|
||||
const type = parameter.childForFieldName('type')?.namedChildren[0];
|
||||
for (const bindingName of patternNames(pattern)) {
|
||||
declare(scope, bindingName, {
|
||||
kind: 'parameter',
|
||||
...(pattern.type === 'identifier' && type ? { type } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'import_statement') {
|
||||
const source = node.childForFieldName('source');
|
||||
const sdk = source !== null && SDK_MODULES.has(plainString(source) ?? '');
|
||||
const typeOnly = node.children.some((child) => child.type === 'type');
|
||||
const clause = node.namedChildren.find((child) => child.type === 'import_clause');
|
||||
for (const child of clause?.namedChildren ?? []) {
|
||||
if (child.type === 'identifier') declare(scope, child);
|
||||
else if (child.type === 'namespace_import') {
|
||||
const local = child.namedChildren[0];
|
||||
if (local) declare(scope, local, { kind: sdk ? 'sdk-namespace' : 'unknown', typeOnly });
|
||||
} else if (child.type === 'named_imports') {
|
||||
for (const specifier of child.namedChildren) {
|
||||
const imported = specifier.childForFieldName('name');
|
||||
const local = specifier.childForFieldName('alias') ?? imported;
|
||||
if (local)
|
||||
declare(scope, local, {
|
||||
kind: sdk && imported?.text === 'McpServer' ? 'sdk' : 'unknown',
|
||||
typeOnly: typeOnly || specifier.children.some((part) => part.type === 'type'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'variable_declarator') {
|
||||
if (name) {
|
||||
const target = node.parent?.type === 'variable_declaration' ? variableScope(scope) : scope;
|
||||
for (const bindingName of patternNames(name)) {
|
||||
declare(target, bindingName, {
|
||||
kind: 'variable',
|
||||
immutable: node.parent?.childForFieldName('kind')?.type === 'const',
|
||||
...(name.type === 'identifier'
|
||||
? { value: node.childForFieldName('value') ?? undefined }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'catch_clause') {
|
||||
const parameter = node.childForFieldName('parameter');
|
||||
if (parameter) for (const bindingName of patternNames(parameter)) declare(scope, bindingName);
|
||||
} else if (node.type === 'for_in_statement') {
|
||||
const left = node.childForFieldName('left');
|
||||
const kind = node.childForFieldName('kind');
|
||||
if (left && kind) {
|
||||
const target = kind.type === 'var' ? variableScope(scope) : scope;
|
||||
for (const bindingName of patternNames(left)) declare(target, bindingName);
|
||||
} else if (left) writes.push(left);
|
||||
} else if (node.type === 'type_parameter') {
|
||||
if (name) declare(scope, name);
|
||||
}
|
||||
if (node.type === 'call_expression') calls.push(node);
|
||||
if (node.type === 'assignment_expression' || node.type === 'augmented_assignment_expression') {
|
||||
const left = node.childForFieldName('left');
|
||||
if (left) writes.push(left);
|
||||
} else if (
|
||||
node.type === 'update_expression' ||
|
||||
(node.type === 'unary_expression' && node.children.some((child) => child.type === 'delete'))
|
||||
) {
|
||||
const argument = node.childForFieldName('argument');
|
||||
if (argument) writes.push(argument);
|
||||
}
|
||||
for (let index = node.namedChildCount - 1; index >= 0; index--) {
|
||||
pending.push({ node: node.namedChild(index)!, scope });
|
||||
}
|
||||
}
|
||||
// Resolve writes after declarations so later declarations also shadow outer names.
|
||||
while (writes.length) {
|
||||
let target = writes.pop()!;
|
||||
const scope = scopes.get(target.id)!;
|
||||
const members: Array<string | null> = [];
|
||||
while (target.type === 'member_expression' || target.type === 'subscript_expression') {
|
||||
const object = target.childForFieldName('object');
|
||||
if (!object) break;
|
||||
const property = target.childForFieldName('property');
|
||||
const index = target.childForFieldName('index');
|
||||
members.push(property ? propertyName(property) : index ? plainString(index) : null);
|
||||
target = object;
|
||||
}
|
||||
// Nested member targets must pass the same guard as direct property writes.
|
||||
for (const name of patternNames(target, (member) => writes.push(member))) {
|
||||
const binding = lookup(scope, name.text);
|
||||
// Lifecycle callbacks and other known properties do not replace the receiver
|
||||
// or its registration methods. Unknown keys and constructor mutations remain unsafe.
|
||||
if (
|
||||
binding?.kind !== 'sdk' &&
|
||||
binding?.kind !== 'sdk-namespace' &&
|
||||
members.length > 0 &&
|
||||
members.every((member) => member !== null) &&
|
||||
!['tool', 'registerTool', '__proto__'].includes(members[members.length - 1]!)
|
||||
)
|
||||
continue;
|
||||
if (binding) binding.invalid = true;
|
||||
}
|
||||
}
|
||||
return { scopes, calls };
|
||||
}
|
||||
|
||||
function sdkBinding(node: SyntaxNode, scope: Scope, forType = false): boolean {
|
||||
let kind: Binding['kind'] = 'sdk';
|
||||
if (node.type === (forType ? 'nested_type_identifier' : 'member_expression')) {
|
||||
const namespace = node.childForFieldName(forType ? 'module' : 'object');
|
||||
const member = node.childForFieldName(forType ? 'name' : 'property');
|
||||
if (namespace?.type !== 'identifier' || member?.text !== 'McpServer') return false;
|
||||
node = namespace;
|
||||
kind = 'sdk-namespace';
|
||||
}
|
||||
if (node.type !== 'identifier' && node.type !== 'type_identifier') return false;
|
||||
const binding = lookup(scope, node.text);
|
||||
return binding?.kind === kind && !binding.invalid && (forType || !binding.typeOnly);
|
||||
}
|
||||
|
||||
function sdkReceiver(node: SyntaxNode, scope: Scope, scopes: ReadonlyMap<number, Scope>): boolean {
|
||||
if (node.type !== 'identifier') return false;
|
||||
const binding = lookup(scope, node.text);
|
||||
if (!binding || binding.invalid) return false;
|
||||
if (binding.kind === 'parameter' && binding.type) {
|
||||
return sdkBinding(binding.type, binding.scope, true);
|
||||
}
|
||||
const value = binding.value;
|
||||
if (binding.kind !== 'variable' || value?.type !== 'new_expression') return false;
|
||||
if (value.endIndex > node.startIndex && variableScope(binding.scope) === variableScope(scope))
|
||||
return false;
|
||||
const constructor = value.childForFieldName('constructor');
|
||||
return constructor !== null && sdkBinding(constructor, scopes.get(value.id)!);
|
||||
}
|
||||
|
||||
/** An unknown later property can replace description; a later explicit property restores proof. */
|
||||
function descriptionFromConfig(config: SyntaxNode): string {
|
||||
let description = '';
|
||||
if (config.type !== 'object') return description;
|
||||
for (const child of config.namedChildren) {
|
||||
if (child.type === 'comment') continue;
|
||||
const key = child.childForFieldName('key') ?? child.childForFieldName('name');
|
||||
const name =
|
||||
child.type === 'shorthand_property_identifier' ? child.text : key && propertyName(key);
|
||||
if (child.type === 'pair' && name === 'description') {
|
||||
const value = child.childForFieldName('value');
|
||||
description = value ? (plainString(value) ?? '') : '';
|
||||
} else if (!name || name === 'description') {
|
||||
description = '';
|
||||
}
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
function handlerNodeId(
|
||||
node: SyntaxNode,
|
||||
scope: Scope,
|
||||
callableBindings: ReadonlyMap<number, string> | undefined,
|
||||
): string | undefined {
|
||||
if (node.type !== 'identifier') return undefined;
|
||||
const binding = lookup(scope, node.text);
|
||||
if (!binding || binding.invalid) return undefined;
|
||||
if (binding.kind === 'variable') {
|
||||
const value = binding.value;
|
||||
if (
|
||||
!binding.immutable ||
|
||||
!value ||
|
||||
(value.type !== 'arrow_function' && value.type !== 'function_expression')
|
||||
)
|
||||
return undefined;
|
||||
if (value.endIndex > node.startIndex && variableScope(binding.scope) === variableScope(scope))
|
||||
return undefined;
|
||||
} else if (binding.kind !== 'function') return undefined;
|
||||
return callableBindings?.get(binding.name.id);
|
||||
}
|
||||
|
||||
/** Direct SDK registrations only; no wrapper, alias-chain or runtime-value inference. */
|
||||
export function extractToolDefinitions(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
lineOffset = 0,
|
||||
callableBindings?: ReadonlyMap<number, string>,
|
||||
): ExtractedToolDef[] {
|
||||
// Ordinary files need no lexical walk; every supported receiver originates here.
|
||||
const importsSdk = tree.rootNode.namedChildren.some((node) => {
|
||||
if (node.type !== 'import_statement') return false;
|
||||
const source = node.childForFieldName('source');
|
||||
return source !== null && SDK_MODULES.has(plainString(source) ?? '');
|
||||
});
|
||||
if (!importsSdk) return [];
|
||||
|
||||
const { scopes, calls } = collectBindings(tree.rootNode);
|
||||
const definitions: ExtractedToolDef[] = [];
|
||||
for (const call of calls) {
|
||||
const callee = call.childForFieldName('function');
|
||||
if (call.hasError || callee?.type !== 'member_expression') continue;
|
||||
const receiver = callee.childForFieldName('object');
|
||||
const method = callee.childForFieldName('property');
|
||||
if (
|
||||
!receiver ||
|
||||
method?.type !== 'property_identifier' ||
|
||||
(method.text !== 'registerTool' && method.text !== 'tool') ||
|
||||
!sdkReceiver(receiver, scopes.get(call.id)!, scopes)
|
||||
)
|
||||
continue;
|
||||
const args =
|
||||
call
|
||||
.childForFieldName('arguments')
|
||||
?.namedChildren.filter((child) => child.type !== 'comment') ?? [];
|
||||
if (args.some((arg) => arg.type === 'spread_element')) continue;
|
||||
if (method.text === 'registerTool' ? args.length !== 3 : args.length < 2 || args.length > 5)
|
||||
continue;
|
||||
const toolName = plainString(args[0]);
|
||||
if (toolName === null) continue;
|
||||
const description =
|
||||
method.text === 'registerTool'
|
||||
? descriptionFromConfig(args[1])
|
||||
: args.length > 2
|
||||
? (plainString(args[1]) ?? '')
|
||||
: '';
|
||||
const handler = handlerNodeId(args[args.length - 1], scopes.get(call.id)!, callableBindings);
|
||||
definitions.push({
|
||||
filePath,
|
||||
toolName,
|
||||
description,
|
||||
lineNumber: call.startPosition.row + 1 + lineOffset,
|
||||
...(handler !== undefined ? { handlerNodeId: handler } : {}),
|
||||
allowFileFallback: false,
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
|
@ -323,6 +323,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
|||
const toolsByHandlerId = new Map<string, string[]>();
|
||||
const toolsWithoutHandlerByFile = new Map<string, string[]>();
|
||||
for (const td of toolDefs) {
|
||||
if (!td.handlerNodeId && td.allowFileFallback === false) continue;
|
||||
const key = td.handlerNodeId ?? td.filePath;
|
||||
const targetMap = td.handlerNodeId ? toolsByHandlerId : toolsWithoutHandlerByFile;
|
||||
let list = targetMap.get(key);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
|
||||
import { getPhaseOutput } from './types.js';
|
||||
import type { ParseOutput } from './parse.js';
|
||||
import { isBladeTemplateFilename } from 'gitnexus-shared';
|
||||
import { isTemplateRouteCandidate } from '../utils/template-file.js';
|
||||
import { nextjsFileToRouteURL, normalizeFetchURL } from '../route-extractors/nextjs.js';
|
||||
import { expoFileToRouteURL } from '../route-extractors/expo.js';
|
||||
import { phpFileToRouteURL } from '../route-extractors/php.js';
|
||||
|
|
@ -41,6 +41,8 @@ import { readFileContents } from '../filesystem-walker.js';
|
|||
import { isDev } from '../utils/env.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
export { isTemplateRouteCandidate } from '../utils/template-file.js';
|
||||
|
||||
const EXPO_NAV_PATTERNS = [
|
||||
/router\.(push|replace|navigate)\(\s*['"`]([^'"`]+)['"`]/g,
|
||||
/<Link\s+[^>]*href=\s*['"`]([^'"`]+)['"`]/g,
|
||||
|
|
@ -121,17 +123,6 @@ function hasRouteParameters(routeUrl: string): boolean {
|
|||
return /\{[^}]+\}/.test(routeUrl);
|
||||
}
|
||||
|
||||
export const isTemplateRouteCandidate = (filePath: string): boolean => {
|
||||
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
||||
return (
|
||||
normalized.endsWith('.html') ||
|
||||
normalized.endsWith('.htm') ||
|
||||
normalized.endsWith('.ejs') ||
|
||||
normalized.endsWith('.hbs') ||
|
||||
isBladeTemplateFilename(normalized)
|
||||
);
|
||||
};
|
||||
|
||||
export function extractTemplateStaticFetchCalls(
|
||||
filePath: string,
|
||||
content: string,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface ToolDef {
|
|||
filePath: string;
|
||||
description: string;
|
||||
handlerNodeId?: string;
|
||||
allowFileFallback?: false;
|
||||
}
|
||||
|
||||
export interface ToolsOutput {
|
||||
|
|
@ -51,6 +52,7 @@ export const toolsPhase: PipelinePhase<ToolsOutput> = {
|
|||
filePath: td.filePath,
|
||||
description: td.description,
|
||||
...(handlerNodeId !== undefined ? { handlerNodeId } : {}),
|
||||
...(td.allowFileFallback === false ? { allowFileFallback: false as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1235,6 +1235,9 @@ export interface ScopeResolver {
|
|||
readonly isCallableVisibleFromCaller?: (ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
/** Arity of the actual call, when known. A visibility veto must not
|
||||
* infer applicability from a name match alone. */
|
||||
readonly callArity?: number;
|
||||
/** Caller's enclosing scope id. Languages that gate visibility on
|
||||
* caller scope (e.g. C++ two-phase template lookup) consult it;
|
||||
* others ignore. Optional so existing implementations stay valid. */
|
||||
|
|
|
|||
|
|
@ -84,12 +84,7 @@ export function emitFreeCallFallback(
|
|||
* appended to its reason. See `ScopeResolver.markConstructionSites`. */
|
||||
readonly markConstructionSites?: boolean;
|
||||
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
|
||||
readonly isCallableVisibleFromCaller?: (ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
readonly callerScope?: ScopeId;
|
||||
readonly scopes?: ScopeResolutionIndexes;
|
||||
}) => boolean;
|
||||
readonly isCallableVisibleFromCaller?: ScopeResolver['isCallableVisibleFromCaller'];
|
||||
readonly resolveAdlCandidates?: (
|
||||
site: {
|
||||
readonly name: string;
|
||||
|
|
@ -620,6 +615,7 @@ export function emitFreeCallFallback(
|
|||
options.isCallableVisibleFromCaller!({
|
||||
callerParsed: parsed,
|
||||
candidate,
|
||||
callArity: site.arity,
|
||||
callerScope: site.inScope,
|
||||
scopes,
|
||||
})
|
||||
|
|
@ -698,6 +694,7 @@ export function emitFreeCallFallback(
|
|||
!options.isCallableVisibleFromCaller({
|
||||
callerParsed: parsed,
|
||||
candidate: fnDef,
|
||||
callArity: site.arity,
|
||||
callerScope: site.inScope,
|
||||
scopes,
|
||||
})
|
||||
|
|
|
|||
13
gitnexus/src/core/ingestion/utils/template-file.ts
Normal file
13
gitnexus/src/core/ingestion/utils/template-file.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { isBladeTemplateFilename } from 'gitnexus-shared';
|
||||
|
||||
/** Templates whose URLs and fetches can contribute route relationships to the graph. */
|
||||
export const isTemplateRouteCandidate = (filePath: string): boolean => {
|
||||
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
||||
return (
|
||||
normalized.endsWith('.html') ||
|
||||
normalized.endsWith('.htm') ||
|
||||
normalized.endsWith('.ejs') ||
|
||||
normalized.endsWith('.hbs') ||
|
||||
isBladeTemplateFilename(normalized)
|
||||
);
|
||||
};
|
||||
|
|
@ -440,6 +440,8 @@ export interface ExtractedToolDef {
|
|||
description: string;
|
||||
lineNumber: number;
|
||||
handlerNodeId?: string;
|
||||
/** Unresolved registrations must not inherit unrelated same-file flows. */
|
||||
allowFileFallback?: false;
|
||||
}
|
||||
|
||||
export interface ExtractedORMQuery {
|
||||
|
|
@ -1691,6 +1693,7 @@ const processFileGroup = (
|
|||
// node id → graph node id for classes THIS file's capture loop materialized.
|
||||
// Keyed by in-memory AST identity (never persisted); filled below.
|
||||
const classOwnersByNodeId = new Map<number, string>();
|
||||
const callableBindings = new Map<number, string>();
|
||||
|
||||
// #2687: ONE pass over `matches` yields both suppression sets — the
|
||||
// definition-name claims by rank (callable > Property > value), so the dedup
|
||||
|
|
@ -3129,6 +3132,11 @@ const processFileGroup = (
|
|||
}),
|
||||
});
|
||||
|
||||
// Keep actual emitted identities; providers must not reconstruct graph IDs.
|
||||
if (nameNode && (nodeLabel === 'Function' || nodeLabel === 'Method')) {
|
||||
callableBindings.set(nameNode.id, nodeId);
|
||||
}
|
||||
|
||||
// enclosingClassId already computed above (before nodeId generation)
|
||||
const ownerId = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId;
|
||||
|
||||
|
|
@ -3231,6 +3239,23 @@ const processFileGroup = (
|
|||
}
|
||||
}
|
||||
|
||||
if (provider.extractToolDefinitions) {
|
||||
// Distinct lexical declarations can share a graph ID (for example, sibling
|
||||
// block-scoped functions). Such IDs cannot prove which handler owns a tool.
|
||||
const seenCallableIds = new Set<string>();
|
||||
const ambiguousCallableIds = new Set<string>();
|
||||
for (const nodeId of callableBindings.values()) {
|
||||
if (seenCallableIds.has(nodeId)) ambiguousCallableIds.add(nodeId);
|
||||
seenCallableIds.add(nodeId);
|
||||
}
|
||||
for (const [bindingId, nodeId] of callableBindings) {
|
||||
if (ambiguousCallableIds.has(nodeId)) callableBindings.delete(bindingId);
|
||||
}
|
||||
result.toolDefs.push(
|
||||
...provider.extractToolDefinitions(tree, file.path, lineOffset, callableBindings),
|
||||
);
|
||||
}
|
||||
|
||||
// Extract framework routes via provider detection (e.g., Laravel routes.php)
|
||||
if (provider.isRouteFile?.(file.path)) {
|
||||
const extractedRoutes = extractLaravelRoutes(tree, file.path);
|
||||
|
|
|
|||
|
|
@ -110,9 +110,13 @@ export const sanitizeUTF8 = (str: string): string => {
|
|||
};
|
||||
|
||||
export const escapeCSVField = (value: string | number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '""';
|
||||
if (value === undefined || value === null) return '';
|
||||
let str = String(value);
|
||||
str = sanitizeUTF8(str);
|
||||
// Preserve the NULL meaning of absent strings from the 0.18.3 writer.
|
||||
// Newer LadybugDB readers retain a quoted empty field as a STRING value;
|
||||
// it must stay unquoted or unresolved destination addresses can join.
|
||||
if (str.length === 0) return '';
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1101,28 +1101,78 @@ export type LbugProgressCallback = (message: string) => void;
|
|||
|
||||
/**
|
||||
* Run a COPY, retrying once with IGNORE_ERRORS=true (which skips row-level
|
||||
* errors) on first failure. On a second failure, hand the RAW retry error to
|
||||
* `onError` — each call site formats + slices its own message (#2226 F5: node
|
||||
* COPY slices to 200 chars and throws; relationship COPY slices to 80 and warns,
|
||||
* so the helper must not pre-format and lose that distinction). `onError` may
|
||||
* throw to propagate the failure.
|
||||
* errors) on first failure. Log the original failure and native COPY/warning
|
||||
* receipts even when the retry succeeds. Node COPY requires every row; a
|
||||
* skipped-node receipt is a failure. Call sites retain their own message
|
||||
* limits and relationship fallback policy.
|
||||
*/
|
||||
const copyCsvWithRetry = async (
|
||||
targetConn: lbug.Connection,
|
||||
copyQuery: string,
|
||||
onError: (retryErr: unknown) => void,
|
||||
expectedRows?: number,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await queryAndDrain(targetConn, copyQuery);
|
||||
} catch {
|
||||
} catch (firstError) {
|
||||
logger.warn(
|
||||
{ err: firstError, copyQuery },
|
||||
'First COPY failure; retrying with IGNORE_ERRORS=true',
|
||||
);
|
||||
try {
|
||||
const retryQuery = copyQuery.replace(
|
||||
'auto_detect=false)',
|
||||
'auto_detect=false, IGNORE_ERRORS=true)',
|
||||
);
|
||||
await queryAndDrain(targetConn, retryQuery);
|
||||
// Keep COPY and its connection-local warning receipt in one critical
|
||||
// section. Only project diagnostics, never skipped_line_or_record: that
|
||||
// column contains source text. Warnings are retained at a bounded native
|
||||
// limit, so their count is a lower bound, not the exact skipped total.
|
||||
const retry = async () => {
|
||||
await drainQueryResult(await targetConn.query('CALL CLEAR_WARNINGS()'));
|
||||
const result = await readQueryRows(await targetConn.query(retryQuery));
|
||||
const warnings = await readQueryRows(
|
||||
await targetConn.query('CALL SHOW_WARNINGS() RETURN message, file_path, line_number'),
|
||||
);
|
||||
// The native warning limit can be zero; warning rows alone cannot
|
||||
// prove that every CSV row landed. Node COPY exposes its copied count
|
||||
// in the result receipt even when warning retention is disabled.
|
||||
const countReceipt = result
|
||||
.map((row) => String(row.result ?? ''))
|
||||
.map((message) => /^(\d+) tuples have been copied/.exec(message))
|
||||
.find(Boolean);
|
||||
const copiedRows = countReceipt ? Number(countReceipt[1]) : undefined;
|
||||
logger.warn(
|
||||
{
|
||||
copyQuery,
|
||||
copyResult: result,
|
||||
expectedRows,
|
||||
copiedRows,
|
||||
skippedRows:
|
||||
expectedRows !== undefined && copiedRows !== undefined
|
||||
? Math.max(0, expectedRows - copiedRows)
|
||||
: undefined,
|
||||
retainedWarnings: warnings.length,
|
||||
warningSamples: warnings.slice(0, 5),
|
||||
},
|
||||
'COPY retry completed; retained warnings describe skipped rows (a lower bound)',
|
||||
);
|
||||
if (expectedRows !== undefined && (copiedRows !== expectedRows || warnings.length > 0)) {
|
||||
throw new Error(
|
||||
`COPY retry skipped rows or could not verify a complete node load ` +
|
||||
`(copied ${copiedRows ?? 'unknown'} of ${expectedRows}; ${warnings.length} retained warning(s))`,
|
||||
);
|
||||
}
|
||||
};
|
||||
await (isSharedSingletonConn(targetConn) ? withConnLock(retry) : retry());
|
||||
} catch (retryErr) {
|
||||
onError(retryErr);
|
||||
const firstMessage = firstError instanceof Error ? firstError.message : String(firstError);
|
||||
const retryMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
onError(
|
||||
new Error(`COPY retry failed: ${retryMessage}; first failure: ${firstMessage}`, {
|
||||
cause: retryErr,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1192,17 +1242,22 @@ const copyNodeCSVs = async (
|
|||
if (!(await stagingCsvExists(csvPath))) throw missingStagingCsvError(table, csvPath, rows);
|
||||
|
||||
const copyQuery = getCopyQuery(table, normalizeCopyPath(csvPath));
|
||||
await copyCsvWithRetry(targetConn, copyQuery, (retryErr) => {
|
||||
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
// Pool exhaustion gets a remedy (#2631): the raw binder text gives the
|
||||
// operator nothing to act on, and on non-4K-page hosts (Ascend aarch64,
|
||||
// Apple Silicon) the pool bills up to pageSize/4KiB x faster than the
|
||||
// sizing was calibrated for — name the knob and the mechanism.
|
||||
const remedy = bufferPoolExhaustionRemedy(retryMsg);
|
||||
throw new Error(
|
||||
`COPY failed for ${table}: ${retryMsg.slice(0, 200)}${remedy ? ` ${remedy}` : ''}`,
|
||||
);
|
||||
});
|
||||
await copyCsvWithRetry(
|
||||
targetConn,
|
||||
copyQuery,
|
||||
(retryErr) => {
|
||||
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
// Pool exhaustion gets a remedy (#2631): the raw binder text gives the
|
||||
// operator nothing to act on, and on non-4K-page hosts (Ascend aarch64,
|
||||
// Apple Silicon) the pool bills up to pageSize/4KiB x faster than the
|
||||
// sizing was calibrated for — name the knob and the mechanism.
|
||||
const remedy = bufferPoolExhaustionRemedy(retryMsg);
|
||||
throw new Error(
|
||||
`COPY failed for ${table}: ${retryMsg.slice(0, 200)}${remedy ? ` ${remedy}` : ''}`,
|
||||
);
|
||||
},
|
||||
rows,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -3989,6 +4044,7 @@ export const buildFtsQueryCypher = (
|
|||
* @param query - Search query string
|
||||
* @param limit - Maximum results
|
||||
* @param conjunctive - If true, all terms must match (AND); if false, any term matches (OR)
|
||||
* @param missingIndex - Preserve the empty-result default, or propagate missing indexes for diagnostics
|
||||
* @returns Array of { node properties, score }
|
||||
*/
|
||||
export const queryFTS = async (
|
||||
|
|
@ -3997,6 +4053,7 @@ export const queryFTS = async (
|
|||
query: string,
|
||||
limit: number = 20,
|
||||
conjunctive: boolean = false,
|
||||
missingIndex: 'empty' | 'throw' = 'empty',
|
||||
): Promise<
|
||||
Array<{ nodeId: string; name: string; filePath: string; score: number; [key: string]: any }>
|
||||
> => {
|
||||
|
|
@ -4027,7 +4084,7 @@ export const queryFTS = async (
|
|||
// NEW-6 — this used to be a bare `.includes('does not exist')` check
|
||||
// that could not tell the two apart).
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (classifyFtsQueryError(message) === 'missing-index') {
|
||||
if (missingIndex === 'empty' && classifyFtsQueryError(message) === 'missing-index') {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -51,6 +56,7 @@ import {
|
|||
import { summarizeUndecidedSatisfaction } from './ingestion/scope-resolution/undecided-satisfaction.js';
|
||||
import { summarizeScopeExtractionFailures } from './ingestion/scope-resolution/scope-extraction-failures.js';
|
||||
import type { KnowledgeGraph } from './graph/types.js';
|
||||
import { reconcileGraphNodeIdentities } from './incremental/write-reconciliation.js';
|
||||
import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
|
||||
import {
|
||||
initLbug,
|
||||
|
|
@ -128,6 +134,7 @@ import { resolveFtsVersionPair } from './lbug/vendored-extension-path.js';
|
|||
import {
|
||||
startWalCheckpointDriver,
|
||||
checkpointOnce,
|
||||
isManualCheckpointEnabled,
|
||||
type WalCheckpointDriver,
|
||||
} from './lbug/wal-checkpoint-driver.js';
|
||||
import {
|
||||
|
|
@ -1285,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…'),
|
||||
};
|
||||
|
|
@ -1343,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,
|
||||
|
|
@ -1718,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`.',
|
||||
);
|
||||
|
|
@ -1840,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.
|
||||
|
|
@ -2669,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
|
||||
|
|
@ -2854,7 +2951,7 @@ async function runFullAnalysisInner(
|
|||
// (Bugbot review on PR #1479: a prediction that flipped post-pipeline
|
||||
// could skip the embedding cache load and then take the full-rebuild
|
||||
// path, silently losing embeddings).
|
||||
const isIncremental =
|
||||
const incrementalEligible =
|
||||
!options.force &&
|
||||
!!existingMeta &&
|
||||
// Belt and braces, not a second gate: the guard above already set `force`
|
||||
|
|
@ -2867,6 +2964,13 @@ async function runFullAnalysisInner(
|
|||
repoHasGit &&
|
||||
allFilePaths.length > 0;
|
||||
|
||||
// Select a full build before any selective mutation when the operator has
|
||||
// disabled the checkpoint needed to certify an incremental publication.
|
||||
const isIncremental = incrementalEligible && isManualCheckpointEnabled();
|
||||
if (incrementalEligible && !isIncremental) {
|
||||
log('Manual WAL checkpoints are disabled; switching to a full DB write before mutation.');
|
||||
}
|
||||
|
||||
const hashDiff = isIncremental
|
||||
? diffFileHashes(newFileHashes, existingMeta!.fileHashes)
|
||||
: undefined;
|
||||
|
|
@ -3112,6 +3216,24 @@ async function runFullAnalysisInner(
|
|||
// collapse check compares the whole in-memory graph against the whole DB,
|
||||
// which is only a like-for-like comparison on a full rebuild.
|
||||
let wroteChangedSubgraphOnly = false;
|
||||
const markIncrementalGraphVerification = async (): Promise<void> => {
|
||||
if (buildPath !== lbugPath) return;
|
||||
const latest = await loadMeta(metaDir);
|
||||
if (!latest?.incrementalInProgress) {
|
||||
throw new Error('Cannot certify incremental graph without its dirty metadata marker.');
|
||||
}
|
||||
// A failed or aborted identity scan is a graph failure. FTS-only repair
|
||||
// and FTS crash recovery must not clear it while retaining these rows.
|
||||
await saveMeta(metaDir, {
|
||||
...latest,
|
||||
incrementalInProgress: {
|
||||
...latest.incrementalInProgress,
|
||||
phase: 'graph-reconciliation',
|
||||
updatedAt: Date.now(),
|
||||
checkpointSucceeded: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
let incrementalFtsRebuildTables: Set<string> | undefined;
|
||||
if (isIncremental && hashDiff) {
|
||||
// ── Incremental DB writeback ───────────────────────────────────
|
||||
|
|
@ -3163,6 +3285,8 @@ async function runFullAnalysisInner(
|
|||
phase: string,
|
||||
extra: Partial<NonNullable<RepoMeta['incrementalInProgress']>> = {},
|
||||
): Promise<void> => {
|
||||
// Do not stamp live metadata while writing a staging database.
|
||||
if (buildPath !== lbugPath) return;
|
||||
await saveMeta(metaDir, {
|
||||
...existingMeta!,
|
||||
incrementalInProgress: {
|
||||
|
|
@ -3906,6 +4030,14 @@ async function runFullAnalysisInner(
|
|||
'Continuing; recovery will treat the graph-boundary checkpoint as unsuccessful.',
|
||||
);
|
||||
}
|
||||
if (wroteChangedSubgraphOnly) {
|
||||
await markIncrementalGraphVerification();
|
||||
await reconcileGraphNodeIdentities(
|
||||
pipelineResult.graph,
|
||||
executeQuery,
|
||||
'post-COPY/checkpoint',
|
||||
);
|
||||
}
|
||||
if (shouldStampFtsDirtyPhase(ftsWritePlan)) {
|
||||
// Lift the prior-meta precondition: a first-ever in-place run (Windows
|
||||
// full rebuild, or any in-place incremental) must stamp too. Staging
|
||||
|
|
@ -4002,6 +4134,7 @@ async function runFullAnalysisInner(
|
|||
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
|
||||
: undefined,
|
||||
});
|
||||
if (wroteChangedSubgraphOnly) await markIncrementalGraphVerification();
|
||||
if (ftsResult.ok) {
|
||||
progress('fts', 90, 'Search indexes ready');
|
||||
} else if (ftsFailureIsFatal(ftsResult.failureClass, useAtomicSwap)) {
|
||||
|
|
@ -4054,6 +4187,12 @@ async function runFullAnalysisInner(
|
|||
progress('fts', 90, 'Search indexes skipped (FTS unavailable)');
|
||||
}
|
||||
|
||||
if (wroteChangedSubgraphOnly) {
|
||||
// FTS has returned. Later embedding/finalization failures must require
|
||||
// graph recovery, since post-FTS node identities are not yet certified.
|
||||
await markIncrementalGraphVerification();
|
||||
}
|
||||
|
||||
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
|
||||
// Runs on BOTH the full-rebuild path and the incremental path:
|
||||
// - Full rebuild / escalated write: DB was wiped, every cached row
|
||||
|
|
@ -4428,6 +4567,7 @@ async function runFullAnalysisInner(
|
|||
semanticMode = vectorIndexReady ? 'vector-index' : 'exact-scan';
|
||||
}
|
||||
|
||||
let stagedCheckpointEmbeddingCount: number | undefined;
|
||||
if (!embeddingSkipped) {
|
||||
const { isHttpMode } = await import('./embeddings/http-client.js');
|
||||
const httpMode = isHttpMode();
|
||||
|
|
@ -4442,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) {
|
||||
|
|
@ -4478,11 +4628,10 @@ async function runFullAnalysisInner(
|
|||
// /api/embed checkpoint writer in server/api.ts already uses, which also
|
||||
// keeps a concurrent writer's update from being reverted by a stale
|
||||
// snapshot) and replace ONLY `embeddingCheckpoint` — plus
|
||||
// `stats.embeddings` when the caller actually MEASURED the live count
|
||||
// (the post-window `onCheckpoint`). The window-start callback passes
|
||||
// nothing: restating the previous run's count there both re-published a
|
||||
// stale number and clobbered the live count a preceding `onCheckpoint`
|
||||
// had just written.
|
||||
// `stats.embeddings` when the caller actually MEASURED the published
|
||||
// index (the post-window `onCheckpoint` on an in-place build). A staging
|
||||
// build's count is not published until the atomic swap succeeds. The
|
||||
// window-start callback passes nothing, preserving the latest count.
|
||||
const saveEmbeddingCheckpoint = async (
|
||||
checkpoint: {
|
||||
nodesProcessed: number;
|
||||
|
|
@ -4492,7 +4641,18 @@ async function runFullAnalysisInner(
|
|||
pendingNodeIds: string[],
|
||||
embeddings?: number,
|
||||
): Promise<void> => {
|
||||
if (embeddings !== undefined && buildPath !== lbugPath) {
|
||||
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` —
|
||||
|
|
@ -4503,16 +4663,30 @@ async function runFullAnalysisInner(
|
|||
lastCommit: '',
|
||||
indexedAt: new Date().toISOString(),
|
||||
};
|
||||
const interrupted = mintInterruptedCheckpoint(
|
||||
embeddingIdentity,
|
||||
checkpoint,
|
||||
pendingNodeIds,
|
||||
);
|
||||
await saveMeta(metaDir, {
|
||||
...base,
|
||||
...(embeddings === undefined ? {} : { stats: { ...base.stats, embeddings } }),
|
||||
...(embeddings === undefined || buildPath !== lbugPath
|
||||
? {}
|
||||
: { 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],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -4535,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
|
||||
|
|
@ -4549,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(
|
||||
|
|
@ -4708,14 +4901,13 @@ async function runFullAnalysisInner(
|
|||
// already written to disk: prior meta says 0, a clean run inserts
|
||||
// embeddings and checkpoints the real count, the final probe is
|
||||
// unavailable, and finalization carries the stale 0 forward while reporting
|
||||
// success. `loadMeta` never throws (it returns null), and the checkpoint
|
||||
// writer already re-reads the same way, so this is the same freshness
|
||||
// discipline applied to the same field.
|
||||
// success. For a staged build, use its last measured count only in the
|
||||
// final meta, written after the swap; never publish it at a checkpoint.
|
||||
const latestMetaForCount =
|
||||
embeddingCount === undefined ? ((await loadMeta(metaDir)) ?? existingMeta) : undefined;
|
||||
const persistedEmbeddingCount = resolvePersistedEmbeddingCount(
|
||||
measuredEmbeddingCount,
|
||||
latestMetaForCount?.stats?.embeddings,
|
||||
stagedCheckpointEmbeddingCount ?? latestMetaForCount?.stats?.embeddings,
|
||||
);
|
||||
|
||||
const { getRuntimeCapabilities } = await import('./platform/capabilities.js');
|
||||
|
|
@ -4956,96 +5148,25 @@ async function runFullAnalysisInner(
|
|||
// Parse-cache publish waits until after that swap + saveMeta so a failed
|
||||
// registerRepo / close / swap cannot replace live shards (#3153).
|
||||
|
||||
// Forward the --name alias and the registry-collision bypass bit.
|
||||
// `allowDuplicateName` is its own concern — independent from the
|
||||
// pipeline `force` above. The CLI maps it from
|
||||
// `--allow-duplicate-name` only; `--force` and `--skills` both
|
||||
// trigger pipeline re-run but never bypass the registry guard.
|
||||
// The returned name is the one actually written to the registry
|
||||
// (after applying the precedence chain in registerRepo) — reuse it
|
||||
// so AGENTS.md / skill files reference the same name MCP clients
|
||||
// will look up (#979).
|
||||
const projectName = await registerRepo(repoPath, meta, {
|
||||
name: options.registryName,
|
||||
onRename: (previousName, nextName) =>
|
||||
log(`Registry name changed: "${previousName}" -> "${nextName}".`),
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
// Non-primary branch runs upsert into the entry's branches[]; the
|
||||
// primary/flat run (placement.branch === undefined) refreshes the
|
||||
// top-level fields (#2106).
|
||||
branch: placement.branch,
|
||||
storagePath,
|
||||
});
|
||||
|
||||
// ── #2354: the flat workspace slot has adopted this run's branch ──────
|
||||
// Drop a now-shadowed `branches/<slug>/` sub-index for the same label
|
||||
// (unreachable once the flat slot serves it) and align the registry's
|
||||
// top-level branch label. Best-effort (#2364 review F5): the index is
|
||||
// complete and registered, and a failure
|
||||
// here leaves only a stale registry label / undeleted shadowed dir —
|
||||
// never wrong routing, because the flat meta this run already stamped is
|
||||
// what applyBranchScope trusts. Retried by the next content-changing run
|
||||
// (same-commit fast-path runs skip it: their guard compares the
|
||||
// already-stamped meta label).
|
||||
if (!placement.branch && branchLabel) {
|
||||
try {
|
||||
await adoptFlatBranchLabel(repoPath, branchLabel, storagePath);
|
||||
} catch (e) {
|
||||
log(
|
||||
`Warning: could not sync the workspace branch label (${(e as Error).message}); continuing.`,
|
||||
if (wroteChangedSubgraphOnly) {
|
||||
// Registry freshness must not advance either. Include FTS, embedding
|
||||
// restoration and the final WAL drain in the certified boundary.
|
||||
await markIncrementalGraphVerification();
|
||||
await walCheckpointDriver.stop();
|
||||
if (!(await checkpointOnce())) {
|
||||
throw new Error(
|
||||
'Graph identity reconciliation failed: final checkpoint could not be verified; run `gitnexus analyze --force`.',
|
||||
);
|
||||
}
|
||||
await reconcileGraphNodeIdentities(
|
||||
pipelineResult.graph,
|
||||
executeQuery,
|
||||
'pre-publish/checkpoint',
|
||||
);
|
||||
}
|
||||
|
||||
// Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
|
||||
await ensureGitNexusIgnored(repoPath, storagePath);
|
||||
|
||||
// ── Generate AI context files (best-effort) ───────────────────────
|
||||
let aggregatedClusterCount = 0;
|
||||
if (pipelineResult.communityResult?.communities) {
|
||||
const groups = new Map<string, number>();
|
||||
for (const c of pipelineResult.communityResult.communities) {
|
||||
const label = c.heuristicLabel || c.label || 'Unknown';
|
||||
groups.set(label, (groups.get(label) || 0) + c.symbolCount);
|
||||
}
|
||||
aggregatedClusterCount = Array.from(groups.values()).filter((count) => count >= 5).length;
|
||||
}
|
||||
|
||||
// Only (re)generate the repo-root AI context files (AGENTS.md / CLAUDE.md /
|
||||
// skills) for the primary/flat index (#2106). A non-primary branch analyze
|
||||
// must not churn the repo's committed AGENTS.md with branch-specific stats.
|
||||
if (!placement.branch) {
|
||||
try {
|
||||
await generateAIContextFiles(
|
||||
repoPath,
|
||||
storagePath,
|
||||
projectName,
|
||||
{
|
||||
files: pipelineResult.totalFileCount,
|
||||
nodes: stats.nodes,
|
||||
edges: stats.edges,
|
||||
communities:
|
||||
pipelineResult.communityResult?.stats.totalCommunities ??
|
||||
existingMeta?.stats?.communities,
|
||||
clusters: aggregatedClusterCount,
|
||||
processes:
|
||||
pipelineResult.processResult?.stats.totalProcesses ?? existingMeta?.stats?.processes,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
skipAgentsMd: options.skipAgentsMd,
|
||||
skipSkills: options.skipSkills,
|
||||
noStats: options.noStats,
|
||||
defaultBranch: options.defaultBranch,
|
||||
hasPdg: options.pdg === true,
|
||||
hasSpringActuator: options.springActuatorPath !== undefined,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Best-effort — don't fail the entire analysis for context file issues
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close LadybugDB ──────────────────────────────────────────────
|
||||
// Stop the manual checkpoint driver before closeLbug so its
|
||||
// in-flight CHECKPOINT cannot race the `safeClose` CHECKPOINT.
|
||||
|
|
@ -5108,6 +5229,97 @@ 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
|
||||
// receipt intact, just as it leaves the cache unpublished.
|
||||
// Forward the --name alias and the registry-collision bypass bit.
|
||||
// `allowDuplicateName` is its own concern — independent from the
|
||||
// pipeline `force` above. The CLI maps it from
|
||||
// `--allow-duplicate-name` only; `--force` and `--skills` both
|
||||
// trigger pipeline re-run but never bypass the registry guard.
|
||||
// The returned name is the one actually written to the registry
|
||||
// (after applying the precedence chain in registerRepo) — reuse it
|
||||
// so AGENTS.md / skill files reference the same name MCP clients
|
||||
// will look up (#979).
|
||||
const projectName = await registerRepo(repoPath, meta, {
|
||||
name: options.registryName,
|
||||
onRename: (previousName, nextName) =>
|
||||
log(`Registry name changed: "${previousName}" -> "${nextName}".`),
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
// Non-primary branch runs upsert into the entry's branches[]; the
|
||||
// primary/flat run (placement.branch === undefined) refreshes the
|
||||
// top-level fields (#2106).
|
||||
branch: placement.branch,
|
||||
storagePath,
|
||||
});
|
||||
|
||||
// ── #2354: the flat workspace slot has adopted this run's branch ──────
|
||||
// Drop a now-shadowed `branches/<slug>/` sub-index for the same label
|
||||
// (unreachable once the flat slot serves it) and align the registry's
|
||||
// top-level branch label. Best-effort (#2364 review F5): the index is
|
||||
// complete and registered, and a failure
|
||||
// here leaves only a stale registry label / undeleted shadowed dir —
|
||||
// never wrong routing, because the flat meta this run already stamped is
|
||||
// what applyBranchScope trusts. Retried by the next content-changing run
|
||||
// (same-commit fast-path runs skip it: their guard compares the
|
||||
// already-stamped meta label).
|
||||
if (!placement.branch && branchLabel) {
|
||||
try {
|
||||
await adoptFlatBranchLabel(repoPath, branchLabel, storagePath);
|
||||
} catch (e) {
|
||||
log(
|
||||
`Warning: could not sync the workspace branch label (${(e as Error).message}); continuing.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generate AI context files (best-effort) ───────────────────────
|
||||
let aggregatedClusterCount = 0;
|
||||
if (pipelineResult.communityResult?.communities) {
|
||||
const groups = new Map<string, number>();
|
||||
for (const c of pipelineResult.communityResult.communities) {
|
||||
const label = c.heuristicLabel || c.label || 'Unknown';
|
||||
groups.set(label, (groups.get(label) || 0) + c.symbolCount);
|
||||
}
|
||||
aggregatedClusterCount = Array.from(groups.values()).filter((count) => count >= 5).length;
|
||||
}
|
||||
|
||||
// Only (re)generate the repo-root AI context files (AGENTS.md / CLAUDE.md /
|
||||
// skills) for the primary/flat index (#2106). A non-primary branch analyze
|
||||
// must not churn the repo's committed AGENTS.md with branch-specific stats.
|
||||
if (!placement.branch) {
|
||||
try {
|
||||
await generateAIContextFiles(
|
||||
repoPath,
|
||||
storagePath,
|
||||
projectName,
|
||||
{
|
||||
files: pipelineResult.totalFileCount,
|
||||
nodes: stats.nodes,
|
||||
edges: stats.edges,
|
||||
communities:
|
||||
pipelineResult.communityResult?.stats.totalCommunities ??
|
||||
existingMeta?.stats?.communities,
|
||||
clusters: aggregatedClusterCount,
|
||||
processes:
|
||||
pipelineResult.processResult?.stats.totalProcesses ?? existingMeta?.stats?.processes,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
skipAgentsMd: options.skipAgentsMd,
|
||||
skipSkills: options.skipSkills,
|
||||
noStats: options.noStats,
|
||||
defaultBranch: options.defaultBranch,
|
||||
hasPdg: options.pdg === true,
|
||||
hasSpringActuator: options.springActuatorPath !== undefined,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Best-effort — don't fail the entire analysis for context file issues
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the incremental parse cache only after a successful graph
|
||||
// publish (#3153). try/catch so a cache-write failure never breaks an
|
||||
|
|
@ -5227,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 */
|
||||
}
|
||||
|
|
@ -5239,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ export interface FTSSearchResponse {
|
|||
* which only does so when every table failed).
|
||||
*/
|
||||
nonBenignErrors?: string[];
|
||||
/** Configured table.index names that could not be queried because their index is missing. */
|
||||
missingIndexes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -142,6 +144,7 @@ export const searchFTSFromLbug = async (
|
|||
const resultsByIndex: any[][] = [];
|
||||
let queriesSucceeded = 0;
|
||||
const nonBenignErrors: string[] = [];
|
||||
const missingIndexes: string[] = [];
|
||||
|
||||
const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts');
|
||||
if (ftsExtension && !ftsExtension.loaded) {
|
||||
|
|
@ -171,24 +174,28 @@ export const searchFTSFromLbug = async (
|
|||
if (outcome.rows) {
|
||||
queriesSucceeded++;
|
||||
resultsByIndex.push(outcome.rows);
|
||||
} else if (!outcome.benign) {
|
||||
} else if (outcome.benign) {
|
||||
missingIndexes.push(`${table}.${indexName}`);
|
||||
} else {
|
||||
nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
|
||||
// tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing
|
||||
// index (via the SAME classifyFtsQueryError this module re-exports); a
|
||||
// missing-table or real query error rethrows here — track it the same way
|
||||
// the MCP pool path does instead of a bare `catch {}` that dropped it.
|
||||
// Opt into missing-index propagation so absent indexes cannot masquerade
|
||||
// as successful zero-match queries. Keep core/pool classification identical.
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
try {
|
||||
const result = await queryFTS(table, indexName, searchQuery, limit, false);
|
||||
const result = await queryFTS(table, indexName, searchQuery, limit, false, 'throw');
|
||||
queriesSucceeded++;
|
||||
resultsByIndex.push(result);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
nonBenignErrors.push(redactPaths(message));
|
||||
if (classifyFtsQueryError(message) === 'missing-index') {
|
||||
missingIndexes.push(`${table}.${indexName}`);
|
||||
} else {
|
||||
nonBenignErrors.push(redactPaths(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -234,5 +241,6 @@ export const searchFTSFromLbug = async (
|
|||
})),
|
||||
ftsAvailable,
|
||||
...(nonBenignErrors.length > 0 && { nonBenignErrors }),
|
||||
...(missingIndexes.length > 0 && { missingIndexes }),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
dropFTSIndex,
|
||||
indexRowName,
|
||||
indexRowTable,
|
||||
indexRowType,
|
||||
resolveGateRows,
|
||||
DEFAULT_FTS_STEMMER,
|
||||
type IndexCatalogSnapshot,
|
||||
|
|
@ -110,19 +111,21 @@ export const ftsDegradedWarning = (
|
|||
};
|
||||
|
||||
/**
|
||||
* Warning for when the FTS extension is loaded and indexes exist, but every
|
||||
* configured table's query failed for a real, non-benign reason (timeout,
|
||||
* connection reset, native fault) — as opposed to `ftsDegradedWarning`'s
|
||||
* missing-index case. `--repair-fts` will not fix a query/connection error,
|
||||
* so this deliberately does NOT suggest it: reusing the missing-index
|
||||
* message here would reproduce, for this cause, the exact misleading
|
||||
* "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1).
|
||||
* Warning when no FTS query succeeded and at least one failed for a real,
|
||||
* non-benign reason (timeout, connection reset, native fault). `--repair-fts`
|
||||
* will not fix those errors. If indexes are also missing, the caller composes
|
||||
* their repair guidance separately; do not deny that additional failure cause.
|
||||
*/
|
||||
export const ftsQueryFailedWarning = (context: FtsWarningContext): string =>
|
||||
export const ftsQueryFailedWarning = (
|
||||
context: FtsWarningContext,
|
||||
hasMissingIndexes = false,
|
||||
): string =>
|
||||
'FTS keyword search failed — every configured index query returned an error' +
|
||||
(context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') +
|
||||
'; results do not include keyword matches. This is not a missing-index ' +
|
||||
'condition — see server logs for details.' +
|
||||
'; results do not include keyword matches. ' +
|
||||
(hasMissingIndexes
|
||||
? 'See server logs for query error details.'
|
||||
: 'This is not a missing-index condition — see server logs for details.') +
|
||||
` (resolved: ${formatResolvedSuffix(context)})`;
|
||||
|
||||
// Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token
|
||||
|
|
@ -392,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
|
||||
|
|
@ -402,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;
|
||||
}
|
||||
|
|
@ -533,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('; ');
|
||||
|
|
@ -553,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);
|
||||
|
|
|
|||
|
|
@ -18,21 +18,35 @@
|
|||
* provably stale index indistinguishable from a fresh one. `status` is the
|
||||
* additive channel that separates them for a caller that wants to act on it:
|
||||
*
|
||||
* - `current` — the index is at HEAD: `rev-list` answered 0, or it could not
|
||||
* answer but HEAD alone resolved to the indexed commit.
|
||||
* - `behind` — `rev-list` answered N > 0; `commitsBehind` is N.
|
||||
* - `diverged` — `rev-list` could not answer, but HEAD resolved and is not the
|
||||
* indexed commit. The index is provably not at HEAD; only the count is
|
||||
* unknown. A branch-pinned `serve` clone reaches this once git prunes the
|
||||
* commit a failed re-index left behind — the pinned update is a
|
||||
* `fetch --depth 1`, which orphans it — and a rewritten history reaches it
|
||||
* directly. It is the rule the Claude hook already applies:
|
||||
* HEAD !== lastCommit.
|
||||
* - `unknown` — HEAD could not be resolved at all: not a git repository, git
|
||||
* timed out, or no commit was recorded.
|
||||
* The successful probe is `rev-list --left-right --count lastCommit...HEAD`:
|
||||
* the left count is indexed-only commits, and the right is HEAD-only commits.
|
||||
* Both counts come from one HEAD snapshot, without a follow-up process.
|
||||
*
|
||||
* `isStale` and `commitsBehind` keep their historical values in every case, so
|
||||
* no existing consumer changes behaviour unless it reads `status`.
|
||||
* - `current` — both counts are 0, or `rev-list` could not answer but a
|
||||
* fallback `rev-parse HEAD` resolved to the indexed commit.
|
||||
* - `behind` — the right count is N > 0; `commitsBehind` is N, including
|
||||
* when the left count is positive too (divergent or shallow history).
|
||||
* - `diverged` — the index is provably not at HEAD, reached two different ways:
|
||||
* - The left count is positive and the right is 0: HEAD is an ancestor
|
||||
* of the indexed commit (#3127). The working tree checked out an older
|
||||
* commit than the one indexed, or a release branch behind the indexed
|
||||
* tip. The mismatch is established: `isStale` is `true` and
|
||||
* `commitsBehind` stays 0 (there is no forward count to report).
|
||||
* - `rev-list` could not answer at all, but HEAD resolved and is not the
|
||||
* indexed commit: only the count is unknown. A branch-pinned `serve`
|
||||
* clone reaches this once git prunes the commit a failed re-index left
|
||||
* behind — the pinned update is a `fetch --depth 1`, which orphans it —
|
||||
* and a rewritten history reaches it directly. This arm keeps the
|
||||
* historical fail-open `isStale: false` (see below).
|
||||
* - `unknown` — the probe could not establish the relationship: no readable
|
||||
* HEAD, a timeout, no recorded commit, or malformed count output.
|
||||
*
|
||||
* `isStale` and `commitsBehind` keep their historical fail-open values
|
||||
* (`false` / `0`) whenever the check could not fully answer — every `unknown`,
|
||||
* and the `rev-list`-failure arm of `diverged` — so no existing consumer
|
||||
* changes behaviour there unless it reads `status`. The other arm of
|
||||
* `diverged` (the confirmed rollback) is a successful, computed
|
||||
* answer rather than a failure, so `isStale` reflects it (`true`) instead.
|
||||
*/
|
||||
export type StalenessStatus = 'current' | 'behind' | 'diverged' | 'unknown';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ import { resolveGraphPath } from '../../storage/shared-store.js';
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { scoreImpactRisk, unusedAxesForImpactWalk, type ImpactRiskResult } from 'gitnexus-shared';
|
||||
import {
|
||||
scoreImpactRisk,
|
||||
unusedAxesForImpactWalk,
|
||||
getLanguageFromFilename,
|
||||
type ImpactRiskResult,
|
||||
} from 'gitnexus-shared';
|
||||
import {
|
||||
initLbug,
|
||||
executeQuery,
|
||||
|
|
@ -23,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';
|
||||
|
|
@ -30,8 +44,10 @@ import { shapeQueryProcessAttaches } from './query-process-attaches.js';
|
|||
import { LBUG_ID_PROBE_BATCH_SIZE, LBUG_QUERY_BATCH_SIZE } from '../../core/lbug/query-batch.js';
|
||||
import { chunk, mapConcurrent } from '../../lib/utils.js';
|
||||
import { pathSuffixOf } from './path-predicate.js';
|
||||
import { isCobolFile, isJclFile } from '../../core/ingestion/cobol/file-types.js';
|
||||
import { toOneBasedLine } from '../../core/ingestion/utils/line-base.js';
|
||||
import { isTestFilePath } from '../../core/ingestion/utils/test-file-path.js';
|
||||
import { isTemplateRouteCandidate } from '../../core/ingestion/utils/template-file.js';
|
||||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js';
|
||||
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
|
||||
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
|
||||
|
|
@ -317,6 +333,70 @@ function nonBlankUid(value: unknown): string | undefined {
|
|||
return typeof value === 'string' ? value.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function assertSymbolRowIdentity(row: unknown): void {
|
||||
assertQueryIdentity(row, 'id', 0, [
|
||||
['name', 1],
|
||||
['type', 2],
|
||||
['filePath', 3],
|
||||
]);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface StringAliasDefinition {
|
||||
canonical: string;
|
||||
aliases: readonly string[];
|
||||
|
|
@ -3143,6 +3223,7 @@ export class LocalBackend {
|
|||
// regardless of whether OTHER tables succeeded — previously a real error
|
||||
// on N-1 of N tables while one succeeded left zero diagnostic trail.
|
||||
const ftsQueryErrors = bm25SearchResult?.nonBenignErrors;
|
||||
const ftsMissingIndexes = bm25SearchResult?.missingIndexes;
|
||||
if (ftsQueryErrors) {
|
||||
// tri-review NEW-5: these strings are already classified non-benign by
|
||||
// classifyFtsQueryError — do NOT route them through logQueryError,
|
||||
|
|
@ -3632,13 +3713,15 @@ export class LocalBackend {
|
|||
branch: repo.branch,
|
||||
indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt,
|
||||
};
|
||||
// tri-review NEW-1: every table failing for a REAL error (timeout,
|
||||
// connection reset) is not a missing-index condition — `ftsDegradedWarning`'s
|
||||
// "run --repair-fts" headline won't fix it. Route to a dedicated message
|
||||
// instead of burying the real cause as a trailing suffix on bad advice.
|
||||
// Real errors (timeout, connection reset) need their own diagnosis.
|
||||
// When some indexes are also missing, preserve both causes and append
|
||||
// their repair guidance below even though no FTS query succeeded.
|
||||
warnings.push(
|
||||
ftsQueryErrors
|
||||
? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] })
|
||||
? ftsQueryFailedWarning(
|
||||
{ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] },
|
||||
!!ftsMissingIndexes?.length,
|
||||
)
|
||||
: ftsDegradedWarning(warningContext, ftsDisabledReason),
|
||||
);
|
||||
} else if (ftsQueryErrors) {
|
||||
|
|
@ -3651,6 +3734,12 @@ export class LocalBackend {
|
|||
`FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`,
|
||||
);
|
||||
}
|
||||
if (ftsMissingIndexes?.length && (ftsUsed || ftsQueryErrors)) {
|
||||
warnings.push(
|
||||
`FTS keyword search is incomplete: missing configured indexes (${ftsMissingIndexes.join(', ')}). ` +
|
||||
'Results may be missing matches from those node types. Run `gitnexus analyze --repair-fts`.',
|
||||
);
|
||||
}
|
||||
// #2331: a CJK query against a server process resolving
|
||||
// GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase
|
||||
// matches with no other signal — this is the only place an agent driving
|
||||
|
|
@ -3782,7 +3871,7 @@ export class LocalBackend {
|
|||
// #2767: a partial FTS failure (some tables ok, one or more real errors)
|
||||
// is as much a "results may be incomplete" signal as enrichmentDegraded —
|
||||
// flag it the same way rather than only via the warning string.
|
||||
const ftsPartial = ftsUsed && !!ftsQueryErrors;
|
||||
const ftsPartial = ftsUsed && (!!ftsQueryErrors || !!ftsMissingIndexes?.length);
|
||||
|
||||
return {
|
||||
processes,
|
||||
|
|
@ -3803,7 +3892,12 @@ export class LocalBackend {
|
|||
query: string,
|
||||
limit: number,
|
||||
disabledReason?: FtsDisabledReason,
|
||||
): Promise<{ results: any[]; ftsUsed: boolean; nonBenignErrors?: string[] }> {
|
||||
): Promise<{
|
||||
results: any[];
|
||||
ftsUsed: boolean;
|
||||
nonBenignErrors?: string[];
|
||||
missingIndexes?: string[];
|
||||
}> {
|
||||
if (disabledReason) return { results: [], ftsUsed: false };
|
||||
let searchFTSFromLbug;
|
||||
try {
|
||||
|
|
@ -3837,6 +3931,7 @@ export class LocalBackend {
|
|||
const bm25Results = ftsResponse?.results ?? [];
|
||||
const ftsUsed = ftsResponse?.ftsAvailable ?? false;
|
||||
const nonBenignErrors = ftsResponse?.nonBenignErrors;
|
||||
const missingIndexes = ftsResponse?.missingIndexes;
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
|
|
@ -3910,7 +4005,12 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
|
||||
return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
|
||||
return {
|
||||
results,
|
||||
ftsUsed,
|
||||
...(nonBenignErrors && { nonBenignErrors }),
|
||||
...(missingIndexes && { missingIndexes }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -4344,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,
|
||||
|
|
@ -4380,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);
|
||||
|
|
@ -4387,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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -4512,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,
|
||||
|
|
@ -4522,6 +4626,7 @@ export class LocalBackend {
|
|||
endLine: (r.endLine ?? r[5]) as number,
|
||||
...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}),
|
||||
};
|
||||
assertSymbolIdentity(symbol.id, uid);
|
||||
// Same LadybugDB label-enrichment as the name-based path: a UID
|
||||
// pointing at a Class must still surface `type: 'Class'` so impact's
|
||||
// Class/Interface BFS seed fires. No-op when type is already set.
|
||||
|
|
@ -4645,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,
|
||||
|
|
@ -4655,7 +4764,6 @@ export class LocalBackend {
|
|||
endLine: (r.endLine ?? r[5]) as number,
|
||||
...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}),
|
||||
}));
|
||||
|
||||
// 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).
|
||||
|
|
@ -4805,9 +4913,14 @@ 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) {
|
||||
return { error: msg, recoverySuggestion: SYMBOL_IDENTITY_RECOVERY_SUGGESTION };
|
||||
}
|
||||
if (isWalCorruptionError(err)) {
|
||||
return {
|
||||
error: msg,
|
||||
|
|
@ -4922,6 +5035,8 @@ export class LocalBackend {
|
|||
{ symId },
|
||||
),
|
||||
]);
|
||||
assertContextRefs(incomingRows);
|
||||
assertContextRefs(incomingAdvisedRows);
|
||||
incomingRows.push(...incomingAdvisedRows);
|
||||
let typedPropertyRows: any[] = [];
|
||||
|
||||
|
|
@ -5026,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
|
||||
|
|
@ -5042,6 +5167,7 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('context:class-incoming-expansion', e);
|
||||
}
|
||||
}
|
||||
|
|
@ -5072,6 +5198,8 @@ export class LocalBackend {
|
|||
{ symId },
|
||||
),
|
||||
]);
|
||||
assertContextRefs(outgoingRows);
|
||||
assertContextRefs(outgoingAdvisedRows);
|
||||
outgoingRows.push(...outgoingAdvisedRows);
|
||||
|
||||
// Process participation.
|
||||
|
|
@ -5095,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);
|
||||
}
|
||||
|
||||
|
|
@ -5132,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);
|
||||
|
|
@ -5141,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);
|
||||
}
|
||||
|
||||
|
|
@ -5191,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
|
||||
|
|
@ -5285,6 +5423,7 @@ export class LocalBackend {
|
|||
try {
|
||||
chain = await this._computeContextChain(repo, symId, requestedDepth);
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('context:chain-bfs', e);
|
||||
}
|
||||
}
|
||||
|
|
@ -5325,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],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -5407,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.
|
||||
|
|
@ -5426,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: [] };
|
||||
}
|
||||
|
|
@ -6445,6 +6592,7 @@ export class LocalBackend {
|
|||
// throwaway arrays the size of the row set (40k rows 11.4ms → 4.5ms, 200k
|
||||
// rows 71.3ms → 26.6ms).
|
||||
const exactlyMatchedPaths = new Set<string>();
|
||||
const mappedPaths = new Set<string>();
|
||||
for (const row of symbolRows) {
|
||||
if (row.filePath === row.diffPath) exactlyMatchedPaths.add(row.diffPath);
|
||||
}
|
||||
|
|
@ -6454,6 +6602,8 @@ export class LocalBackend {
|
|||
if (sym.filePath !== sym.diffPath && exactlyMatchedPaths.has(diffPath)) continue;
|
||||
const hunks = hunksByPath.get(diffPath) ?? [];
|
||||
if (!hunksOverlapRange(hunks, sym.startLine, sym.endLine)) continue;
|
||||
// A suffix fallback is a hint, not proof that this is the changed file.
|
||||
if (sym.filePath === diffPath) mappedPaths.add(diffPath);
|
||||
if (changedSymbols.has(sym.id)) continue;
|
||||
|
||||
changedSymbols.set(sym.id, {
|
||||
|
|
@ -6465,6 +6615,27 @@ export class LocalBackend {
|
|||
});
|
||||
}
|
||||
|
||||
// An empty successful query cannot prove a source diff is safe: its rows
|
||||
// may be missing, outside indexed spans, or not yet indexed. Keep ordinary
|
||||
// docs/config diffs measurable, but withhold a ranked source-risk verdict.
|
||||
const isSourceFile = (file: string): boolean =>
|
||||
getLanguageFromFilename(file) !== null ||
|
||||
isCobolFile(file) ||
|
||||
isJclFile(file) ||
|
||||
isTemplateRouteCandidate(file);
|
||||
const unmappedFiles = [
|
||||
...new Set(
|
||||
fileDiffs
|
||||
.filter(
|
||||
({ filePath, oldFilePath }) =>
|
||||
!mappedPaths.has(filePath) &&
|
||||
(isSourceFile(filePath) || (oldFilePath !== undefined && isSourceFile(oldFilePath))),
|
||||
)
|
||||
.map(({ filePath }) => filePath),
|
||||
),
|
||||
];
|
||||
if (unmappedFiles.length > 0) queryDegraded = true;
|
||||
|
||||
// Find affected processes -- batched queries instead of N+1
|
||||
const affectedProcesses = new Map<string, any>();
|
||||
if (changedSymbols.size > 0) {
|
||||
|
|
@ -6565,8 +6736,9 @@ export class LocalBackend {
|
|||
},
|
||||
changed_symbols: listedSymbols,
|
||||
affected_processes: Array.from(affectedProcesses.values()),
|
||||
// A swallowed query failure makes the counts/risk above incomplete — tell
|
||||
// the caller so the safety gate isn't trusted as a clean result (#2283).
|
||||
...(unmappedFiles.length > 0 && { unmapped_files: unmappedFiles }),
|
||||
// Failed queries or unmapped source files leave counts/risk incomplete;
|
||||
// the safety gate must not treat that as a clean result (#2283).
|
||||
...(queryDegraded && { partial: true }),
|
||||
...(listedSymbols.length < changedSymbols.size && { truncated: true }),
|
||||
};
|
||||
|
|
@ -7082,13 +7254,23 @@ 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 =
|
||||
(err instanceof Error ? err.message : String(err)) || 'Impact analysis failed';
|
||||
const suggestion = 'The graph query failed — try gitnexus context <symbol> as a fallback';
|
||||
const recoverySuggestion = isWalCorruptionError(err) ? WAL_RECOVERY_SUGGESTION : undefined;
|
||||
const recoverySuggestion =
|
||||
err instanceof SymbolIdentityError
|
||||
? SYMBOL_IDENTITY_RECOVERY_SUGGESTION
|
||||
: isWalCorruptionError(err)
|
||||
? WAL_RECOVERY_SUGGESTION
|
||||
: undefined;
|
||||
const suggestion =
|
||||
err instanceof SymbolIdentityError
|
||||
? SYMBOL_IDENTITY_RECOVERY_SUGGESTION
|
||||
: 'The graph query failed — try gitnexus context <symbol> as a fallback';
|
||||
if (params.mode === 'pdg') {
|
||||
// Symbol resolution never reached the catch with a resolved symbol (the
|
||||
// throw can originate before/within resolution), so the envelope carries
|
||||
|
|
@ -7392,6 +7574,7 @@ export class LocalBackend {
|
|||
} catch (e) {
|
||||
probeFailed = true;
|
||||
candidateProbeFailed = true;
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('impact:ambiguous-candidate', e);
|
||||
}
|
||||
return {
|
||||
|
|
@ -7649,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);
|
||||
}
|
||||
|
|
@ -7686,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;
|
||||
|
|
@ -7725,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;
|
||||
|
|
@ -7878,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
|
||||
|
|
@ -7917,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, {
|
||||
|
|
@ -7942,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);
|
||||
|
|
@ -8001,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);
|
||||
|
|
@ -8093,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
|
||||
|
|
@ -8128,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>();
|
||||
|
|
@ -8172,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);
|
||||
|
|
@ -8179,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);
|
||||
|
|
@ -8203,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);
|
||||
|
|
@ -8210,6 +8413,7 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('impact:class-node-expansion', e);
|
||||
traversalComplete = false;
|
||||
}
|
||||
|
|
@ -8247,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)) {
|
||||
|
|
@ -8266,6 +8473,7 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('impact:object-callable-expansion', e);
|
||||
traversalComplete = false;
|
||||
}
|
||||
|
|
@ -8322,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],
|
||||
|
|
@ -8421,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)
|
||||
|
|
@ -8546,6 +8766,7 @@ export class LocalBackend {
|
|||
`,
|
||||
{ ids },
|
||||
).catch((err) => {
|
||||
rethrowSymbolIdentityError(err);
|
||||
processQueryFailed = true;
|
||||
enrichmentDegraded = true;
|
||||
logQueryError('impact:process-chunk', err);
|
||||
|
|
@ -8553,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
|
||||
|
|
@ -8601,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);
|
||||
|
|
@ -8623,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));
|
||||
|
|
@ -8640,6 +8872,7 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
enrichmentDegraded = true;
|
||||
logQueryError('impact:process-chunk-backfill', e);
|
||||
}
|
||||
|
|
@ -8702,6 +8935,7 @@ export class LocalBackend {
|
|||
`,
|
||||
{ ids: idsChunk },
|
||||
).catch((err) => {
|
||||
rethrowSymbolIdentityError(err);
|
||||
moduleQueryFailed = true;
|
||||
enrichmentDegraded = true;
|
||||
logQueryError('impact:module-chunk', err);
|
||||
|
|
@ -8709,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);
|
||||
|
|
@ -8743,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);
|
||||
|
|
@ -8814,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;
|
||||
|
|
@ -8975,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 = {
|
||||
|
|
@ -8990,6 +9240,7 @@ export class LocalBackend {
|
|||
else perSymbolProcesses.set(String(sid), [procEntry]);
|
||||
}
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('impact:per-symbol-process-chunk', e);
|
||||
}
|
||||
}
|
||||
|
|
@ -9106,6 +9357,7 @@ export class LocalBackend {
|
|||
];
|
||||
|
||||
try {
|
||||
assertSymbolIdentity(sym.id ?? sym[0], uid);
|
||||
// skipPerSymbolEnrichment suppresses ONLY the per-symbol STEP_IN_PROCESS
|
||||
// enrichment pass while preserving byDepth. Group-mode cross-repo fan-out
|
||||
// may fan across many repos; the per-symbol pass adds up to MAX_CHUNKS
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue