diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml deleted file mode 100644 index 25ea4b8d4..000000000 --- a/.github/workflows/ci-integration.yml +++ /dev/null @@ -1,199 +0,0 @@ -name: Integration Tests - -on: - workflow_call: - inputs: - collect-coverage: - description: 'Whether to run the coverage collection job (only needed for PR reports)' - required: false - default: true - type: boolean - -jobs: - # ── Integration test matrix ───────────────────────────────────────── - # Each test-group runs on a SEPARATE runner per OS, giving full process - # isolation for the LadybugDB native C++ addon. - # 3 OS x 4 groups = 12 parallel jobs. - # - # Groups: - # lbug-db — 8 files using withTestLbugDB / lbug-adapter (native addon) - # Each file runs as its own `vitest run` invocation for full - # process isolation. LadybugDB's native N-API addon registers - # persistent handles that prevent fork workers from exiting - # on Linux, and its C++ destructors segfault during - # process.exit(). Running each file in its own process lets - # the OS reclaim all resources cleanly. - # pipeline — 12 files: ingestion pipeline + csv + 9 resolver tests - # e2e — 4 files: child-process only (spawnSync), no in-process lbug - # standalone — 4 files: pure logic, no lbug, no child processes - test-matrix: - name: integration (${{ matrix.os }} / ${{ matrix.test-group }}) - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - test-group: [lbug-db, pipeline, e2e, standalone] - include: - - test-group: lbug-db - # Marker — actual files are listed in the run step below - test-glob: '' - - test-group: pipeline - test-glob: >- - test/integration/pipeline.test.ts - test/integration/csv-pipeline.test.ts - test/integration/parsing.test.ts - test/integration/resolvers/typescript.test.ts - test/integration/resolvers/csharp.test.ts - test/integration/resolvers/cpp.test.ts - test/integration/resolvers/java.test.ts - test/integration/resolvers/python.test.ts - test/integration/resolvers/rust.test.ts - test/integration/resolvers/go.test.ts - test/integration/resolvers/kotlin.test.ts - test/integration/resolvers/php.test.ts - test/integration/resolvers/ruby.test.ts - test/integration/resolvers/swift.test.ts - - test-group: e2e - test-glob: >- - test/integration/cli-e2e.test.ts - test/integration/hooks-e2e.test.ts - test/integration/skills-e2e.test.ts - test/integration/ignore-and-skip-e2e.test.ts - - test-group: standalone - test-glob: >- - test/integration/filesystem-walker.test.ts - test/integration/enrichment.test.ts - test/integration/tree-sitter-languages.test.ts - test/integration/worker-pool.test.ts - runs-on: ${{ matrix.os }} - timeout-minutes: 25 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: ./.github/actions/setup-gitnexus - with: - build: 'true' - - # lbug-db: run each file in its own vitest process for full isolation. - # LadybugDB's native addon hangs fork workers on Linux — process isolation - # is the only reliable fix boundary. - - name: Run integration tests — lbug-db (process-isolated) - if: matrix.test-group == 'lbug-db' - working-directory: gitnexus - shell: bash - run: | - set -e - files=( - test/integration/lbug-core-adapter.test.ts - test/integration/lbug-pool.test.ts - test/integration/lbug-pool-stability.test.ts - test/integration/local-backend.test.ts - test/integration/local-backend-calltool.test.ts - test/integration/search-core.test.ts - test/integration/search-pool.test.ts - test/integration/augmentation.test.ts - ) - exit_code=0 - for f in "${files[@]}"; do - echo "::group::$f" - if ! npx vitest run --reporter=verbose --pool=forks "$f"; then - exit_code=1 - echo "::error::Test file failed: $f" - fi - echo "::endgroup::" - done - exit $exit_code - - # Non-lbug groups: run all files in a single vitest invocation - - name: Run integration tests — ${{ matrix.test-group }} - if: matrix.test-group != 'lbug-db' - shell: bash - env: - TEST_GLOB: ${{ matrix.test-glob }} - run: npx vitest run --reporter=verbose $TEST_GLOB - working-directory: gitnexus - - # ── Coverage collection (ubuntu only) ───────────────────────────────── - # Runs non-lbug integration tests with coverage enabled so the PR report - # can merge integration + unit coverage for a combined view. - # lbug-db tests are excluded because each file must run in its own vitest - # process (native addon isolation) which prevents single-run coverage merge. - coverage: - name: integration (ubuntu / coverage) - if: inputs.collect-coverage - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: ./.github/actions/setup-gitnexus - with: - build: 'true' - - - name: Run integration tests with coverage - working-directory: gitnexus - run: >- - npx vitest run - --reporter=default - --reporter=json - --outputFile=integration-results.json - --coverage - --coverage.reporter=json-summary - --coverage.reporter=json - --coverage.reporter=text - --coverage.thresholdAutoUpdate=false - --coverage.reportOnFailure=true - --coverage.thresholds.statements=0 - --coverage.thresholds.branches=0 - --coverage.thresholds.functions=0 - --coverage.thresholds.lines=0 - test/integration/pipeline.test.ts - test/integration/csv-pipeline.test.ts - test/integration/parsing.test.ts - test/integration/cli-e2e.test.ts - test/integration/hooks-e2e.test.ts - test/integration/filesystem-walker.test.ts - test/integration/enrichment.test.ts - test/integration/tree-sitter-languages.test.ts - test/integration/worker-pool.test.ts - test/integration/ignore-and-skip-e2e.test.ts - test/integration/resolvers/typescript.test.ts - test/integration/resolvers/csharp.test.ts - test/integration/resolvers/cpp.test.ts - test/integration/resolvers/java.test.ts - test/integration/resolvers/python.test.ts - test/integration/resolvers/rust.test.ts - test/integration/resolvers/go.test.ts - test/integration/resolvers/kotlin.test.ts - test/integration/resolvers/php.test.ts - test/integration/resolvers/ruby.test.ts - test/integration/resolvers/swift.test.ts - - - name: Upload integration coverage - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: integration-reports - path: | - gitnexus/coverage/coverage-summary.json - gitnexus/coverage/coverage-final.json - gitnexus/integration-results.json - retention-days: 5 - - # ── Unified status gate ────────────────────────────────────────────── - # Branch protection should require THIS job, not the matrix jobs directly. - # ci.yml's needs.integration.result aggregates through this gate. - status: - name: integration (all groups) - needs: test-matrix - if: always() - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Check all matrix jobs passed - shell: bash - env: - RESULT: ${{ needs.test-matrix.result }} - run: | - if [[ "$RESULT" != "success" ]]; then - echo "::error::Integration matrix failed or cancelled: $RESULT" - exit 1 - fi diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml deleted file mode 100644 index 106035930..000000000 --- a/.github/workflows/ci-report.yml +++ /dev/null @@ -1,522 +0,0 @@ -name: CI Report - -# Triggered after the CI workflow completes. Because workflow_run -# always runs code from the *default branch*, it receives a read/write -# GITHUB_TOKEN — even when the triggering PR comes from a fork. - -on: - workflow_run: - workflows: ["CI"] - types: [completed] - -permissions: - actions: read # needed to list/download workflow run artifacts - contents: read # needed for sparse checkout of vitest.config.ts - pull-requests: write # needed to post sticky PR comment - -jobs: - pr-report: - name: PR Report - # Only run for pull-request CI runs - if: >- - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion != 'cancelled' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # ── Download artifacts from the CI run ──────────────────────── - - name: Download artifacts - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 - with: - script: | - const fs = require('fs'); - const path = require('path'); - const runId = context.payload.workflow_run.id; - - const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: runId, - }); - - async function downloadArtifact(name, dest) { - const match = allArtifacts.data.artifacts.find(a => a.name === name); - if (!match) { - core.warning(`Artifact "${name}" not found`); - return false; - } - const zip = await github.rest.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: match.id, - archive_format: 'zip', - }); - fs.mkdirSync(dest, { recursive: true }); - fs.writeFileSync(path.join(dest, `${name}.zip`), Buffer.from(zip.data)); - return true; - } - - const temp = process.env.RUNNER_TEMP; - await downloadArtifact('pr-meta', path.join(temp, 'dl')); - await downloadArtifact('test-reports', path.join(temp, 'dl')); - await downloadArtifact('integration-reports', path.join(temp, 'dl')); - - - name: Extract artifacts - shell: bash - run: | - cd "$RUNNER_TEMP/dl" - # Extract each artifact into its own directory to avoid filename collisions - for z in *.zip; do - [ -f "$z" ] || continue - name="${z%.zip}" - mkdir -p "$RUNNER_TEMP/artifacts/$name" - unzip -o "$z" -d "$RUNNER_TEMP/artifacts/$name" - done - - - name: Read PR metadata - id: meta - shell: bash - run: | - DIR="$RUNNER_TEMP/artifacts/pr-meta" - if [ ! -f "$DIR/pr_number" ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "::warning::pr_number artifact missing — skipping report" - exit 0 - fi - - # Validate PR number is a positive integer (artifact comes from - # untrusted fork code, so treat contents defensively). - PR_NUM=$(cat "$DIR/pr_number" | tr -d '[:space:]') - if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "::error::Invalid PR number in artifact: '$PR_NUM'" - exit 0 - fi - - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT" - # Validate job-result strings against known GitHub Actions values. - # Artifact contents come from the PR workflow (potentially untrusted - # fork code), so we whitelist to prevent newline injection into - # GITHUB_OUTPUT. - validate_result() { - local val - val=$(cat "$1" | tr -d '[:space:]') - case "$val" in - success|failure|cancelled|skipped) echo "$val" ;; - *) echo "unknown" ;; - esac - } - - echo "quality=$(validate_result "$DIR/quality_result")" >> "$GITHUB_OUTPUT" - echo "unit=$(validate_result "$DIR/unit_result")" >> "$GITHUB_OUTPUT" - echo "integration=$(validate_result "$DIR/integration_result")" >> "$GITHUB_OUTPUT" - - - name: Checkout (for vitest config) - if: steps.meta.outputs.skip != 'true' - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - sparse-checkout: gitnexus/vitest.config.ts - sparse-checkout-cone-mode: false - - # ── Fetch base branch coverage for delta reporting ─────────── - - name: Fetch base branch coverage - if: steps.meta.outputs.skip != 'true' - id: base-coverage - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 - with: - script: | - const fs = require('fs'); - const path = require('path'); - - // Find the latest successful CI run on main - const runs = await github.rest.actions.listWorkflowRuns({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'ci.yml', - branch: 'main', - status: 'success', - per_page: 1, - }); - - if (runs.data.workflow_runs.length === 0) { - core.setOutput('found', 'false'); - core.info('No successful main branch CI runs found'); - return; - } - - const mainRunId = runs.data.workflow_runs[0].id; - const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: mainRunId, - }); - - const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports'); - if (!testReports) { - core.setOutput('found', 'false'); - core.info('No test-reports artifact on main branch'); - return; - } - - const zip = await github.rest.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: testReports.id, - archive_format: 'zip', - }); - - const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage'); - fs.mkdirSync(dest, { recursive: true }); - fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data)); - core.setOutput('found', 'true'); - core.setOutput('dir', dest); - - - name: Extract base coverage - if: steps.meta.outputs.skip != 'true' && steps.base-coverage.outputs.found == 'true' - shell: bash - run: | - cd "${{ steps.base-coverage.outputs.dir }}" - unzip -o base.zip -d base - - # ── Merge coverage from unit + integration ───────────────────── - - name: Setup Node.js - if: steps.meta.outputs.skip != 'true' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 20 - - - name: Install coverage merge tools - if: steps.meta.outputs.skip != 'true' - run: npm install --no-save istanbul-lib-coverage istanbul-lib-report istanbul-reports - - - name: Merge coverage reports - if: steps.meta.outputs.skip != 'true' - id: coverage - shell: bash - run: | - DIR="$RUNNER_TEMP/artifacts" - UNIT_COV=$(find "$DIR/test-reports" -name "coverage-final.json" -type f 2>/dev/null | head -1) - INTEG_COV=$(find "$DIR/integration-reports" -name "coverage-final.json" -type f 2>/dev/null | head -1) - MERGED_DIR="$RUNNER_TEMP/merged-coverage" - mkdir -p "$MERGED_DIR" - - if [ -n "$UNIT_COV" ] && [ -n "$INTEG_COV" ]; then - echo "has_merged=true" >> "$GITHUB_OUTPUT" - # Merge using Node.js + istanbul-lib-coverage. - # Paths are passed via env vars to avoid shell interpolation - # inside the script string. - UNIT_COV_PATH="$UNIT_COV" \ - INTEG_COV_PATH="$INTEG_COV" \ - MERGED_OUT_DIR="$MERGED_DIR" \ - node -e " - const libCoverage = require('istanbul-lib-coverage'); - const libReport = require('istanbul-lib-report'); - const reports = require('istanbul-reports'); - const fs = require('fs'); - - const map = libCoverage.createCoverageMap({}); - map.merge(JSON.parse(fs.readFileSync(process.env.UNIT_COV_PATH, 'utf8'))); - map.merge(JSON.parse(fs.readFileSync(process.env.INTEG_COV_PATH, 'utf8'))); - - const context = libReport.createContext({ - coverageMap: map, - dir: process.env.MERGED_OUT_DIR, - }); - reports.create('json-summary').execute(context); - console.log('Merged coverage written to ' + process.env.MERGED_OUT_DIR + '/coverage-summary.json'); - " - elif [ -n "$UNIT_COV" ]; then - echo "has_merged=false" >> "$GITHUB_OUTPUT" - echo "::warning::Integration coverage not found — using unit coverage only" - else - echo "has_merged=false" >> "$GITHUB_OUTPUT" - echo "::warning::No coverage data found" - fi - - - name: Build report - if: steps.meta.outputs.skip != 'true' - id: report - shell: bash - env: - QUALITY: ${{ steps.meta.outputs.quality }} - UNIT: ${{ steps.meta.outputs.unit }} - INTEG: ${{ steps.meta.outputs.integration }} - HAS_MERGED: ${{ steps.coverage.outputs.has_merged }} - BASE_FOUND: ${{ steps.base-coverage.outputs.found }} - BASE_DIR: ${{ steps.base-coverage.outputs.dir }} - RUN_URL: ${{ github.event.workflow_run.html_url }} - run: | - DIR="$RUNNER_TEMP/artifacts" - MERGED_DIR="$RUNNER_TEMP/merged-coverage" - - # ── Helper: read coverage summary into prefixed vars ── - read_cov() { - local prefix=$1 file=$2 - if [ -n "$file" ] && [ -f "$file" ]; then - local val - val=$(jq -r '.total.statements.pct // "N/A"' "$file" 2>/dev/null) || val="N/A" - printf -v "${prefix}_STMTS" '%s' "$val" - val=$(jq -r '.total.branches.pct // "N/A"' "$file" 2>/dev/null) || val="N/A" - printf -v "${prefix}_BRANCH" '%s' "$val" - val=$(jq -r '.total.functions.pct // "N/A"' "$file" 2>/dev/null) || val="N/A" - printf -v "${prefix}_FUNCS" '%s' "$val" - val=$(jq -r '.total.lines.pct // "N/A"' "$file" 2>/dev/null) || val="N/A" - printf -v "${prefix}_LINES" '%s' "$val" - val=$(jq -r '"\(.total.statements.covered)/\(.total.statements.total)"' "$file" 2>/dev/null) || val="" - printf -v "${prefix}_STMTS_COV" '%s' "$val" - val=$(jq -r '"\(.total.branches.covered)/\(.total.branches.total)"' "$file" 2>/dev/null) || val="" - printf -v "${prefix}_BRANCH_COV" '%s' "$val" - val=$(jq -r '"\(.total.functions.covered)/\(.total.functions.total)"' "$file" 2>/dev/null) || val="" - printf -v "${prefix}_FUNCS_COV" '%s' "$val" - val=$(jq -r '"\(.total.lines.covered)/\(.total.lines.total)"' "$file" 2>/dev/null) || val="" - printf -v "${prefix}_LINES_COV" '%s' "$val" - return 0 - else - printf -v "${prefix}_STMTS" '%s' "N/A" - printf -v "${prefix}_BRANCH" '%s' "N/A" - printf -v "${prefix}_FUNCS" '%s' "N/A" - printf -v "${prefix}_LINES" '%s' "N/A" - printf -v "${prefix}_STMTS_COV" '%s' "" - printf -v "${prefix}_BRANCH_COV" '%s' "" - printf -v "${prefix}_FUNCS_COV" '%s' "" - printf -v "${prefix}_LINES_COV" '%s' "" - return 1 - fi - } - - # ── Read all coverage reports ── - UNIT_SUMMARY=$(find "$DIR/test-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1) - INTEG_SUMMARY=$(find "$DIR/integration-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1) - MERGED_SUMMARY="$MERGED_DIR/coverage-summary.json" - - read_cov "U" "$UNIT_SUMMARY" - HAS_UNIT=$? - read_cov "I" "$INTEG_SUMMARY" - HAS_INTEG=$? - read_cov "M" "$MERGED_SUMMARY" - - # ── Read base branch coverage (main) ── - BASE_SUMMARY="" - if [ "$BASE_FOUND" = "true" ] && [ -n "$BASE_DIR" ]; then - BASE_SUMMARY=$(find "$BASE_DIR/base" -name "coverage-summary.json" -type f 2>/dev/null | head -1) - fi - read_cov "B" "$BASE_SUMMARY" - - # ── Locate test results ── - RESULTS_FILE=$(find "$DIR/test-reports" -name "test-results.json" -type f 2>/dev/null | head -1) - INTEG_RESULTS=$(find "$DIR/integration-reports" -name "integration-results.json" -type f 2>/dev/null | head -1) - - if [ -n "$RESULTS_FILE" ]; then - U_TOTAL=$(jq -r '.numTotalTests' "$RESULTS_FILE" 2>/dev/null || echo 0) - U_PASSED=$(jq -r '.numPassedTests' "$RESULTS_FILE" 2>/dev/null || echo 0) - U_FAILED=$(jq -r '.numFailedTests' "$RESULTS_FILE" 2>/dev/null || echo 0) - U_SKIPPED=$(jq -r '.numPendingTests' "$RESULTS_FILE" 2>/dev/null || echo 0) - U_SUITES=$(jq -r '.numTotalTestSuites' "$RESULTS_FILE" 2>/dev/null || echo 0) - U_DURATION=$(jq -r '((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor' "$RESULTS_FILE" 2>/dev/null || echo 0) - else - U_TOTAL=0; U_PASSED=0; U_FAILED=0; U_SKIPPED=0; U_SUITES=0; U_DURATION=0 - fi - - if [ -n "$INTEG_RESULTS" ]; then - I_TOTAL=$(jq -r '.numTotalTests' "$INTEG_RESULTS" 2>/dev/null || echo 0) - I_PASSED=$(jq -r '.numPassedTests' "$INTEG_RESULTS" 2>/dev/null || echo 0) - I_FAILED=$(jq -r '.numFailedTests' "$INTEG_RESULTS" 2>/dev/null || echo 0) - I_SKIPPED=$(jq -r '.numPendingTests' "$INTEG_RESULTS" 2>/dev/null || echo 0) - I_SUITES=$(jq -r '.numTotalTestSuites' "$INTEG_RESULTS" 2>/dev/null || echo 0) - I_DURATION=$(jq -r '((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor' "$INTEG_RESULTS" 2>/dev/null || echo 0) - else - I_TOTAL=0; I_PASSED=0; I_FAILED=0; I_SKIPPED=0; I_SUITES=0; I_DURATION=0 - fi - - # ── Sum test results ── - TOTAL=$((U_TOTAL + I_TOTAL)) - PASSED=$((U_PASSED + I_PASSED)) - FAILED=$((U_FAILED + I_FAILED)) - SKIPPED=$((U_SKIPPED + I_SKIPPED)) - SUITES=$((U_SUITES + I_SUITES)) - - # ── Status helpers ── - status_icon() { - case "$1" in - success) echo "✅" ;; - failure) echo "❌" ;; - cancelled) echo "⏭️" ;; - *) echo "❓" ;; - esac - } - - cov_delta() { - local pct=$1 base=$2 - if [ "$pct" = "N/A" ] || [ "$base" = "N/A" ]; then echo "—"; return; fi - local diff - diff=$(awk "BEGIN { printf \"%.1f\", $pct - $base }") - if [ "$(awk "BEGIN { print ($pct > $base) ? 1 : 0 }")" = "1" ]; then - echo "📈 +${diff}" - elif [ "$(awk "BEGIN { print ($pct < $base) ? 1 : 0 }")" = "1" ]; then - echo "📉 ${diff}" - else - echo "= ${diff}" - fi - } - - cov_bar() { - local pct=$1 base=$2 - if [ "$pct" = "N/A" ]; then echo "—"; return; fi - local filled - filled=$(awk "BEGIN { printf \"%d\", $pct / 5 }") - (( filled < 0 )) && filled=0 - (( filled > 20 )) && filled=20 - local empty=$((20 - filled)) - local bar="" - for ((i=0; i= base (or base unavailable), red if dropped - if [ "$base" = "N/A" ] || [ "$(awk "BEGIN { print ($pct >= $base) ? 1 : 0 }")" = "1" ]; then - echo "🟢 ${bar}" - else - echo "🔴 ${bar}" - fi - } - - # ── Overall status ── - if [[ "$QUALITY" == "success" && "$UNIT" == "success" && "$INTEG" == "success" ]]; then - OVERALL="✅ **All checks passed**" - else - OVERALL="❌ **Some checks failed**" - fi - - # ── Build markdown ── - { - echo "body</dev/null; then - echo "### Test Results" - echo "" - echo "| Suite | Tests | Passed | Failed | Skipped | Duration |" - echo "|-------|-------|--------|--------|---------|----------|" - if [ "$U_TOTAL" -gt 0 ] 2>/dev/null; then - echo "| Unit | ${U_TOTAL} | ${U_PASSED} | ${U_FAILED} | ${U_SKIPPED} | ${U_DURATION}s |" - fi - if [ "$I_TOTAL" -gt 0 ] 2>/dev/null; then - echo "| Integration | ${I_TOTAL} | ${I_PASSED} | ${I_FAILED} | ${I_SKIPPED} | ${I_DURATION}s |" - fi - echo "| **Total** | **${TOTAL}** | **${PASSED}** | **${FAILED}** | **${SKIPPED}** | **$((U_DURATION + I_DURATION))s** |" - echo "" - - if [ "$FAILED" = "0" ]; then - echo "✅ All **${PASSED}** tests passed" - else - echo "❌ **${FAILED}** failed / **${PASSED}** passed" - fi - if [ "$SKIPPED" != "0" ]; then - echo "" - echo "
" - echo "${SKIPPED} test(s) skipped — expand for details" - echo "" - # Extract skipped test names from integration results - if [ -n "$INTEG_RESULTS" ] && [ "$I_SKIPPED" -gt 0 ] 2>/dev/null; then - echo "**Integration:**" - jq -r ' - .testResults[] - | .assertionResults[]? - | select(.status == "pending" or .status == "skipped") - | "- \(.ancestorTitles | join(" > ")) > \(.title)" - ' "$INTEG_RESULTS" 2>/dev/null || echo "- _(unable to parse skipped test details)_" - fi - # Extract skipped test names from unit results - if [ -n "$RESULTS_FILE" ] && [ "$U_SKIPPED" -gt 0 ] 2>/dev/null; then - echo "" - echo "**Unit:**" - jq -r ' - .testResults[] - | .assertionResults[]? - | select(.status == "pending" or .status == "skipped") - | "- \(.ancestorTitles | join(" > ")) > \(.title)" - ' "$RESULTS_FILE" 2>/dev/null || echo "- _(unable to parse skipped test details)_" - fi - echo "" - echo "
" - fi - echo "" - fi - - # ── Coverage table helper ── - cov_table() { - local label=$1 s=$2 b=$3 f=$4 l=$5 sc=$6 bc=$7 fc=$8 lc=$9 - shift 9 - local bs=$1 bb=$2 bf=$3 bl=$4 - echo "#### ${label}" - echo "" - echo "| Metric | Coverage | Covered | Base | Delta | Status |" - echo "|--------|----------|---------|------|-------|--------|" - echo "| Statements | **${s}%** | ${sc} | ${bs}% | $(cov_delta "$s" "$bs") | $(cov_bar "$s" "$bs") |" - echo "| Branches | **${b}%** | ${bc} | ${bb}% | $(cov_delta "$b" "$bb") | $(cov_bar "$b" "$bb") |" - echo "| Functions | **${f}%** | ${fc} | ${bf}% | $(cov_delta "$f" "$bf") | $(cov_bar "$f" "$bf") |" - echo "| Lines | **${l}%** | ${lc} | ${bl}% | $(cov_delta "$l" "$bl") | $(cov_bar "$l" "$bl") |" - echo "" - } - - if [ "$M_STMTS" != "N/A" ]; then - echo "### Code Coverage" - echo "" - cov_table "Combined (Unit + Integration)" \ - "$M_STMTS" "$M_BRANCH" "$M_FUNCS" "$M_LINES" \ - "$M_STMTS_COV" "$M_BRANCH_COV" "$M_FUNCS_COV" "$M_LINES_COV" \ - "$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES" - - echo "
" - echo "Coverage breakdown by test suite" - echo "" - if [ "$U_STMTS" != "N/A" ]; then - cov_table "Unit Tests" \ - "$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \ - "$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \ - "$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES" - fi - if [ "$I_STMTS" != "N/A" ]; then - cov_table "Integration Tests" \ - "$I_STMTS" "$I_BRANCH" "$I_FUNCS" "$I_LINES" \ - "$I_STMTS_COV" "$I_BRANCH_COV" "$I_FUNCS_COV" "$I_LINES_COV" \ - "$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES" - fi - echo "
" - echo "" - elif [ "$U_STMTS" != "N/A" ]; then - echo "### Code Coverage (Unit only)" - echo "" - cov_table "Unit Tests" \ - "$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \ - "$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \ - "$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES" - else - echo "### Code Coverage" - echo "" - echo "⚠️ Coverage data unavailable - check the [unit test job](${RUN_URL}) for details." - echo "" - fi - - echo "---" - echo "📋 [View full run](${RUN_URL}) · Generated by CI" - echo "GITNEXUS_CI_REPORT_EOF_7f3a" - } >> "$GITHUB_OUTPUT" - - - name: Comment on PR - if: steps.meta.outputs.skip != 'true' - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2 - with: - header: ci-report - number: ${{ steps.meta.outputs.pr_number }} - message: ${{ steps.report.outputs.body }} diff --git a/.github/workflows/ci-unit-tests.yml b/.github/workflows/ci-tests.yml similarity index 81% rename from .github/workflows/ci-unit-tests.yml rename to .github/workflows/ci-tests.yml index 686afd324..c48401bcd 100644 --- a/.github/workflows/ci-unit-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -1,20 +1,22 @@ -name: Unit Tests +name: Tests on: workflow_call: jobs: - unit-tests: - name: unit (ubuntu / coverage) + tests: + name: ubuntu / coverage runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: ./.github/actions/setup-gitnexus + with: + build: 'true' - - name: Run unit tests with coverage + - name: Run all tests with coverage run: >- - npx vitest run test/unit + npx vitest run --reporter=default --reporter=json --outputFile=test-results.json @@ -38,16 +40,18 @@ jobs: retention-days: 5 cross-platform: - name: unit (${{ matrix.os }}) + name: ${{ matrix.os }} strategy: fail-fast: false matrix: # Ubuntu already covered by the coverage job above os: [windows-latest, macos-latest] runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 25 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: ./.github/actions/setup-gitnexus - - run: npx vitest run test/unit + with: + build: 'true' + - run: npx vitest run working-directory: gitnexus diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea9c8ca93..e2a3df818 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,10 +16,10 @@ concurrency: # ── Reusable workflow orchestration ───────────────────────────────── # Each concern lives in its own workflow file for maintainability: # ci-quality.yml — typecheck (tsc --noEmit) -# ci-unit-tests.yml — unit tests with coverage + cross-platform -# ci-integration.yml — integration test matrix (3 OS x 4 groups) +# ci-tests.yml — all tests with coverage (ubuntu) + cross-platform # -# Shared setup is DRY via .github/actions/setup-gitnexus composite action. +# The PR report runs inline (not via workflow_run) so it uses the +# PR branch's code instead of main's — avoids stale report templates. jobs: quality: @@ -27,56 +27,16 @@ jobs: permissions: contents: read - unit-tests: - uses: ./.github/workflows/ci-unit-tests.yml + tests: + uses: ./.github/workflows/ci-tests.yml permissions: contents: read - integration: - uses: ./.github/workflows/ci-integration.yml - with: - collect-coverage: ${{ github.event_name == 'pull_request' }} - permissions: - contents: read - - # ── Save PR metadata for the reporting workflow ───────────────── - # The ci-report.yml workflow (triggered by workflow_run) needs the - # PR number and job results to post a comment. We save them as an - # artifact because workflow_run context doesn't reliably carry PR - # info for fork PRs. - save-pr-meta: - name: Save PR Metadata - if: always() && github.event_name == 'pull_request' - needs: [quality, unit-tests, integration] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Write metadata - shell: bash - env: - PR_NUMBER: ${{ github.event.number }} - QUALITY: ${{ needs.quality.result }} - UNIT: ${{ needs.unit-tests.result }} - INTEG: ${{ needs.integration.result }} - run: | - mkdir -p pr-meta - echo "$PR_NUMBER" > pr-meta/pr_number - echo "$QUALITY" > pr-meta/quality_result - echo "$UNIT" > pr-meta/unit_result - echo "$INTEG" > pr-meta/integration_result - - - name: Upload PR metadata - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: pr-meta - path: pr-meta/ - retention-days: 1 - # ── Unified CI gate ────────────────────────────────────────────── # Single required check for branch protection. ci-status: name: CI Gate - needs: [quality, unit-tests, integration] + needs: [quality, tests] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -85,15 +45,270 @@ jobs: shell: bash env: QUALITY: ${{ needs.quality.result }} - UNIT: ${{ needs.unit-tests.result }} - INTEG: ${{ needs.integration.result }} + TESTS: ${{ needs.tests.result }} run: | - echo "Quality: $QUALITY" - echo "Unit Tests: $UNIT" - echo "Integration: $INTEG" + echo "Quality: $QUALITY" + echo "Tests: $TESTS" if [[ "$QUALITY" != "success" ]] || - [[ "$UNIT" != "success" ]] || - [[ "$INTEG" != "success" ]]; then + [[ "$TESTS" != "success" ]]; then echo "::error::One or more CI jobs failed" exit 1 fi + + # ── PR Report ──────────────────────────────────────────────────── + # Posts a sticky comment with test results, coverage, and + # per-platform status. Runs inline so it uses the PR branch's + # report template (not main's stale version via workflow_run). + pr-report: + name: PR Report + if: always() && github.event_name == 'pull_request' + needs: [quality, tests] + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + pull-requests: write + timeout-minutes: 5 + steps: + - name: Download test reports + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: test-reports + path: ${{ runner.temp }}/test-reports + continue-on-error: true + + - name: Fetch cross-platform job results + id: jobs + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + script: | + const jobs = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + per_page: 50, + }); + + const results = {}; + for (const job of jobs.data.jobs) { + if (job.name.includes('ubuntu')) results.ubuntu = job.conclusion || 'pending'; + else if (job.name.includes('windows')) results.windows = job.conclusion || 'pending'; + else if (job.name.includes('macos')) results.macos = job.conclusion || 'pending'; + } + core.setOutput('ubuntu', results.ubuntu || 'unknown'); + core.setOutput('windows', results.windows || 'unknown'); + core.setOutput('macos', results.macos || 'unknown'); + + - name: Fetch base branch coverage + id: base-coverage + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const runs = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'ci.yml', + branch: 'main', + status: 'success', + per_page: 1, + }); + + if (runs.data.workflow_runs.length === 0) { + core.setOutput('found', 'false'); + return; + } + + const mainRunId = runs.data.workflow_runs[0].id; + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: mainRunId, + }); + + const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports'); + if (!testReports) { + core.setOutput('found', 'false'); + return; + } + + const zip = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: testReports.id, + archive_format: 'zip', + }); + + const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage'); + fs.mkdirSync(dest, { recursive: true }); + fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data)); + core.setOutput('found', 'true'); + core.setOutput('dir', dest); + + - name: Extract base coverage + if: steps.base-coverage.outputs.found == 'true' + shell: bash + run: | + cd "${{ steps.base-coverage.outputs.dir }}" + unzip -o base.zip -d base + + - name: Build and post report + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + env: + QUALITY: ${{ needs.quality.result }} + TESTS: ${{ needs.tests.result }} + UBUNTU: ${{ steps.jobs.outputs.ubuntu }} + WINDOWS: ${{ steps.jobs.outputs.windows }} + MACOS: ${{ steps.jobs.outputs.macos }} + BASE_FOUND: ${{ steps.base-coverage.outputs.found }} + BASE_DIR: ${{ steps.base-coverage.outputs.dir }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const icon = (s) => ({ success: '✅', failure: '❌', cancelled: '⏭️' }[s] || '❓'); + const temp = process.env.RUNNER_TEMP; + + // ── Read coverage ── + function readCov(dir) { + const out = { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A', + stmtsCov: '', branchCov: '', funcsCov: '', linesCov: '' }; + try { + const files = require('child_process') + .execSync(`find "${dir}" -name coverage-summary.json -type f`, { encoding: 'utf8' }) + .trim().split('\n').filter(Boolean); + if (!files.length) return out; + const d = JSON.parse(fs.readFileSync(files[0], 'utf8')).total; + out.stmts = d.statements.pct; out.branch = d.branches.pct; + out.funcs = d.functions.pct; out.lines = d.lines.pct; + out.stmtsCov = `${d.statements.covered}/${d.statements.total}`; + out.branchCov = `${d.branches.covered}/${d.branches.total}`; + out.funcsCov = `${d.functions.covered}/${d.functions.total}`; + out.linesCov = `${d.lines.covered}/${d.lines.total}`; + } catch {} + return out; + } + + const cov = readCov(path.join(temp, 'test-reports')); + const base = process.env.BASE_FOUND === 'true' + ? readCov(path.join(process.env.BASE_DIR, 'base')) + : { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A' }; + + // ── Read test results ── + let total = 0, passed = 0, failed = 0, skipped = 0, suites = 0, duration = '0s'; + let skippedTests = []; + try { + const files = require('child_process') + .execSync(`find "${path.join(temp, 'test-reports')}" -name test-results.json -type f`, { encoding: 'utf8' }) + .trim().split('\n').filter(Boolean); + if (files.length) { + const r = JSON.parse(fs.readFileSync(files[0], 'utf8')); + total = r.numTotalTests || 0; + passed = r.numPassedTests || 0; + failed = r.numFailedTests || 0; + skipped = r.numPendingTests || 0; + suites = r.numTotalTestSuites || 0; + const durS = Math.floor((Math.max(...r.testResults.map(t => t.endTime)) - r.startTime) / 1000); + duration = durS >= 60 ? `${Math.floor(durS / 60)}m ${durS % 60}s` : `${durS}s`; + // Collect skipped test names + for (const suite of r.testResults) { + for (const t of (suite.assertionResults || [])) { + if (t.status === 'pending' || t.status === 'skipped') { + skippedTests.push(`- ${t.ancestorTitles.join(' > ')} > ${t.title}`); + } + } + } + } + } catch {} + + // ── Coverage delta ── + function delta(pct, basePct) { + if (pct === 'N/A' || basePct === 'N/A') return '—'; + const d = (pct - basePct).toFixed(1); + if (d > 0) return `📈 +${d}%`; + if (d < 0) return `📉 ${d}%`; + return '='; + } + + // ── Build markdown ── + const { QUALITY, TESTS, UBUNTU, WINDOWS, MACOS } = process.env; + const overall = (QUALITY === 'success' && TESTS === 'success') + ? '✅ **All checks passed**' : '❌ **Some checks failed**'; + const sha = context.sha.slice(0, 7); + + let body = `## CI Report\n\n${overall}   \`${sha}\`\n\n`; + + body += `### Pipeline\n\n`; + body += `| Stage | Status | Ubuntu | Windows | macOS |\n`; + body += `|-------|--------|--------|---------|-------|\n`; + body += `| Typecheck | ${icon(QUALITY)} \`${QUALITY}\` | — | — | — |\n`; + body += `| Tests | ${icon(TESTS)} \`${TESTS}\` | ${icon(UBUNTU)} | ${icon(WINDOWS)} | ${icon(MACOS)} |\n\n`; + + if (total > 0) { + body += `### Tests\n\n`; + body += `| Metric | Value |\n|--------|-------|\n`; + body += `| Total | **${total}** |\n`; + body += `| Passed | **${passed}** |\n`; + if (failed > 0) body += `| Failed | **${failed}** |\n`; + if (skipped > 0) body += `| Skipped | ${skipped} |\n`; + body += `| Files | ${suites} |\n`; + body += `| Duration | ${duration} |\n\n`; + + if (failed === 0) { + body += `✅ All **${passed}** tests passed across **${suites}** files\n`; + } else { + body += `❌ **${failed}** failed / **${passed}** passed\n`; + } + + if (skippedTests.length > 0) { + body += `\n
\n${skipped} test(s) skipped\n\n`; + body += skippedTests.join('\n') + '\n\n
\n'; + } + body += '\n'; + } + + if (cov.stmts !== 'N/A') { + body += `### Coverage\n\n`; + body += `| Metric | Coverage | Covered | Base (main) | Delta |\n`; + body += `|--------|----------|---------|-------------|-------|\n`; + body += `| Statements | **${cov.stmts}%** | ${cov.stmtsCov} | ${base.stmts}% | ${delta(cov.stmts, base.stmts)} |\n`; + body += `| Branches | **${cov.branch}%** | ${cov.branchCov} | ${base.branch}% | ${delta(cov.branch, base.branch)} |\n`; + body += `| Functions | **${cov.funcs}%** | ${cov.funcsCov} | ${base.funcs}% | ${delta(cov.funcs, base.funcs)} |\n`; + body += `| Lines | **${cov.lines}%** | ${cov.linesCov} | ${base.lines}% | ${delta(cov.lines, base.lines)} |\n\n`; + } else { + body += `### Coverage\n\n⚠️ Coverage data unavailable — check the [test job](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for details.\n\n`; + } + + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + body += `---\n📋 [Full run](${runUrl}) · Coverage from Ubuntu · Generated by CI`; + + // ── Post sticky comment ── + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const marker = ''; + const existing = comments.find(c => c.body?.includes(marker)); + const fullBody = marker + '\n' + body; + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: fullBody, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: fullBody, + }); + } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 13d00954e..3da468208 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,7 @@ jobs: uses: ./.github/workflows/ci.yml permissions: contents: read + actions: read pull-requests: write publish: diff --git a/AGENTS.md b/AGENTS.md index 713800cb9..58eead9d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 symbols, 4935 relationships, 157 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (2094 symbols, 4982 relationships, 159 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -17,7 +17,7 @@ This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 sym 1. `gitnexus_query({query: ""})` — find execution flows related to the issue 2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/feat-phase7-type-resolution/process/{processName}` — trace the full execution flow step by step +3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step 4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed ## When Refactoring @@ -56,10 +56,10 @@ This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 sym | Resource | Use for | |----------|---------| -| `gitnexus://repo/feat-phase7-type-resolution/context` | Codebase overview, check index freshness | -| `gitnexus://repo/feat-phase7-type-resolution/clusters` | All functional areas | -| `gitnexus://repo/feat-phase7-type-resolution/processes` | All execution flows | -| `gitnexus://repo/feat-phase7-type-resolution/process/{name}` | Step-by-step execution trace | +| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | +| `gitnexus://repo/GitNexus/clusters` | All functional areas | +| `gitnexus://repo/GitNexus/processes` | All execution flows | +| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing diff --git a/CLAUDE.md b/CLAUDE.md index 713800cb9..58eead9d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 symbols, 4935 relationships, 157 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (2094 symbols, 4982 relationships, 159 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -17,7 +17,7 @@ This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 sym 1. `gitnexus_query({query: ""})` — find execution flows related to the issue 2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/feat-phase7-type-resolution/process/{processName}` — trace the full execution flow step by step +3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step 4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed ## When Refactoring @@ -56,10 +56,10 @@ This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 sym | Resource | Use for | |----------|---------| -| `gitnexus://repo/feat-phase7-type-resolution/context` | Codebase overview, check index freshness | -| `gitnexus://repo/feat-phase7-type-resolution/clusters` | All functional areas | -| `gitnexus://repo/feat-phase7-type-resolution/processes` | All execution flows | -| `gitnexus://repo/feat-phase7-type-resolution/process/{name}` | Step-by-step execution trace | +| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | +| `gitnexus://repo/GitNexus/clusters` | All functional areas | +| `gitnexus://repo/GitNexus/processes` | All execution flows | +| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 25263b0b4..f20e0b35d 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -33,7 +33,7 @@ "graphology-layout-noverlap": "^0.4.2", "isomorphic-git": "^1.36.1", "jszip": "^3.10.1", - "@ladybugdb/wasm-core": "^0.15.1", + "@ladybugdb/wasm-core": "^0.15.2", "langchain": "^1.2.10", "lru-cache": "^11.2.4", "lucide-react": "^0.562.0", diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index c7bcf67c4..ad265edb0 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to GitNexus will be documented in this file. +## [1.4.7] - 2026-03-19 + +### Added +- **Phase 8 field/property type resolution** — ACCESSES edges with `declaredType` for field reads/writes (#354) +- **Phase 9 return-type variable binding** — call-result variable binding across 11 languages (#379) + - `extractPendingAssignment` in per-language type extractors captures `let x = getUser()` patterns + - Unified fixpoint loop resolves variable types from function return types after initial walk + - Field access on call-result variables: `user.name` resolves `name` via return type's class definition + - Method-call-result chaining: `user.getProfile().bio` resolves through intermediate return types + - 22 new test fixtures covering call-result and method-chain binding across all supported languages + - Integration tests added for all 10 language resolver suites +- **ACCESSES edge type** with read/write field access tracking (#372) +- **Python `enumerate()` for-loop support** with nested tuple patterns (#356) +- **MCP tool/resource descriptions** updated to reflect Phase 9 ACCESSES edge semantics and `declaredType` property + +### Fixed +- **mcp**: server crashes under parallel tool calls (#326, #349) +- **parsing**: undefined error on languages missing from call routers (#364) +- **web**: add missing Kotlin entries to `Record` maps +- **rust**: `await` expression unwrapping in `extractPendingAssignment` for async call-result binding +- **tests**: update property edge and write access expectations across multiple language tests +- **docs**: corrected stale "single-pass" claims in type-resolution-system.md to reflect walk+fixpoint architecture + +### Changed +- **Upgrade `@ladybugdb/core` to 0.15.2** and remove segfault workarounds (#374) +- **type-resolution-roadmap.md** overhauled — completed phases condensed to summaries, Phases 10–14 added with full engineering specs + ## [1.4.6] - 2026-03-18 ### Added diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 656dbff75..dbc672aaa 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,17 +1,17 @@ { "name": "gitnexus", - "version": "1.4.0", + "version": "1.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.4.0", + "version": "1.4.7", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@huggingface/transformers": "^3.0.0", - "@ladybugdb/core": "^0.15.1", + "@ladybugdb/core": "^0.15.2", "@modelcontextprotocol/sdk": "^1.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", @@ -36,7 +36,6 @@ "tree-sitter-python": "^0.21.0", "tree-sitter-ruby": "^0.23.1", "tree-sitter-rust": "^0.21.0", - "tree-sitter-swift": "^0.6.0", "tree-sitter-typescript": "^0.21.0", "uuid": "^13.0.0" }, @@ -1151,16 +1150,74 @@ } }, "node_modules/@ladybugdb/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.1.tgz", - "integrity": "sha512-a+jhzIlS2+57Y2YWXlta7Dq5A3577dQ8YO7DzPCFZxozeiGIZn0K9v0ROO+ws4PW9BwuQYI5BXQxTEtaa1Otlg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.2.tgz", + "integrity": "sha512-DpseEj9CM/QTV0z+rvBk6nB2mOoG4GVhnKKLiXChGTVddgpH6R/Pv2YiDZB7rUIDnFpJxVQNbQaYEkZ7i1h1KA==", "hasInstallScript": true, "license": "MIT", "dependencies": { "cmake-js": "^8.0.0", "node-addon-api": "^6.0.0" + }, + "optionalDependencies": { + "@ladybugdb/core-darwin-arm64": "0.15.2", + "@ladybugdb/core-linux-arm64": "0.15.2", + "@ladybugdb/core-linux-x64": "0.15.2", + "@ladybugdb/core-win32-x64": "0.15.2" } }, + "node_modules/@ladybugdb/core-darwin-arm64": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.2.tgz", + "integrity": "sha512-ifLyUTPzlh2zR1IqkUT5AfldX+X4zfWBzwakmGTgMPxyrEiRNDwUKfnNxHeLQ/TJTOS/nfzYxxLLt5CZf2/FhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ladybugdb/core-linux-arm64": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.2.tgz", + "integrity": "sha512-9537UbHOiuSr/BaTfjcoBsHxEKF4uEXWyXEjm/AQCGXQFocX3nQDVNDYJzuDYjKZ51oJRJ0oSuesAStOCwjolA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ladybugdb/core-linux-x64": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.2.tgz", + "integrity": "sha512-1+xLoapjbMQzDHxcPpMPt8Suuvms3nhOIZFNGPDcWz90NwEmLAjWNFQZZHeg8DRz0vG2j8UY292bvGORVcxs8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ladybugdb/core-win32-x64": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.2.tgz", + "integrity": "sha512-+LIJVKBNSrf2bGruJO4l0ihrLKZkv5+lNitK8xc3T7gC1bcc+FaYtRMvlgZP6Qh2rEHAjqfbaSKVrdw0M2EXTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@ladybugdb/core/node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index d1596ba64..f80d5687d 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.4.6", + "version": "1.4.7", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", @@ -39,9 +39,9 @@ "scripts": { "build": "tsc", "dev": "tsx watch src/cli/index.ts", - "test": "vitest run test/unit", + "test": "vitest run", + "test:unit": "vitest run test/unit", "test:integration": "vitest run test/integration", - "test:all": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "prepare": "npm run build", @@ -59,7 +59,7 @@ "graphology": "^0.25.4", "graphology-indices": "^0.17.0", "graphology-utils": "^2.3.0", - "@ladybugdb/core": "^0.15.1", + "@ladybugdb/core": "^0.15.2", "ignore": "^7.0.5", "lru-cache": "^11.0.0", "mnemonist": "^0.39.0", diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 0e4a2edaa..ce97f468c 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -2,7 +2,7 @@ import type { SyntaxNode } from './utils.js'; import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES, isBuiltInOrNoise } from './utils.js'; import { SupportedLanguages } from '../../config/supported-languages.js'; import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js'; -import type { ClassNameLookup, ReturnTypeLookup, ForLoopExtractorContext } from './type-extractors/types.js'; +import type { ClassNameLookup, ReturnTypeLookup, ForLoopExtractorContext, PendingAssignment } from './type-extractors/types.js'; import { extractSimpleTypeName, extractVarName, stripNullable, extractReturnTypeName } from './type-extractors/shared.js'; import type { SymbolTable } from './symbol-table.js'; @@ -364,6 +364,47 @@ const SKIP_SUBTREE_TYPES = new Set([ 'regex', 'regex_pattern', ]); +const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']); + +/** Resolve a field's declared type given a receiver variable and field name. + * Uses SymbolTable to find the class nodeId for the receiver's type, then + * looks up the field via the eagerly-populated fieldByOwner index. */ +const resolveFieldType = ( + receiver: string, field: string, + scopeEnv: ReadonlyMap, symbolTable?: SymbolTable, +): string | undefined => { + if (!symbolTable) return undefined; + const receiverType = scopeEnv.get(receiver); + if (!receiverType) return undefined; + const classDefs = symbolTable.lookupFuzzy(receiverType) + .filter(d => CLASS_LIKE_TYPES.has(d.type)); + if (classDefs.length !== 1) return undefined; + const fieldDef = symbolTable.lookupFieldByOwner(classDefs[0].nodeId, field); + if (!fieldDef?.declaredType) return undefined; + return extractReturnTypeName(fieldDef.declaredType); +}; + +/** Resolve a method's return type given a receiver variable and method name. + * Uses SymbolTable to find class nodeIds for the receiver's type, then + * looks up the method via lookupFuzzyCallable filtered by ownerId. */ +const resolveMethodReturnType = ( + receiver: string, method: string, + scopeEnv: ReadonlyMap, symbolTable?: SymbolTable, +): string | undefined => { + if (!symbolTable) return undefined; + const receiverType = scopeEnv.get(receiver); + if (!receiverType) return undefined; + const classDefs = symbolTable.lookupFuzzy(receiverType) + .filter(d => CLASS_LIKE_TYPES.has(d.type)); + if (classDefs.length === 0) return undefined; + const classNodeIds = new Set(classDefs.map(d => d.nodeId)); + const methods = symbolTable.lookupFuzzyCallable(method) + .filter(d => d.ownerId && classNodeIds.has(d.ownerId)); + if (methods.length !== 1) return undefined; + if (!methods[0].returnType) return undefined; + return extractReturnTypeName(methods[0].returnType); +}; + export const buildTypeEnv = ( tree: { rootNode: SyntaxNode }, language: SupportedLanguages, @@ -403,12 +444,10 @@ export const buildTypeEnv = ( TYPED_PARAMETER_TYPES.forEach(t => interestingNodeTypes.add(t)); config.declarationNodeTypes.forEach(t => interestingNodeTypes.add(t)); config.forLoopNodeTypes?.forEach(t => interestingNodeTypes.add(t)); - // Tier 2: copy-propagation (`const b = a`) and call-result propagation (`const b = foo()`) - const pendingCopies: Array<{ scope: string; lhs: string; rhs: string }> = []; - // NOTE: Infrastructure-ready — no language extractor currently returns { kind: 'callResult' } - // from extractPendingAssignment. When one does, this array will bind variables to their - // function return types at TypeEnv build time. See PendingAssignment in types.ts. - const pendingCallResults: Array<{ scope: string; lhs: string; callee: string }> = []; + // Tier 2: unified fixpoint propagation — collects copy, callResult, fieldAccess, and + // methodCallResult items during walk(), then iterates until no new bindings are produced. + // Handles arbitrary-depth mixed chains: callResult → fieldAccess → methodCallResult → copy. + const pendingItems: Array<{ scope: string } & PendingAssignment> = []; // Maps `scope\0varName` → the type annotation AST node from the original declaration. // Allows pattern extractors to navigate back to the declaration's generic type arguments // (e.g., to extract T from Result for `if let Ok(x) = res`). @@ -611,11 +650,7 @@ export const buildTypeEnv = ( if (scopeEnv) { const pending = config.extractPendingAssignment(node, scopeEnv); if (pending) { - if (pending.kind === 'copy') { - pendingCopies.push({ scope, lhs: pending.lhs, rhs: pending.rhs }); - } else { - pendingCallResults.push({ scope, lhs: pending.lhs, callee: pending.callee }); - } + pendingItems.push({ scope, ...pending }); } } } @@ -641,28 +676,47 @@ export const buildTypeEnv = ( walk(tree.rootNode, FILE_SCOPE); - // Tier 2a: copy-propagation — `const b = a` where `a` has a known type from Tier 0/1. - // Multi-hop chains resolve when forward-declared (a→b→c in source order); - // reverse-order assignments are depth-1 only. No fixpoint iteration — - // this covers 95%+ of real-world patterns. - for (const { scope, lhs, rhs } of pendingCopies) { - const scopeEnv = env.get(scope); - if (!scopeEnv || scopeEnv.has(lhs)) continue; - const rhsType = scopeEnv.get(rhs) ?? env.get(FILE_SCOPE)?.get(rhs); - if (rhsType) scopeEnv.set(lhs, rhsType); - } + // Unified fixpoint propagation: iterate over ALL pending items (copy, callResult, + // fieldAccess, methodCallResult) until no new bindings are produced. + // Handles arbitrary-depth mixed chains: + // const user = getUser(); // callResult → User + // const addr = user.address; // fieldAccess → Address (depends on user) + // const city = addr.getCity(); // methodCallResult → City (depends on addr) + // const alias = city; // copy → City (depends on city) + // Data flow: SymbolTable (immutable) + scopeEnv → resolve → scopeEnv. + // Termination: finite entries, each bound at most once (first-writer-wins), max 10 iterations. + const MAX_FIXPOINT_ITERATIONS = 10; + const resolved = new Set(); + for (let iter = 0; iter < MAX_FIXPOINT_ITERATIONS; iter++) { + let changed = false; + for (let i = 0; i < pendingItems.length; i++) { + if (resolved.has(i)) continue; + const item = pendingItems[i]; + const scopeEnv = env.get(item.scope); + if (!scopeEnv || scopeEnv.has(item.lhs)) { resolved.add(i); continue; } - // Tier 2b: call-result propagation — `const b = foo()` where `foo` has a declared return type. - // Uses ReturnTypeLookup which is backed by SymbolTable.lookupFuzzyCallable. - // Conservative: only binds when exactly one callable matches (avoids overload ambiguity). - // NOTE: Currently dormant — no extractPendingAssignment implementation emits 'callResult' yet. - // The loop is structurally complete and will activate when any language extractor starts - // returning { kind: 'callResult', lhs, callee } from extractPendingAssignment. - for (const { scope, lhs, callee } of pendingCallResults) { - const scopeEnv = env.get(scope); - if (!scopeEnv || scopeEnv.has(lhs)) continue; - const typeName = returnTypeLookup.lookupReturnType(callee); - if (typeName) scopeEnv.set(lhs, typeName); + let typeName: string | undefined; + switch (item.kind) { + case 'callResult': + typeName = returnTypeLookup.lookupReturnType(item.callee); + break; + case 'copy': + typeName = scopeEnv.get(item.rhs) ?? env.get(FILE_SCOPE)?.get(item.rhs); + break; + case 'fieldAccess': + typeName = resolveFieldType(item.receiver, item.field, scopeEnv, symbolTable); + break; + case 'methodCallResult': + typeName = resolveMethodReturnType(item.receiver, item.method, scopeEnv, symbolTable); + break; + } + if (typeName) { + scopeEnv.set(item.lhs, typeName); + resolved.add(i); + changed = true; + } + } + if (!changed) break; } return { diff --git a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts index e7dacf057..d8c5448e8 100644 --- a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts @@ -171,7 +171,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => const declarator = node.childForFieldName('declarator'); if (!declarator || declarator.type !== 'init_declarator') return undefined; const value = declarator.childForFieldName('value'); - if (!value || value.type !== 'identifier') return undefined; + if (!value) return undefined; const nameNode = declarator.childForFieldName('declarator'); if (!nameNode) return undefined; const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator' @@ -179,7 +179,31 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => if (!finalName) return undefined; const lhs = extractVarName(finalName); if (!lhs || scopeEnv.has(lhs)) return undefined; - return { kind: 'copy', lhs, rhs: value.text }; + if (value.type === 'identifier') return { kind: 'copy', lhs, rhs: value.text }; + // field_expression RHS → fieldAccess (a.field) + if (value.type === 'field_expression') { + const obj = value.firstNamedChild; + const field = value.lastNamedChild; + if (obj?.type === 'identifier' && field?.type === 'field_identifier') { + return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text }; + } + } + // call_expression RHS + if (value.type === 'call_expression') { + const funcNode = value.childForFieldName('function'); + if (funcNode?.type === 'identifier') { + return { kind: 'callResult', lhs, callee: funcNode.text }; + } + // method call with receiver: call_expression → function: field_expression + if (funcNode?.type === 'field_expression') { + const obj = funcNode.firstNamedChild; + const field = funcNode.lastNamedChild; + if (obj?.type === 'identifier' && field?.type === 'field_identifier') { + return { kind: 'methodCallResult', lhs, receiver: obj.text, method: field.text }; + } + } + } + return undefined; }; // --- For-loop Tier 1c --- diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts index e2a15ceb5..f6550dc26 100644 --- a/gitnexus/src/core/ingestion/type-extractors/csharp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -327,6 +327,46 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => if (valueNode && valueNode !== nameNode && (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier')) { return { kind: 'copy', lhs, rhs: valueNode.text }; } + // member_access_expression RHS → fieldAccess (a.Field) + if (valueNode?.type === 'member_access_expression') { + const expr = valueNode.childForFieldName('expression'); + const name = valueNode.childForFieldName('name'); + if (expr?.type === 'identifier' && name?.type === 'identifier') { + return { kind: 'fieldAccess', lhs, receiver: expr.text, field: name.text }; + } + } + // invocation_expression RHS + if (valueNode?.type === 'invocation_expression') { + const funcNode = valueNode.firstNamedChild; + if (funcNode?.type === 'identifier_name' || funcNode?.type === 'identifier') { + return { kind: 'callResult', lhs, callee: funcNode.text }; + } + // method call with receiver → methodCallResult: a.GetC() + if (funcNode?.type === 'member_access_expression') { + const expr = funcNode.childForFieldName('expression'); + const name = funcNode.childForFieldName('name'); + if (expr?.type === 'identifier' && name?.type === 'identifier') { + return { kind: 'methodCallResult', lhs, receiver: expr.text, method: name.text }; + } + } + } + // await_expression → unwrap and check inner + if (valueNode?.type === 'await_expression') { + const inner = valueNode.firstNamedChild; + if (inner?.type === 'invocation_expression') { + const funcNode = inner.firstNamedChild; + if (funcNode?.type === 'identifier_name' || funcNode?.type === 'identifier') { + return { kind: 'callResult', lhs, callee: funcNode.text }; + } + if (funcNode?.type === 'member_access_expression') { + const expr = funcNode.childForFieldName('expression'); + const name = funcNode.childForFieldName('name'); + if (expr?.type === 'identifier' && name?.type === 'identifier') { + return { kind: 'methodCallResult', lhs, receiver: expr.text, method: name.text }; + } + } + } + } } return undefined; }; diff --git a/gitnexus/src/core/ingestion/type-extractors/go.ts b/gitnexus/src/core/ingestion/type-extractors/go.ts index e5d689315..0b81301bb 100644 --- a/gitnexus/src/core/ingestion/type-extractors/go.ts +++ b/gitnexus/src/core/ingestion/type-extractors/go.ts @@ -397,6 +397,29 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => const lhs = lhsNode.text; if (scopeEnv.has(lhs)) return undefined; if (rhsNode.type === 'identifier') return { kind: 'copy', lhs, rhs: rhsNode.text }; + // selector_expression RHS → fieldAccess (a.field) + if (rhsNode.type === 'selector_expression') { + const operand = rhsNode.childForFieldName('operand'); + const field = rhsNode.childForFieldName('field'); + if (operand?.type === 'identifier' && field) { + return { kind: 'fieldAccess', lhs, receiver: operand.text, field: field.text }; + } + } + // call_expression RHS + if (rhsNode.type === 'call_expression') { + const funcNode = rhsNode.childForFieldName('function'); + if (funcNode?.type === 'identifier') { + return { kind: 'callResult', lhs, callee: funcNode.text }; + } + // method call with receiver: call_expression → function: selector_expression + if (funcNode?.type === 'selector_expression') { + const operand = funcNode.childForFieldName('operand'); + const field = funcNode.childForFieldName('field'); + if (operand?.type === 'identifier' && field) { + return { kind: 'methodCallResult', lhs, receiver: operand.text, method: field.text }; + } + } + } return undefined; } if (node.type === 'var_spec' || node.type === 'var_declaration') { @@ -422,6 +445,28 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => } const rhsNode = exprList?.firstNamedChild; if (rhsNode?.type === 'identifier') return { kind: 'copy', lhs, rhs: rhsNode.text }; + // selector_expression RHS → fieldAccess + if (rhsNode?.type === 'selector_expression') { + const operand = rhsNode.childForFieldName('operand'); + const field = rhsNode.childForFieldName('field'); + if (operand?.type === 'identifier' && field) { + return { kind: 'fieldAccess', lhs, receiver: operand.text, field: field.text }; + } + } + // call_expression RHS + if (rhsNode?.type === 'call_expression') { + const funcNode = rhsNode.childForFieldName('function'); + if (funcNode?.type === 'identifier') { + return { kind: 'callResult', lhs, callee: funcNode.text }; + } + if (funcNode?.type === 'selector_expression') { + const operand = funcNode.childForFieldName('operand'); + const field = funcNode.childForFieldName('field'); + if (operand?.type === 'identifier' && field) { + return { kind: 'methodCallResult', lhs, receiver: operand.text, method: field.text }; + } + } + } } } return undefined; diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index 2e55ff324..e0f3e9fc7 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -199,6 +199,31 @@ const extractJavaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv const lhs = nameNode.text; if (scopeEnv.has(lhs)) continue; if (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier') return { kind: 'copy', lhs, rhs: valueNode.text }; + // field_access RHS → fieldAccess (a.field) + if (valueNode.type === 'field_access') { + const obj = valueNode.childForFieldName('object'); + const field = valueNode.childForFieldName('field'); + if (obj?.type === 'identifier' && field) { + return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text }; + } + } + // method_invocation RHS + if (valueNode.type === 'method_invocation') { + const objField = valueNode.childForFieldName('object'); + if (!objField) { + // No receiver → callResult + const nameField = valueNode.childForFieldName('name'); + if (nameField?.type === 'identifier') { + return { kind: 'callResult', lhs, callee: nameField.text }; + } + } else if (objField.type === 'identifier') { + // With receiver → methodCallResult + const nameField = valueNode.childForFieldName('name'); + if (nameField?.type === 'identifier') { + return { kind: 'methodCallResult', lhs, receiver: objField.text, method: nameField.text }; + } + } + } } return undefined; }; @@ -541,7 +566,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE if (!nameNode || nameNode.type !== 'simple_identifier') return undefined; const lhs = nameNode.text; if (scopeEnv.has(lhs)) return undefined; - // Find the RHS: a simple_identifier sibling after the "=" token + // Find the RHS after the "=" token let foundEq = false; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); @@ -550,6 +575,31 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE if (foundEq && child.type === 'simple_identifier') { return { kind: 'copy', lhs, rhs: child.text }; } + // navigation_expression RHS → fieldAccess (a.field) + if (foundEq && child.type === 'navigation_expression') { + const recv = child.firstNamedChild; + const suffix = child.lastNamedChild; + const fieldNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix; + if (recv?.type === 'simple_identifier' && fieldNode?.type === 'simple_identifier') { + return { kind: 'fieldAccess', lhs, receiver: recv.text, field: fieldNode.text }; + } + } + // call_expression RHS + if (foundEq && child.type === 'call_expression') { + const calleeNode = child.firstNamedChild; + if (calleeNode?.type === 'simple_identifier') { + return { kind: 'callResult', lhs, callee: calleeNode.text }; + } + // navigation_expression callee → methodCallResult (a.method()) + if (calleeNode?.type === 'navigation_expression') { + const recv = calleeNode.firstNamedChild; + const suffix = calleeNode.lastNamedChild; + const methodNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix; + if (recv?.type === 'simple_identifier' && methodNode?.type === 'simple_identifier') { + return { kind: 'methodCallResult', lhs, receiver: recv.text, method: methodNode.text }; + } + } + } } return undefined; } @@ -560,8 +610,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE if (!nameNode) return undefined; const lhs = nameNode.text; if (scopeEnv.has(lhs)) return undefined; - // Look for RHS simple_identifier after "=" in the parent (property_declaration) - // variable_declaration itself doesn't contain "=" — it's in the parent + // Look for RHS after "=" in the parent (property_declaration) const parent = node.parent; if (!parent) return undefined; let foundEq = false; @@ -572,6 +621,28 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE if (foundEq && child.type === 'simple_identifier') { return { kind: 'copy', lhs, rhs: child.text }; } + if (foundEq && child.type === 'navigation_expression') { + const recv = child.firstNamedChild; + const suffix = child.lastNamedChild; + const fieldNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix; + if (recv?.type === 'simple_identifier' && fieldNode?.type === 'simple_identifier') { + return { kind: 'fieldAccess', lhs, receiver: recv.text, field: fieldNode.text }; + } + } + if (foundEq && child.type === 'call_expression') { + const calleeNode = child.firstNamedChild; + if (calleeNode?.type === 'simple_identifier') { + return { kind: 'callResult', lhs, callee: calleeNode.text }; + } + if (calleeNode?.type === 'navigation_expression') { + const recv = calleeNode.firstNamedChild; + const suffix = calleeNode.lastNamedChild; + const methodNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix; + if (recv?.type === 'simple_identifier' && methodNode?.type === 'simple_identifier') { + return { kind: 'methodCallResult', lhs, receiver: recv.text, method: methodNode.text }; + } + } + } } return undefined; } diff --git a/gitnexus/src/core/ingestion/type-extractors/php.ts b/gitnexus/src/core/ingestion/type-extractors/php.ts index b140b1bb6..ffaebde73 100644 --- a/gitnexus/src/core/ingestion/type-extractors/php.ts +++ b/gitnexus/src/core/ingestion/type-extractors/php.ts @@ -351,11 +351,37 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => const left = node.childForFieldName('left'); const right = node.childForFieldName('right'); if (!left || !right) return undefined; - if (left.type !== 'variable_name' || right.type !== 'variable_name') return undefined; + if (left.type !== 'variable_name') return undefined; const lhs = left.text; - const rhs = right.text; - if (!lhs || !rhs || scopeEnv.has(lhs)) return undefined; - return { kind: 'copy', lhs, rhs }; + if (!lhs || scopeEnv.has(lhs)) return undefined; + if (right.type === 'variable_name') { + const rhs = right.text; + if (rhs) return { kind: 'copy', lhs, rhs }; + } + // member_access_expression RHS → fieldAccess ($a->field) + if (right.type === 'member_access_expression') { + const obj = right.childForFieldName('object'); + const name = right.childForFieldName('name'); + if (obj?.type === 'variable_name' && name) { + return { kind: 'fieldAccess', lhs, receiver: obj.text, field: name.text }; + } + } + // function_call_expression RHS → callResult (bare function calls only) + if (right.type === 'function_call_expression') { + const funcNode = right.childForFieldName('function'); + if (funcNode?.type === 'name') { + return { kind: 'callResult', lhs, callee: funcNode.text }; + } + } + // member_call_expression RHS → methodCallResult ($a->method()) + if (right.type === 'member_call_expression') { + const obj = right.childForFieldName('object'); + const name = right.childForFieldName('name'); + if (obj?.type === 'variable_name' && name) { + return { kind: 'methodCallResult', lhs, receiver: obj.text, method: name.text }; + } + } + return undefined; }; const FOR_LOOP_NODE_TYPES: ReadonlySet = new Set([ diff --git a/gitnexus/src/core/ingestion/type-extractors/python.ts b/gitnexus/src/core/ingestion/type-extractors/python.ts index ca4192cff..0b075dfc4 100644 --- a/gitnexus/src/core/ingestion/type-extractors/python.ts +++ b/gitnexus/src/core/ingestion/type-extractors/python.ts @@ -358,6 +358,29 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => const lhs = left.type === 'identifier' ? left.text : undefined; if (!lhs || scopeEnv.has(lhs)) return undefined; if (right.type === 'identifier') return { kind: 'copy', lhs, rhs: right.text }; + // attribute RHS → fieldAccess (a.field) + if (right.type === 'attribute') { + const obj = right.firstNamedChild; + const field = right.lastNamedChild; + if (obj?.type === 'identifier' && field?.type === 'identifier' && obj !== field) { + return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text }; + } + } + // call RHS + if (right.type === 'call') { + const funcNode = right.childForFieldName('function'); + if (funcNode?.type === 'identifier') { + return { kind: 'callResult', lhs, callee: funcNode.text }; + } + // method call with receiver: call → function: attribute + if (funcNode?.type === 'attribute') { + const obj = funcNode.firstNamedChild; + const method = funcNode.lastNamedChild; + if (obj?.type === 'identifier' && method?.type === 'identifier' && obj !== method) { + return { kind: 'methodCallResult', lhs, receiver: obj.text, method: method.text }; + } + } + } return undefined; }; diff --git a/gitnexus/src/core/ingestion/type-extractors/ruby.ts b/gitnexus/src/core/ingestion/type-extractors/ruby.ts index 953708be9..5a1f29f26 100644 --- a/gitnexus/src/core/ingestion/type-extractors/ruby.ts +++ b/gitnexus/src/core/ingestion/type-extractors/ruby.ts @@ -389,8 +389,22 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => const varName = lhsNode.text; if (scopeEnv.has(varName)) return undefined; const rhsNode = node.childForFieldName('right'); - if (!rhsNode || rhsNode.type !== 'identifier') return undefined; - return { kind: 'copy', lhs: varName, rhs: rhsNode.text }; + if (!rhsNode) return undefined; + if (rhsNode.type === 'identifier') return { kind: 'copy', lhs: varName, rhs: rhsNode.text }; + // call/method_call RHS — Ruby uses method calls for both field access and method calls + if (rhsNode.type === 'call' || rhsNode.type === 'method_call') { + const methodNode = rhsNode.childForFieldName('method'); + const receiverNode = rhsNode.childForFieldName('receiver'); + if (!receiverNode && methodNode?.type === 'identifier') { + // No receiver → callResult (bare function call) + return { kind: 'callResult', lhs: varName, callee: methodNode.text }; + } + if (receiverNode?.type === 'identifier' && methodNode?.type === 'identifier') { + // With receiver → methodCallResult (a.method) + return { kind: 'methodCallResult', lhs: varName, receiver: receiverNode.text, method: methodNode.text }; + } + } + return undefined; }; export const typeConfig: LanguageTypeConfig = { diff --git a/gitnexus/src/core/ingestion/type-extractors/rust.ts b/gitnexus/src/core/ingestion/type-extractors/rust.ts index c3d389638..7059cc8bd 100644 --- a/gitnexus/src/core/ingestion/type-extractors/rust.ts +++ b/gitnexus/src/core/ingestion/type-extractors/rust.ts @@ -197,7 +197,34 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => if (!pattern || !value) return undefined; const lhs = extractVarName(pattern); if (!lhs || scopeEnv.has(lhs)) return undefined; - if (value.type === 'identifier') return { kind: 'copy', lhs, rhs: value.text }; + // Unwrap Rust .await: `let user = get_user().await` → call_expression + const unwrapped = unwrapAwait(value) ?? value; + if (unwrapped.type === 'identifier') return { kind: 'copy', lhs, rhs: unwrapped.text }; + // field_expression RHS → fieldAccess (a.field) + if (unwrapped.type === 'field_expression') { + const obj = unwrapped.firstNamedChild; + const field = unwrapped.lastNamedChild; + if (obj?.type === 'identifier' && field?.type === 'field_identifier') { + return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text }; + } + } + // call_expression RHS → callResult (simple calls only) + if (unwrapped.type === 'call_expression') { + const funcNode = unwrapped.childForFieldName('function'); + if (funcNode?.type === 'identifier') { + return { kind: 'callResult', lhs, callee: funcNode.text }; + } + } + // method_call_expression RHS → methodCallResult (receiver.method()) + if (unwrapped.type === 'method_call_expression') { + const obj = unwrapped.firstNamedChild; + if (obj?.type === 'identifier') { + const methodNode = unwrapped.childForFieldName('name') ?? unwrapped.namedChild(1); + if (methodNode?.type === 'field_identifier') { + return { kind: 'methodCallResult', lhs, receiver: obj.text, method: methodNode.text }; + } + } + } return undefined; }; diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index 5896af149..af3b8a17c 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -53,15 +53,20 @@ export interface ForLoopExtractorContext { export type ForLoopExtractor = (node: SyntaxNode, ctx: ForLoopExtractorContext) => void; /** Discriminated union for pending Tier-2 propagation items. - * - `copy` — `const b = a` (identifier alias, propagate a's type to b) - * - `callResult` — `const b = foo()` (bind b to foo's declared return type) */ + * - `copy` — `const b = a` (identifier alias, propagate a's type to b) + * - `callResult` — `const b = foo()` (bind b to foo's declared return type) + * - `fieldAccess` — `const b = a.field` (bind b to field's declaredType on a's type) + * - `methodCallResult` — `const b = a.method()` (bind b to method's returnType on a's type) */ export type PendingAssignment = | { kind: 'copy'; lhs: string; rhs: string } - | { kind: 'callResult'; lhs: string; callee: string }; + | { kind: 'callResult'; lhs: string; callee: string } + | { kind: 'fieldAccess'; lhs: string; receiver: string; field: string } + | { kind: 'methodCallResult'; lhs: string; receiver: string; method: string }; /** Extracts a pending assignment for Tier 2 propagation. - * Returns a PendingAssignment when the RHS is a bare identifier (`copy`) or a - * call expression (`callResult`) and the LHS has no resolved type yet. + * Returns a PendingAssignment when the RHS is a bare identifier (`copy`), a + * call expression (`callResult`), a field access (`fieldAccess`), or a + * method call with receiver (`methodCallResult`) and the LHS has no resolved type yet. * Returns undefined if the node is not a matching assignment. */ export type PendingAssignmentExtractor = ( node: SyntaxNode, diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index 8f215203d..56f4d15b8 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -440,6 +440,34 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => const lhs = nameNode.text; if (scopeEnv.has(lhs)) continue; if (valueNode.type === 'identifier') return { kind: 'copy', lhs, rhs: valueNode.text }; + // member_expression RHS → fieldAccess (a.field, this.field) + if (valueNode.type === 'member_expression') { + const obj = valueNode.childForFieldName('object'); + const prop = valueNode.childForFieldName('property'); + if (obj && prop?.type === 'property_identifier' && + (obj.type === 'identifier' || obj.type === 'this')) { + return { kind: 'fieldAccess', lhs, receiver: obj.text, field: prop.text }; + } + continue; + } + // Unwrap await: `const user = await fetchUser()` or `await a.getC()` + const callNode = unwrapAwait(valueNode); + if (!callNode || callNode.type !== 'call_expression') continue; + const funcNode = callNode.childForFieldName('function'); + if (!funcNode) continue; + // Simple call → callResult: getUser() + if (funcNode.type === 'identifier') { + return { kind: 'callResult', lhs, callee: funcNode.text }; + } + // Method call with receiver → methodCallResult: a.getC() + if (funcNode.type === 'member_expression') { + const obj = funcNode.childForFieldName('object'); + const prop = funcNode.childForFieldName('property'); + if (obj && prop?.type === 'property_identifier' && + (obj.type === 'identifier' || obj.type === 'this')) { + return { kind: 'methodCallResult', lhs, receiver: obj.text, method: prop.text }; + } + } } return undefined; }; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index e46c03c58..bd4061797 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -18,6 +18,9 @@ let conn: lbug.Connection | null = null; let currentDbPath: string | null = null; let ftsLoaded = false; +/** Expose the current Database for pool adapter reuse in tests. */ +export const getDatabase = (): lbug.Database | null => db; + // Global session lock for operations that touch module-level lbug globals. // This guarantees no DB switch can happen while an operation is running. let sessionLock: Promise = Promise.resolve(); diff --git a/gitnexus/src/mcp/core/lbug-adapter.ts b/gitnexus/src/mcp/core/lbug-adapter.ts index 19e09f0b0..cf9bb1ad6 100644 --- a/gitnexus/src/mcp/core/lbug-adapter.ts +++ b/gitnexus/src/mcp/core/lbug-adapter.ts @@ -27,6 +27,8 @@ interface PoolEntry { waiters: Array<(conn: lbug.Connection) => void>; lastUsed: number; dbPath: string; + /** Set to true when the pool entry is closed — checkin will close orphaned connections */ + closed: boolean; } const pool = new Map(); @@ -40,6 +42,8 @@ interface SharedDB { db: lbug.Database; refCount: number; ftsLoaded: boolean; + /** When true, closeOne skips db.close() — the Database is owned externally. */ + external?: boolean; } const dbCache = new Map(); @@ -96,21 +100,47 @@ function evictLRU(): void { } /** - * Remove a repo from the pool and release its shared Database ref. - * - * LadybugDB's native .closeSync() triggers N-API destructor hooks that - * segfault on Linux/macOS. Pool databases are opened read-only, so - * there is no WAL to flush — just deleting the pool entry and letting - * the GC (or process exit) reclaim native resources is safe. + * Remove a repo from the pool, close its connections, and release its + * shared Database ref. Only closes the Database when no other repoIds + * reference it (refCount === 0). */ function closeOne(repoId: string): void { const entry = pool.get(repoId); - if (entry) { - const shared = dbCache.get(entry.dbPath); - if (shared && shared.refCount > 0) { - shared.refCount--; + if (!entry) return; + + entry.closed = true; + + // Close available connections — fire-and-forget with .catch() to prevent + // unhandled rejections. Native close() returns Promise but can crash + // the N-API destructor on macOS/Windows; deferring to process exit lets + // dangerouslyIgnoreUnhandledErrors absorb the crash. + for (const conn of entry.available) { + conn.close().catch(() => {}); + } + entry.available.length = 0; + + // Checked-out connections can't be closed here — they're in-flight. + // The checkin() function detects entry.closed and closes them on return. + + // Only close the Database when no other repoIds reference it. + // External databases (injected via initLbugWithDb) are never closed here — + // the core adapter owns them and handles their lifecycle. + const shared = dbCache.get(entry.dbPath); + if (shared) { + shared.refCount--; + if (shared.refCount === 0) { + if (shared.external) { + // External databases are owned by the core adapter — don't close + // or remove from cache. Keep the entry so future initLbug() calls + // for the same dbPath reuse it instead of hitting a file lock. + shared.refCount = 0; + } else { + shared.db.close().catch(() => {}); + dbCache.delete(entry.dbPath); + } } } + pool.delete(repoId); } @@ -274,7 +304,69 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { // Register pool entry only after all connections are pre-warmed and FTS is // loaded. Concurrent executeQuery calls see either "not initialized" // (and throw cleanly) or a fully ready pool — never a half-built one. - pool.set(repoId, { db, available, checkedOut: 0, waiters: [], lastUsed: Date.now(), dbPath }); + pool.set(repoId, { db, available, checkedOut: 0, waiters: [], lastUsed: Date.now(), dbPath, closed: false }); + ensureIdleTimer(); +} + +/** + * Initialize a pool entry from a pre-existing Database object. + * + * Used in tests to avoid the writable→close→read-only cycle that crashes + * on macOS due to N-API destructor segfaults. The pool adapter reuses + * the core adapter's writable Database instead of opening a new read-only one. + * + * The Database is registered in the shared dbCache so closeOne() decrements + * the refCount correctly. If the Database is already cached (e.g. another + * repoId already injected it), the existing entry is reused. + */ +export async function initLbugWithDb( + repoId: string, + existingDb: lbug.Database, + dbPath: string, +): Promise { + const existing = pool.get(repoId); + if (existing) { + existing.lastUsed = Date.now(); + return; + } + + // Register in dbCache with external: true so other initLbug() calls + // for the same dbPath reuse this Database instead of trying to open + // a new one (which would fail with a file lock error). + // closeOne() respects the external flag and skips db.close(). + let shared = dbCache.get(dbPath); + if (!shared) { + shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true }; + dbCache.set(dbPath, shared); + } + shared.refCount++; + + const available: lbug.Connection[] = []; + preWarmActive = true; + try { + for (let i = 0; i < MAX_CONNS_PER_REPO; i++) { + available.push(createConnection(existingDb)); + } + } finally { + preWarmActive = false; + } + + // Load FTS extension if not already loaded on this Database + try { + await available[0].query('LOAD EXTENSION fts'); + } catch { + // Extension may already be loaded or not installed + } + + pool.set(repoId, { + db: existingDb, + available, + checkedOut: 0, + waiters: [], + lastUsed: Date.now(), + dbPath, + closed: false + }); ensureIdleTimer(); } @@ -319,10 +411,17 @@ function checkout(entry: PoolEntry): Promise { /** * Return a connection to the pool after use. + * If the pool entry was closed while the connection was checked out (e.g. + * LRU eviction), close the orphaned connection instead of returning it. * If there are queued waiters, hand the connection directly to the next one * instead of putting it back in the available array (avoids race conditions). */ function checkin(entry: PoolEntry, conn: lbug.Connection): void { + if (entry.closed) { + // Pool entry was deleted during checkout — close the orphaned connection + conn.close().catch(() => {}); + return; + } if (entry.waiters.length > 0) { // Hand directly to the next waiter — no intermediate available state const waiter = entry.waiters.shift()!; @@ -352,6 +451,10 @@ export const executeQuery = async (repoId: string, cypher: string): Promise => { * Check if a specific repo's pool is active */ export const isLbugReady = (repoId: string): boolean => pool.has(repoId); + +/** Regex to detect write operations in user-supplied Cypher queries */ +export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i; + +/** Check if a Cypher query contains write operations */ +export function isWriteQuery(query: string): boolean { + return CYPHER_WRITE_RE.test(query); +} diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index d6e5a512b..106a2273e 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -321,6 +321,13 @@ nodes: additional_node_types: "Multi-language: Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Constructor, Template, Module (use backticks in queries: \`Struct\`, \`Enum\`, etc.)" +node_properties: + common: "name (STRING), filePath (STRING), startLine (INT32), endLine (INT32)" + Method: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)" + Function: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)" + Property: "declaredType (STRING) — the field's type annotation (e.g., 'Address', 'City'). Used for field-access chain resolution." + Constructor: "parameterCount (INT32)" + relationships: - CONTAINS: File/Folder contains child - DEFINES: File defines a symbol diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index a8e5ce8ac..b19baadbf 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -95,7 +95,7 @@ EXAMPLES: MATCH (c:Class {name: "UserService"})-[r:CodeRelation {type: 'HAS_METHOD'}]->(m:Method) RETURN m.name, m.parameterCount, m.returnType • Find all properties of a class: - MATCH (c:Class {name: "User"})-[r:CodeRelation {type: 'HAS_PROPERTY'}]->(p:Property) RETURN p.name, p.description + MATCH (c:Class {name: "User"})-[r:CodeRelation {type: 'HAS_PROPERTY'}]->(p:Property) RETURN p.name, p.declaredType • Find all writers of a field: MATCH (f:Function)-[r:CodeRelation {type: 'ACCESSES', reason: 'write'}]->(p:Property) WHERE p.name = "address" RETURN f.name, f.filePath @@ -132,7 +132,7 @@ AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/pro Handles disambiguation: if multiple symbols share the same name, returns candidates for you to pick from. Use uid param for zero-ambiguity lookup from prior results. -NOTE: ACCESSES edges (field read/write tracking) are included in context results. Coverage: reads detected during call chain resolution (e.g., user.address.save() emits a read on 'address'). Standalone reads and writes require Phase 2.`, +NOTE: ACCESSES edges (field read/write tracking) are included in context results with reason 'read' or 'write'. CALLS edges resolve through field access chains and method-call chains (e.g., user.address.getCity().save() produces CALLS edges at each step).`, inputSchema: { type: 'object', properties: { diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-call-result-binding/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-call-result-binding/app.cpp new file mode 100644 index 000000000..69e818252 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-call-result-binding/app.cpp @@ -0,0 +1,6 @@ +#include "user.h" + +void processUser() { + auto user = getUser("alice"); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-call-result-binding/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-call-result-binding/user.h new file mode 100644 index 000000000..112b70fea --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-call-result-binding/user.h @@ -0,0 +1,14 @@ +#pragma once +#include + +class User { +public: + User(const std::string& n) : name_(n) {} + bool save() { return true; } +private: + std::string name_; +}; + +User getUser(const std::string& name) { + return User(name); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-method-chain-binding/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-method-chain-binding/app.cpp new file mode 100644 index 000000000..fc69a7a21 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-method-chain-binding/app.cpp @@ -0,0 +1,8 @@ +#include "models.h" + +void processChain() { + auto user = getUser(); + auto addr = user.address; + auto city = addr.getCity(); + city.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-method-chain-binding/models.h b/gitnexus/test/fixtures/lang-resolution/cpp-method-chain-binding/models.h new file mode 100644 index 000000000..e7dad9478 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-method-chain-binding/models.h @@ -0,0 +1,26 @@ +#pragma once +#include + +class City { +public: + std::string name; + City(const std::string& n) : name(n) {} + bool save() { return true; } +}; + +class Address { +public: + City city; + Address(const City& c) : city(c) {} + City getCity() { return city; } +}; + +class User { +public: + Address address; + User(const Address& a) : address(a) {} +}; + +User getUser() { + return User(Address(City("NYC"))); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-call-result-binding/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-call-result-binding/App.cs new file mode 100644 index 000000000..52a0e032a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-call-result-binding/App.cs @@ -0,0 +1,22 @@ +class User { + public string Name { get; set; } + + public User(string name) { + Name = name; + } + + public bool Save() { + return true; + } +} + +class App { + static User GetUser(string name) { + return new User(name); + } + + void ProcessUser() { + var user = GetUser("alice"); + user.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-method-chain-binding/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-method-chain-binding/App.cs new file mode 100644 index 000000000..1266c130e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-method-chain-binding/App.cs @@ -0,0 +1,29 @@ +class City { + public string Name { get; set; } + public City(string name) { Name = name; } + public bool Save() { return true; } +} + +class Address { + public City City { get; set; } + public Address(City city) { City = city; } + public City GetCity() { return City; } +} + +class User { + public Address Address { get; set; } + public User(Address address) { Address = address; } +} + +class App { + static User GetUser() { + return new User(new Address(new City("NYC"))); + } + + void ProcessChain() { + var user = GetUser(); + var addr = user.Address; + var city = addr.GetCity(); + city.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-call-result-binding/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-call-result-binding/cmd/main.go new file mode 100644 index 000000000..770cb8e77 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-call-result-binding/cmd/main.go @@ -0,0 +1,12 @@ +package main + +import "example.com/callresult/models" + +func GetUser(name string) *models.User { + return &models.User{Name: name} +} + +func processUser() { + user := GetUser("alice") + user.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-call-result-binding/go.mod b/gitnexus/test/fixtures/lang-resolution/go-call-result-binding/go.mod new file mode 100644 index 000000000..a08a7afac --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-call-result-binding/go.mod @@ -0,0 +1,3 @@ +module example.com/callresult + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-call-result-binding/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-call-result-binding/models/user.go new file mode 100644 index 000000000..0e78a30a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-call-result-binding/models/user.go @@ -0,0 +1,9 @@ +package models + +type User struct { + Name string +} + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/cmd/main.go new file mode 100644 index 000000000..32c6ab877 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/cmd/main.go @@ -0,0 +1,14 @@ +package main + +import "example.com/methodchain/models" + +func GetUser() *models.User { + return &models.User{} +} + +func processChain() { + user := GetUser() + addr := user.Address + city := addr.GetCity() + city.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/go.mod b/gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/go.mod new file mode 100644 index 000000000..fba59f405 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/go.mod @@ -0,0 +1,3 @@ +module example.com/methodchain + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/models/user.go new file mode 100644 index 000000000..1cf6b6ca8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/models/user.go @@ -0,0 +1,21 @@ +package models + +type City struct { + Name string +} + +func (c *City) Save() bool { + return true +} + +type Address struct { + City City +} + +func (a *Address) GetCity() *City { + return &a.City +} + +type User struct { + Address Address +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-call-result-binding/App.java b/gitnexus/test/fixtures/lang-resolution/java-call-result-binding/App.java new file mode 100644 index 000000000..387648704 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-call-result-binding/App.java @@ -0,0 +1,10 @@ +public class App { + static User getUser(String name) { + return new User(name); + } + + void processUser() { + var user = getUser("alice"); + user.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-call-result-binding/User.java b/gitnexus/test/fixtures/lang-resolution/java-call-result-binding/User.java new file mode 100644 index 000000000..3d9d41eba --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-call-result-binding/User.java @@ -0,0 +1,11 @@ +public class User { + private String name; + + public User(String name) { + this.name = name; + } + + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-method-chain-binding/App.java b/gitnexus/test/fixtures/lang-resolution/java-method-chain-binding/App.java new file mode 100644 index 000000000..6679b6fc8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-method-chain-binding/App.java @@ -0,0 +1,12 @@ +class App { + static User getUser() { + return new User(new Address(new City("NYC"))); + } + + void processChain() { + var user = getUser(); + var addr = user.address; + var city = addr.getCity(); + city.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-method-chain-binding/Models.java b/gitnexus/test/fixtures/lang-resolution/java-method-chain-binding/Models.java new file mode 100644 index 000000000..5f6d0cf81 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-method-chain-binding/Models.java @@ -0,0 +1,16 @@ +class City { + String name; + City(String name) { this.name = name; } + boolean save() { return true; } +} + +class Address { + City city; + Address(City city) { this.city = city; } + City getCity() { return city; } +} + +class User { + Address address; + User(Address address) { this.address = address; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-call-result-binding/app.js b/gitnexus/test/fixtures/lang-resolution/js-call-result-binding/app.js new file mode 100644 index 000000000..0b41f2e58 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-call-result-binding/app.js @@ -0,0 +1,6 @@ +const { getUser } = require('./service'); + +function processUser() { + const user = getUser('alice'); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-call-result-binding/models.js b/gitnexus/test/fixtures/lang-resolution/js-call-result-binding/models.js new file mode 100644 index 000000000..7f19a622d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-call-result-binding/models.js @@ -0,0 +1,11 @@ +class User { + constructor(name) { + this.name = name; + } + + save() { + return true; + } +} + +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/js-call-result-binding/service.js b/gitnexus/test/fixtures/lang-resolution/js-call-result-binding/service.js new file mode 100644 index 000000000..88f942207 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-call-result-binding/service.js @@ -0,0 +1,11 @@ +const { User } = require('./models'); + +/** + * @param {string} name + * @returns {User} + */ +function getUser(name) { + return new User(name); +} + +module.exports = { getUser }; diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-call-result-binding/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-call-result-binding/User.kt new file mode 100644 index 000000000..9a1e72ee3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-call-result-binding/User.kt @@ -0,0 +1,14 @@ +class User(val name: String) { + fun save(): Boolean { + return true + } +} + +fun getUser(name: String): User { + return User(name) +} + +fun processUser() { + val user = getUser("alice") + user.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-method-chain-binding/Models.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-method-chain-binding/Models.kt new file mode 100644 index 000000000..ce1337136 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-method-chain-binding/Models.kt @@ -0,0 +1,18 @@ +class City(val name: String) { + fun save(): Boolean = true +} + +class Address(val city: City) { + fun getCity(): City = city +} + +class User(val address: Address) + +fun getUser(): User = User(Address(City("NYC"))) + +fun processChain() { + val user = getUser() + val addr = user.address + val city = addr.getCity() + city.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-call-result-binding/App.php b/gitnexus/test/fixtures/lang-resolution/php-call-result-binding/App.php new file mode 100644 index 000000000..16f199b4d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-call-result-binding/App.php @@ -0,0 +1,22 @@ +name = $name; + } + + public function save(): bool { + return true; + } +} + +function getUser(string $name): User { + return new User($name); +} + +function processUser(): void { + $user = getUser("alice"); + $user->save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-method-chain-binding/App.php b/gitnexus/test/fixtures/lang-resolution/php-method-chain-binding/App.php new file mode 100644 index 000000000..6b381bf39 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-method-chain-binding/App.php @@ -0,0 +1,28 @@ +name = $name; } + public function save(): bool { return true; } +} + +class Address { + public City $city; + public function __construct(City $city) { $this->city = $city; } + public function getCity(): City { return $this->city; } +} + +class User { + public Address $address; + public function __construct(Address $address) { $this->address = $address; } +} + +function getUser(): User { + return new User(new Address(new City("NYC"))); +} + +function processChain(): void { + $user = getUser(); + $city = $user->getCity(); + $city->save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/python-call-result-binding/app.py b/gitnexus/test/fixtures/lang-resolution/python-call-result-binding/app.py new file mode 100644 index 000000000..ed57b080c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-call-result-binding/app.py @@ -0,0 +1,5 @@ +from service import get_user + +def process_user(): + user = get_user("alice") + user.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-call-result-binding/models.py b/gitnexus/test/fixtures/lang-resolution/python-call-result-binding/models.py new file mode 100644 index 000000000..c0ddac39f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-call-result-binding/models.py @@ -0,0 +1,6 @@ +class User: + def __init__(self, name: str): + self.name = name + + def save(self) -> bool: + return True diff --git a/gitnexus/test/fixtures/lang-resolution/python-call-result-binding/service.py b/gitnexus/test/fixtures/lang-resolution/python-call-result-binding/service.py new file mode 100644 index 000000000..7bf2641bd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-call-result-binding/service.py @@ -0,0 +1,4 @@ +from models import User + +def get_user(name: str) -> User: + return User(name) diff --git a/gitnexus/test/fixtures/lang-resolution/python-method-chain-binding/app.py b/gitnexus/test/fixtures/lang-resolution/python-method-chain-binding/app.py new file mode 100644 index 000000000..1469dbe3a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-method-chain-binding/app.py @@ -0,0 +1,9 @@ +from models import User, Address, City + +def get_user() -> User: + return User(Address(City("NYC"))) + +def process_chain(): + user = get_user() + city = user.get_city() + city.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-method-chain-binding/models.py b/gitnexus/test/fixtures/lang-resolution/python-method-chain-binding/models.py new file mode 100644 index 000000000..ca0e26c13 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-method-chain-binding/models.py @@ -0,0 +1,21 @@ +class City: + def __init__(self, name: str): + self.name = name + + def save(self) -> bool: + return True + +class Address: + city: City + + def __init__(self, city: City): + self.city = city + + def get_city(self) -> City: + return self.city + +class User: + address: Address + + def __init__(self, address: Address): + self.address = address diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-call-result-binding/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-call-result-binding/app.rb new file mode 100644 index 000000000..c44f2d66b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-call-result-binding/app.rb @@ -0,0 +1,15 @@ +class User + def save + true + end +end + +# @return [User] +def get_user(name) + User.new +end + +def process_user + user = get_user("alice") + user.save +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-method-chain-binding/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-method-chain-binding/app.rb new file mode 100644 index 000000000..c4063f1f6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-method-chain-binding/app.rb @@ -0,0 +1,31 @@ +class City + def save + true + end +end + +class Address + # @return [City] + def get_city + City.new + end +end + +class User + # @return [Address] + def get_address + Address.new + end +end + +# @return [User] +def get_user + User.new +end + +def process_chain + user = get_user() + addr = user.get_address() + city = addr.get_city() + city.save +end diff --git a/gitnexus/test/fixtures/lang-resolution/rust-call-result-binding/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-call-result-binding/src/main.rs new file mode 100644 index 000000000..c5c8006c8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-call-result-binding/src/main.rs @@ -0,0 +1,7 @@ +mod models; +use models::get_user; + +fn process_user() { + let user = get_user("alice"); + user.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-call-result-binding/src/models.rs b/gitnexus/test/fixtures/lang-resolution/rust-call-result-binding/src/models.rs new file mode 100644 index 000000000..eaa691743 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-call-result-binding/src/models.rs @@ -0,0 +1,13 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) -> bool { + true + } +} + +pub fn get_user(name: &str) -> User { + User { name: name.to_string() } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-method-chain-binding/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-method-chain-binding/src/main.rs new file mode 100644 index 000000000..eb0ee3994 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-method-chain-binding/src/main.rs @@ -0,0 +1,9 @@ +mod models; +use models::get_user; + +fn process_chain() { + let user = get_user(); + let addr = user.address; + let city = addr.get_city(); + city.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-method-chain-binding/src/models.rs b/gitnexus/test/fixtures/lang-resolution/rust-method-chain-binding/src/models.rs new file mode 100644 index 000000000..5d2817bbb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-method-chain-binding/src/models.rs @@ -0,0 +1,23 @@ +pub struct City { + pub name: String, +} + +impl City { + pub fn save(&self) -> bool { true } +} + +pub struct Address { + pub city: City, +} + +impl Address { + pub fn get_city(&self) -> &City { &self.city } +} + +pub struct User { + pub address: Address, +} + +pub fn get_user() -> User { + User { address: Address { city: City { name: "NYC".to_string() } } } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/app.ts new file mode 100644 index 000000000..9487a533e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/app.ts @@ -0,0 +1,12 @@ +import { getUser } from './service'; + +function processUser() { + const user = getUser('alice'); + user.save(); +} + +function processAlias() { + const user = getUser('bob'); + const alias = user; + alias.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/models.ts b/gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/models.ts new file mode 100644 index 000000000..d5470115f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/models.ts @@ -0,0 +1,11 @@ +export class User { + name: string; + + constructor(name: string) { + this.name = name; + } + + save(): boolean { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/service.ts b/gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/service.ts new file mode 100644 index 000000000..39ac0b938 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/service.ts @@ -0,0 +1,5 @@ +import { User } from './models'; + +export function getUser(name: string): User { + return new User(name); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/app.ts new file mode 100644 index 000000000..20fd79807 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/app.ts @@ -0,0 +1,8 @@ +import { getUser } from './service'; + +function processChain() { + const user = getUser(); + const addr = user.address; + const city = addr.getCity(); + city.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/models.ts b/gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/models.ts new file mode 100644 index 000000000..2d2d69098 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/models.ts @@ -0,0 +1,31 @@ +export class City { + name: string; + + constructor(name: string) { + this.name = name; + } + + save(): boolean { + return true; + } +} + +export class Address { + city: City; + + constructor(city: City) { + this.city = city; + } + + getCity(): City { + return this.city; + } +} + +export class User { + address: Address; + + constructor(address: Address) { + this.address = address; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/service.ts b/gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/service.ts new file mode 100644 index 000000000..e6720fcdf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/service.ts @@ -0,0 +1,5 @@ +import { User, Address, City } from './models'; + +export function getUser(): User { + return new User(new Address(new City('NYC'))); +} diff --git a/gitnexus/test/global-setup.ts b/gitnexus/test/global-setup.ts index 3faa9039d..f5760a1b1 100644 --- a/gitnexus/test/global-setup.ts +++ b/gitnexus/test/global-setup.ts @@ -41,14 +41,8 @@ export default async function setup({ provide }: GlobalSetupContext) { // FTS may already be installed system-wide — not fatal } - // Close native handles explicitly on Windows (file locks require it). - // On Linux/macOS, skip close — the N-API destructor hooks can segfault - // or deadlock. The teardown function removes the temp directory, and - // process exit reclaims all native resources. - if (process.platform === 'win32') { - conn.close(); - db.close(); - } + await conn.close(); + await db.close(); // Share the dbPath with all test files via inject('lbugDbPath') provide('lbugDbPath', dbPath); diff --git a/gitnexus/test/helpers/test-indexed-db.ts b/gitnexus/test/helpers/test-indexed-db.ts index e86ac235d..5f1aa32e3 100644 --- a/gitnexus/test/helpers/test-indexed-db.ts +++ b/gitnexus/test/helpers/test-indexed-db.ts @@ -5,8 +5,7 @@ * Each test file clears all data, reseeds, and initializes adapters — * avoiding per-file schema creation overhead. * - * Cleanup is intentionally a no-op: CI runs each LadybugDB test file in its - * own vitest process, so the OS reclaims all native resources on exit. + * Cleanup properly closes adapters and releases native resources. * * Each test file gets a unique repoId to prevent MCP pool map collisions. * Seed data is NOT included — each test provides its own via options.seed. @@ -27,7 +26,7 @@ export interface IndexedDBHandle { repoId: string; /** Temp directory handle for filesystem cleanup */ tmpHandle: TestDBHandle; - /** Cleanup: detaches adapters (null-out, no native .close()) */ + /** Cleanup: closes adapters and releases native resources */ cleanup: () => Promise; } @@ -119,25 +118,26 @@ export function withTestLbugDB( } } - // 7. Close core adapter (Windows only), then open pool adapter (read-only). - // On Windows, LadybugDB enforces file locks — writable + read-only - // can't coexist on the same path, so we must close the core first. - // On Linux/macOS, .close() deadlocks or segfaults via N-API - // destructor hooks, but concurrent Database instances on the same - // path are allowed, so we skip the close entirely. + // 7. Open pool adapter by injecting the core adapter's writable Database. + // LadybugDB enforces file locks — writable + read-only can't coexist + // on the same path, and db.close() segfaults on macOS due to N-API + // destructor issues. Reusing the writable Database avoids both problems. + // Write protection is enforced at the query validation layer (isWriteQuery) + // rather than at the native DB level. if (options?.poolAdapter) { - if (process.platform === 'win32') { - await adapter.closeLbug(); - } - const { initLbug: poolInitLbug } = await import('../../src/mcp/core/lbug-adapter.js'); - await poolInitLbug(repoId, dbPath); + const coreDb = adapter.getDatabase(); + if (!coreDb) throw new Error('withTestLbugDB: core adapter has no open Database'); + const { initLbugWithDb } = await import('../../src/mcp/core/lbug-adapter.js'); + await initLbugWithDb(repoId, coreDb, dbPath); } - // Cleanup: intentionally a no-op. We do NOT call detachLbug() here - // because .closeSync() segfaults on Linux (LadybugDB N-API destructor bug). - // CI runs each LadybugDB test file in its own vitest process, so the OS - // reclaims all native resources on process exit — no explicit cleanup needed. - const cleanup = async () => {}; + const cleanup = async () => { + if (options?.poolAdapter) { + const poolAdapter = await import('../../src/mcp/core/lbug-adapter.js'); + await poolAdapter.closeLbug(repoId); + } + await adapter.closeLbug(); + }; // tmpHandle.dbPath → parent temp dir (not the lbug file) so tests // that create sibling directories (e.g. 'storage') still work. diff --git a/gitnexus/test/integration/lbug-core-adapter.test.ts b/gitnexus/test/integration/lbug-core-adapter.test.ts index 4b6a5bd23..c93ea0d83 100644 --- a/gitnexus/test/integration/lbug-core-adapter.test.ts +++ b/gitnexus/test/integration/lbug-core-adapter.test.ts @@ -5,9 +5,8 @@ * * IMPORTANT: All core adapter tests share ONE coreHandle and ONE coreInitLbug * call because the core adapter is a module-level singleton. Calling - * coreInitLbug with a different path would close the previous native DB - * handle, which segfaults in forked processes. Sharing a single handle - * avoids this entirely. + * coreInitLbug with a different path closes the previous native DB handle + * and opens a new one — sharing a single handle avoids unnecessary churn. */ import { describe, it, expect } from 'vitest'; import fs from 'fs/promises'; diff --git a/gitnexus/test/integration/local-backend.test.ts b/gitnexus/test/integration/local-backend.test.ts index 7b59ec1c3..23c233e63 100644 --- a/gitnexus/test/integration/local-backend.test.ts +++ b/gitnexus/test/integration/local-backend.test.ts @@ -15,14 +15,12 @@ */ import { describe, it, expect } from 'vitest'; import { + CYPHER_WRITE_RE, executeQuery, executeParameterized, -} from '../../src/mcp/core/lbug-adapter.js'; -import { - CYPHER_WRITE_RE, - VALID_RELATION_TYPES, isWriteQuery, -} from '../../src/mcp/local/local-backend.js'; +} from '../../src/mcp/core/lbug-adapter.js'; +import { VALID_RELATION_TYPES } from '../../src/mcp/local/local-backend.js'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; import { LOCAL_BACKEND_SEED_DATA } from '../fixtures/local-backend-seed.js'; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 020366059..8b8e1835d 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -969,3 +969,49 @@ describe('Write access tracking (C++)', () => { } }); }); + +// --------------------------------------------------------------------------- +// Call-result variable binding (Phase 9): auto user = getUser(); user.save() +// --------------------------------------------------------------------------- + +describe('C++ call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to User#save via call-result binding with auto', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Method chain binding (Phase 9C): getUser() → .address → .getCity() → .save() +// --------------------------------------------------------------------------- + +describe('C++ method chain binding via unified fixpoint (Phase 9C)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-method-chain-binding'), + () => {}, + ); + }, 60000); + + it('resolves city.save() to City#save via method chain with auto', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processChain' + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index e603e239d..9aaa6816d 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1316,3 +1316,49 @@ describe('Write access tracking (C#)', () => { } }); }); + +// --------------------------------------------------------------------------- +// Call-result variable binding (Phase 9): var user = GetUser(); user.Save() +// --------------------------------------------------------------------------- + +describe('C# call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves user.Save() to User#Save via call-result binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'Save' && c.source === 'ProcessUser' && c.targetFilePath.includes('App') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Method chain binding (Phase 9C): GetUser() → .Address → .GetCity() → .Save() +// --------------------------------------------------------------------------- + +describe('C# method chain binding via unified fixpoint (Phase 9C)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-method-chain-binding'), + () => {}, + ); + }, 60000); + + it('resolves city.Save() to City#Save via method chain', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'Save' && c.source === 'ProcessChain' && c.targetFilePath.includes('App') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index a1ba00c13..5aeb9ac54 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -1099,3 +1099,49 @@ describe('Write access tracking (Go)', () => { expect(addressWrite!.source).toBe('updateUser'); }); }); + +// --------------------------------------------------------------------------- +// Call-result variable binding (Phase 9): user := GetUser(); user.Save() +// --------------------------------------------------------------------------- + +describe('Go call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves user.Save() to User#Save via call-result binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'Save' && c.source === 'processUser' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Method chain binding (Phase 9C): GetUser() → .Address → .GetCity() → .Save() +// --------------------------------------------------------------------------- + +describe('Go method chain binding via unified fixpoint (Phase 9C)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-method-chain-binding'), + () => {}, + ); + }, 60000); + + it('resolves city.Save() to City#Save via 3-step chain', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'Save' && c.source === 'processChain' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index b19ca0d63..f26ab2d41 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -1232,3 +1232,49 @@ describe('Write access tracking (Java)', () => { expect(addressWrite!.source).toBe('updateUser'); }); }); + +// --------------------------------------------------------------------------- +// Call-result variable binding (Phase 9): var user = getUser(); user.save() +// --------------------------------------------------------------------------- + +describe('Java call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to User#save via call-result binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('User') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Method chain binding (Phase 9C): getUser() → .address → .getCity() → .save() +// --------------------------------------------------------------------------- + +describe('Java method chain binding via unified fixpoint (Phase 9C)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-method-chain-binding'), + () => {}, + ); + }, 60000); + + it('resolves city.save() to City#save via method chain', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processChain' && c.targetFilePath.includes('Models') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index a6866faca..6ea6cd2e5 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -1395,3 +1395,49 @@ describe('Write access tracking (Kotlin)', () => { } }); }); + +// --------------------------------------------------------------------------- +// Call-result variable binding (Phase 9): val user = getUser(); user.save() +// --------------------------------------------------------------------------- + +describe('Kotlin call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to User#save via call-result binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('User') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Method chain binding (Phase 9C): getUser() → .address → .getCity() → .save() +// --------------------------------------------------------------------------- + +describe('Kotlin method chain binding via unified fixpoint (Phase 9C)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-method-chain-binding'), + () => {}, + ); + }, 60000); + + it('resolves city.save() to City#save via method chain', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processChain' && c.targetFilePath.includes('Models') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index f8ea5c570..03c1f670b 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -1336,3 +1336,49 @@ describe('Write access tracking (PHP)', () => { } }); }); + +// --------------------------------------------------------------------------- +// Call-result variable binding (Phase 9): $user = getUser(); $user->save() +// --------------------------------------------------------------------------- + +describe('PHP call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves $user->save() to User#save via call-result binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('App') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Method chain binding (Phase 9C): getUser() → ->getCity() → ->save() +// --------------------------------------------------------------------------- + +describe('PHP method chain binding via unified fixpoint (Phase 9C)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-method-chain-binding'), + () => {}, + ); + }, 60000); + + it('resolves $city->save() to City#save via method chain', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processChain' && c.targetFilePath.includes('App') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index bdbf58c55..ac9974192 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1382,3 +1382,49 @@ describe('Write access tracking (Python)', () => { expect(addressWrite!.source).toBe('update_user'); }); }); + +// --------------------------------------------------------------------------- +// Call-result variable binding (Phase 9): user = get_user(); user.save() +// --------------------------------------------------------------------------- + +describe('Python call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to User#save via call-result binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Method chain binding (Phase 9C): get_user() → .get_city() → .save() +// --------------------------------------------------------------------------- + +describe('Python method chain binding via unified fixpoint (Phase 9C)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-method-chain-binding'), + () => {}, + ); + }, 60000); + + it('resolves city.save() to City#save via method chain', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_chain' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index ad6f474f7..ef07fc114 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -966,3 +966,49 @@ describe('Write access tracking (Ruby)', () => { } }); }); + +// --------------------------------------------------------------------------- +// Call-result variable binding (Phase 9): user = get_user(); user.save +// --------------------------------------------------------------------------- + +describe('Ruby call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves user.save to User#save via call-result binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('app') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Method chain binding (Phase 9C): get_user() → .get_address() → .get_city() → .save +// --------------------------------------------------------------------------- + +describe('Ruby method chain binding via unified fixpoint (Phase 9C)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-method-chain-binding'), + () => {}, + ); + }, 60000); + + it('resolves city.save to City#save via method chain', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_chain' && c.targetFilePath.includes('app') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index e5b0e1d05..107adf2c5 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -1429,3 +1429,49 @@ describe('Write access tracking (Rust)', () => { expect(scoreWrite!.source).toBe('update_user'); }); }); + +// --------------------------------------------------------------------------- +// Call-result variable binding (Phase 9): let user = get_user(); user.save() +// --------------------------------------------------------------------------- + +describe('Rust call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to User#save via call-result binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Method chain binding (Phase 9C): get_user() → .address → .get_city() → .save() +// --------------------------------------------------------------------------- + +describe('Rust method chain binding via unified fixpoint (Phase 9C)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-method-chain-binding'), + () => {}, + ); + }, 60000); + + it('resolves city.save() to City#save via method chain', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'process_chain' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 55a19ef76..0d38f5bc2 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -1959,3 +1959,98 @@ describe('Write access tracking (TypeScript)', () => { } }); }); + +// --------------------------------------------------------------------------- +// Call-result variable binding (Phase 9): const user = getUser(); user.save() +// Activates Tier 2b pendingCallResults — binds return type at TypeEnv build time. +// --------------------------------------------------------------------------- + +describe('TypeScript call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-call-result-binding'), + () => {}, + ); + }, 60000); + + it('detects User class with save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('detects getUser function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + }); + + it('resolves user.save() to User#save via call-result binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves alias.save() to User#save via call-result + copy chain', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processAlias' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// JavaScript call-result variable binding (Phase 9) via JSDoc @returns +// --------------------------------------------------------------------------- + +describe('JavaScript call-result variable binding (Tier 2b)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'js-call-result-binding'), + () => {}, + ); + }, 60000); + + it('resolves user.save() to User#save via call-result binding with JSDoc', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Method chain binding (Phase 9C): getUser() → .address → .getCity() → .save() +// Unified fixpoint resolves field access + method-call-with-receiver at TypeEnv build time. +// --------------------------------------------------------------------------- + +describe('TypeScript method chain binding via unified fixpoint (Phase 9C)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-method-chain-binding'), + () => {}, + ); + }, 60000); + + it('detects User, Address, City classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('User'); + expect(classes).toContain('Address'); + expect(classes).toContain('City'); + }); + + it('resolves city.save() to City#save via 3-step chain (callResult → fieldAccess → methodCallResult)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.source === 'processChain' && c.targetFilePath.includes('models') + ); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/setup-skills.test.ts b/gitnexus/test/integration/setup-skills.test.ts index 9f60909e9..f6f35f76f 100644 --- a/gitnexus/test/integration/setup-skills.test.ts +++ b/gitnexus/test/integration/setup-skills.test.ts @@ -8,6 +8,7 @@ import { setupCommand } from '../../src/cli/setup.js'; describe('setupCommand skills integration', () => { let tempHome: string; const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; const testId = `${Date.now()}-${process.pid}`; const flatSkillName = `test-flat-skill-${testId}`; const dirSkillName = `test-dir-skill-${testId}`; @@ -17,6 +18,7 @@ describe('setupCommand skills integration', () => { beforeAll(async () => { tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-setup-home-')); process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; // os.homedir() checks USERPROFILE on Windows await fs.mkdir(path.join(tempHome, '.cursor'), { recursive: true }); // Create temporary source skills to verify both supported source layouts: @@ -44,6 +46,7 @@ describe('setupCommand skills integration', () => { await fs.rm(path.join(packageSkillsRoot, `${flatSkillName}.md`), { force: true }); await fs.rm(path.join(packageSkillsRoot, dirSkillName), { recursive: true, force: true }); process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; await fs.rm(tempHome, { recursive: true, force: true }); }); diff --git a/gitnexus/test/setup.ts b/gitnexus/test/setup.ts deleted file mode 100644 index e5336a2e6..000000000 --- a/gitnexus/test/setup.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Vitest per-file setup file (runs inside each forked worker). - * - * Unref's all active handles after each test file so the event loop can - * drain naturally. For non-native test files this is sufficient to let - * the fork exit. For LadybugDB test files, native C++ handles may not expose - * .unref() — CI handles this via process isolation (one vitest invocation - * per LadybugDB test file) so the OS reclaims everything on process exit. - * - * IMPORTANT: We do NOT import lbug-adapter here. Importing it would load - * the native addon even in non-LadybugDB test files, registering persistent - * handles that prevent the fork from exiting. - * - * IMPORTANT: We do NOT call process.exit() here. On Linux, process.exit() - * triggers N-API destructor hooks in the LadybugDB native addon that segfault - * (SIGSEGV), crashing the fork before it can send results back via IPC. - */ -import { afterAll } from 'vitest'; - -afterAll(() => { - try { - const handles = (process as any)._getActiveHandles?.(); - if (handles) { - for (const h of handles) { - if (typeof h.unref === 'function') h.unref(); - } - } - } catch {} -}); diff --git a/gitnexus/test/unit/cli-commands.test.ts b/gitnexus/test/unit/cli-commands.test.ts index 54923ca2b..81d1c90dc 100644 --- a/gitnexus/test/unit/cli-commands.test.ts +++ b/gitnexus/test/unit/cli-commands.test.ts @@ -24,7 +24,7 @@ describe('CLI commands', () => { const pkg = await import('../../package.json', { with: { type: 'json' } }); expect(pkg.default.scripts.test).toBeDefined(); expect(pkg.default.scripts['test:integration']).toBeDefined(); - expect(pkg.default.scripts['test:all']).toBeDefined(); + expect(pkg.default.scripts['test:unit']).toBeDefined(); }); it('has build script', async () => { diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index f3e829b31..7464aa9f5 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -10,12 +10,11 @@ */ import { describe, it, expect } from 'vitest'; import { - CYPHER_WRITE_RE, VALID_RELATION_TYPES, VALID_NODE_LABELS, - isWriteQuery, isTestFilePath, } from '../../src/mcp/local/local-backend.js'; +import { CYPHER_WRITE_RE, isWriteQuery } from '../../src/mcp/core/lbug-adapter.js'; // ─── Write-operation blocking (CYPHER_WRITE_RE) ────────────────────── diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index fcbebaba2..21b1810a1 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1602,6 +1602,8 @@ class RepoService { const mockSymbolTable = { lookupFuzzy: (name: string) => name === 'doStuff' ? [{ nodeId: 'n1', filePath: 'utils.kt', type: 'Function' }] : [], + lookupFuzzyCallable: () => [], + lookupFieldByOwner: () => undefined, lookupExact: () => undefined, lookupExactFull: () => undefined, add: () => {}, @@ -2241,10 +2243,11 @@ svc = App::Models::Service.new expect(env.get(scopeKey!)?.get('b')).toBe('User'); }); - it('does NOT resolve reverse-ordered Tier 2 chains (b = a, a = c, c: User)', () => { + it('resolves reverse-ordered Tier 2 chains via fixpoint (b = a, a = c, c: User)', () => { // Two chained Tier 2 assignments in reverse source order. - // Post-walk iterates source order: b = a (a not yet resolved) → fails, - // then a = c (c is Tier 0) → succeeds. b stays unresolved. + // The unified fixpoint loop resolves this in 2 iterations: + // Iter 1: a = c (c is Tier 0 → a = User) + // Iter 2: b = a (a now resolved → b = User) const tree = parse(` function process() { const b = a; @@ -2257,8 +2260,8 @@ svc = App::Models::Service.new expect(scopeKey).toBeDefined(); expect(env.get(scopeKey!)?.get('c')).toBe('User'); expect(env.get(scopeKey!)?.get('a')).toBe('User'); - // b should NOT resolve — reverse Tier 2 chain - expect(env.get(scopeKey!)?.get('b')).toBeUndefined(); + // Fixpoint now resolves reverse-ordered chains + expect(env.get(scopeKey!)?.get('b')).toBe('User'); }); }); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index c8b54e923..6a2956856 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -8,9 +8,13 @@ export default defineConfig({ hookTimeout: 120000, pool: 'forks', globals: true, - setupFiles: ['test/setup.ts'], teardownTimeout: 3000, - dangerouslyIgnoreUnhandledErrors: true, // LadybugDB N-API destructor segfaults on fork exit — not a test failure + // N-API destructors can crash worker forks on macOS during process exit. + // This is independent of the QueryResult lifetime fix in @ladybugdb/core 0.15.2 — + // it's a vitest forks + native addon interaction where destructors run in + // arbitrary order at exit. Tests themselves pass; only the exit crashes. + // TODO: remove once LadybugDB fixes all N-API destructor ordering issues. + dangerouslyIgnoreUnhandledErrors: true, // Coverage stays at root (not supported in project configs) coverage: { @@ -34,7 +38,13 @@ export default defineConfig({ // LadybugDB's native mmap addon causes file-lock conflicts when vitest // runs lbug test files in parallel forks on Windows. The 'lbug-db' - // project forces sequential execution; everything else runs in parallel. + // project forces sequential execution (fileParallelism: false). + // + // Each file runs in its own fork — the fork exits after the file + // completes, triggering an N-API destructor segfault that is caught + // by dangerouslyIgnoreUnhandledErrors. Tests themselves pass; only + // the exit crashes. This is safer than isolate: false, which causes + // native state corruption after 2-3 open/close cycles in the same fork. projects: [ { extends: true, @@ -51,12 +61,14 @@ export default defineConfig({ 'test/integration/augmentation.test.ts', ], fileParallelism: false, + sequence: { groupOrder: 1 }, }, }, { extends: true, test: { name: 'default', + sequence: { groupOrder: 2 }, include: ['test/**/*.test.ts'], exclude: [ 'test/integration/lbug-core-adapter.test.ts', diff --git a/type-resolution-roadmap.md b/type-resolution-roadmap.md index 32591d534..2450f0aa6 100644 --- a/type-resolution-roadmap.md +++ b/type-resolution-roadmap.md @@ -1,25 +1,12 @@ # Type Resolution Roadmap -This roadmap describes the next major capabilities needed to evolve GitNexus's type-resolution layer from a strong receiver-disambiguation aid into a broader static-analysis foundation. - -The roadmap assumes the current system already provides: - -- explicit type extraction from declarations and parameters -- initializer / constructor inference -- loop element inference for many languages -- selected pattern binding and narrowing -- comment-based fallbacks in JS/TS, PHP, and Ruby -- constrained return-type-aware receiver inference during call processing - -The remaining work is about **generalisation**, **deeper structure modelling**, and **better propagation**. +This roadmap describes the evolution of GitNexus's type-resolution layer from a receiver-disambiguation aid into a production-grade static-analysis foundation. --- -## Principles for Future Work +## Principles -The type system should continue to preserve the qualities that make it practical today: - -- **stay conservative** +- **stay conservative** — prefer missing a binding over introducing a misleading one - **prefer explainable inference over clever but brittle inference** - **limit performance overhead during ingestion** - **keep per-language extractors explicit rather than over-generic** @@ -29,435 +16,286 @@ The goal is not to build a compiler. The goal is to support high-value static an --- -## Near-Term Priority: Generalise Existing Inference +## Delivered Phases -The next biggest gain is not inventing a new type system layer. It is expanding the inference the system already performs so more constructs can benefit from it. +### Phase 7: Cross-Scope and Return-Aware Propagation ✅ -### Why this is the right next step +**Shipped in** `feat/phase7-type-resolution`. -Today, return-type-aware inference already exists in constrained form inside `call-processor.ts`, and loop element inference already handles many identifier-based iterables. +- `ReturnTypeLookup` interface threading return-type knowledge into TypeEnv +- Iterable call-expression support across 7 languages (Go, TS, Python, Rust, Java, Kotlin, C#) +- PHP class-level `@var` property typing for `$this->property` foreach (Strategy C) +- `pendingCallResults` infrastructure (Tier 2b loop + `PendingAssignment` union) — activated by Phase 9 -The most valuable next move is to let those signals participate in more places, especially: +### Phase 8: Field and Property Type Resolution ✅ -- iterable expressions rather than only iterable identifiers -- assignment propagation from call results -- doc-comment-derived file-scope bindings where local scope is insufficient +**Shipped in** `feat/phase8-field-property-type-resolution`. + +- SymbolTable `fieldByOwner` index — O(1) field lookup by `ownerNodeId\0fieldName` +- `HAS_PROPERTY` edge type + `declaredType` on Property symbols +- Deep chain resolution up to 3 levels (`user.address.city.getName()`) across 10 languages +- Mixed field+method chains via unified `MixedChainStep[]` (`svc.getUser().address.save()`) +- Type-preserving stdlib passthroughs (`unwrap`, `clone`, `expect`, etc.) +- `ACCESSES` edge type — read/write field access tracking across 12 languages +- C++ `field_declaration` capture, `field_expression` receiver support +- Rust unit struct instantiation, Ruby YARD `@return` for `attr_accessor` + +### Phase 9 + 9C: Return-Type-Aware Variable Binding ✅ + +**Shipped in** `feat/phase9-call-result-binding` (PR #379). + +- Simple call-result binding: `const user = getUser(); user.save()` across 11 languages +- Unified fixpoint loop replacing sequential Tier 2b/2a — handles 4 binding kinds (`callResult`, `copy`, `fieldAccess`, `methodCallResult`) at arbitrary depth +- Field access binding: `const addr = user.address` resolves via `lookupFieldByOwner` + `declaredType` +- Method-call-result binding: `const city = addr.getCity()` resolves via `lookupFuzzyCallable` filtered by `ownerId` +- Fixpoint iterates until stable (max 10 iterations), enabling chains like `getUser() → .address → .getCity() → city.save()` +- Reverse-order copy chains now resolve (`const b = a; const a: User = x` → both resolve) --- -## Phase 7: Cross-Scope and Return-Aware Propagation +## Open Phases -> **Status: COMPLETE** — shipped in `feat/phase7-type-resolution` (commits `ed767e3`, `ca4c6c1`, `d79237e`). +### Phase 10: Loop-Fixpoint Bridge -### Goal +**Supersedes Phase 9B.** For-loop element bindings run during `walk()` before the fixpoint. Variables typed by the fixpoint are invisible to for-loop extraction. -Allow loop inference and assignment inference to see more than the current function-local environment. - -### Problems this phase addresses - -#### 7A. Iterable expressions in Go and similar cases (shipped as Phase 7.3) - -```go -for _, user := range getUsers() { - user.Save() +**Problem:** +```typescript +const users = getUsers(); // fixpoint: users → User[] +for (const u of users) { // walk-time: users untyped → u unresolved + u.save(); // missed CALLS edge } ``` -The iterable is a call expression, not an identifier with a local binding. +**Approach:** Post-fixpoint for-loop replay. During `walk()`, store for-loop AST nodes whose iterable is unresolved. After fixpoint completes, replay `extractForLoopBinding` on those nodes using the now-resolved iterable types. -Resolved: `ReturnTypeLookup` introduced in Phase 7.1 exposes `lookupRawReturnType`. All seven typed-iteration languages (Go, TypeScript, Python, Rust, Java, Kotlin, C#) now unwrap the raw container type string to extract the element type when the iterable is a direct function call. +**Scope:** +- Infrastructure: `pendingForLoops` collection + replay (~20 lines in `type-env.ts`) +- No extractor changes — reuses existing `extractForLoopBinding` +- Swift: add for-loop element binding (currently missing) +- Nested for-loops with field-dependent iterables are NOT replayed (handled by call-processor chain resolution) -#### 7B. File-scope or class-scope iterable typing in PHP (shipped as Phase 7.4) +**Risks:** AST node lifetime (safe — nodes valid within `buildTypeEnv` lifetime). -```php -foreach ($this->users as $user) { - $user->save(); -} -``` - -If `$this->users` is typed through a class property annotation or file/class-scope doc-comment information, the current local-scope-only path may not be enough. - -Resolved: Strategy C in the PHP `extractForLoopBinding` walks up the AST to the enclosing `class_declaration`, scans the `declaration_list` for a matching `property_declaration`, and extracts the element type from the `@var` PHPDoc comment (or PHP 7.4+ native type field). The `@param` workaround previously required in the fixture is gone. - -#### 7C. Broader use of already-known return types (shipped as Phase 7.1 + 7.2) - -The system can already infer receiver types from uniquely resolved call results in `call-processor.ts`. That needs to be generalised so `TypeEnv` can benefit from it too. - -Resolved: `ReturnTypeLookup` (Phase 7.1) encapsulates `lookupReturnType` / `lookupRawReturnType` and is threaded through `ForLoopExtractorContext` (Phase 7.2) to all for-loop extractors. Phase 7.2 also added the `pendingCallResults` infrastructure (the `PendingAssignment` discriminated union in `types.ts` and the Tier 2b processing loop in `type-env.ts`), but no extractor populates it yet — `var x = f()` propagation is Phase 9 work. - -### Engineering direction (as implemented) - -- introduced `ReturnTypeLookup` interface and `buildReturnTypeLookup` factory in `type-env.ts` -- replaced per-extractor `(node, env)` signature with `ForLoopExtractorContext` context object for extensibility -- added `extractElementTypeFromString` to `shared.ts` as the canonical raw-string container unwrapper -- added PHP Strategy C helper (`findClassPropertyElementType`) scoped to the PHP extractor -- kept all changes backwards-compatible — explicit-type paths are untouched - -### Delivered impact - -- loop inference now works for direct function call iterables in all 7 typed-iteration languages -- PHP `$this->property` foreach is resolved from class-level `@var` without requiring `@param` workarounds -- `pendingCallResults` infrastructure is in place (Tier 2b loop + `PendingAssignment` union) — dormant until an extractor emits `{ kind: 'callResult' }` (Phase 9) - -### Risk level - -**Medium** (as predicted) - -The interface change touched all extractors but remained additive — no existing paths were changed. +**Impact: High | Effort: Low-Medium** --- -## Phase 8: Field and Property Type Resolution *(delivered)* +### Phase 11: Inheritance & this/self -### Goal +Four items sharing infrastructure. -Model class / struct fields so chained member access can be resolved more accurately. +#### 11A: MRO-aware field and method lookups -### Status +**Problem:** `lookupFieldByOwner` only finds direct fields. Inherited fields (`Admin extends User`, field on `User`) don't resolve. -**Delivered.** One-level, deep, and mixed field+method chain resolution is implemented across 9 languages. Pattern destructuring (8C) remains open. +**Approach:** Pre-compute `parentMap: Map` from EXTENDS + IMPLEMENTS edges. Pass to `buildTypeEnv`. Update `resolveFieldType` and `resolveMethodReturnType` to walk parent chain on miss (max depth 5, cycle-safe, first-match-wins for diamond inheritance). -#### What shipped +Includes IMPLEMENTS edges so interface-declared fields/methods resolve (Java, Kotlin, C#). -- **SymbolTable `fieldByOwner` index** — O(1) lookup via `ownerNodeId\0fieldName` key. Properties excluded from `globalIndex` to prevent namespace pollution. *(Q1 resolved)* -- **`HAS_PROPERTY` edge type** — split from `HAS_METHOD` to distinguish property linkage -- **`declaredType` field** on Property symbols — semantic split from `returnType` (methods) -- **`resolveFieldAccessType`** in call-processor — resolves field access chains at call sites -- **`extractPropertyDeclaredType`** in shared utils — 5-strategy cross-language type extraction -- **Per-language `@definition.property` captures** — see coverage table below -- **`extractMixedChain`** in utils — unified recursive AST walker that handles both `call_expression` and `field_expression` nodes interchangeably, building `MixedChainStep[]` capped at `MAX_CHAIN_DEPTH` (3). Replaces the earlier separate `extractFieldChain` / `extractCallChain` functions. -- **`receiverMixedChain`** on `ExtractedCall` — unified chain representation replacing the old `receiverCallChain` + `receiverFieldAccess` split -- **`ACCESSES` edge type** — read and write field/property access tracking. Read edges emitted via `walkMixedChain` chain resolution; write edges emitted via tree-sitter `@assignment` capture patterns across 12 languages (C excluded). PHP includes static property writes (`ClassName::$field`). Ruby compound assignment (`operator_assignment`) tracked. -- **Unified chain resolution** in call-processor — a single loop in both `processCalls` (sequential) and `processCallsFromExtracted` (worker) walks `MixedChainStep[]`, dispatching `kind: 'field'` to `resolveFieldAccessType` and `kind: 'call'` to `resolveCallTarget` + return type extraction -- **Type-preserving stdlib passthrough** — `unwrap()`, `expect()`, `clone()`, `as_ref()`, and similar stdlib methods that don't change the receiver type are recognized as identity operations in the chain loop, allowing chains like `user.unwrap().save()` to resolve correctly when TypeEnv has already stripped the nullable wrapper -- **C++ `field_declaration`** property capture via `field_identifier` declarator -- **C++ `field_expression` support** — tree-sitter-cpp uses `argument` (not `object`) for the receiver of `field_expression`; `extractMixedChain` handles this -- **C++ inline method double-indexing guard** — prevents `@definition.function` from creating duplicate symbol entries for methods already captured by `@definition.method` inside class/struct bodies (applied in both `parsing-processor.ts` and `parse-worker.ts`) -- **Rust unit struct instantiation** — `let svc = UserService;` (bare identifier assignment) now recognized by type-env when the RHS matches a known class/struct name -- **Ruby YARD `@return [Type]`** extraction for `attr_accessor` properties, enabling field-type resolution in dynamically typed Ruby +#### 11B: this/self in fixpoint -#### Language coverage +**Problem:** `this.field` and `this.method()` emit pending items with `receiver: 'this'`, but `scopeEnv.get('this')` returns `undefined`. -| Language | Property capture | `declaredType` extraction | Deep chain | Notes | -|----------|-----------------|--------------------------|:----------:|-------| -| TypeScript | ✅ `public_field_definition`, `private_property_identifier`, `required_parameter` | ✅ Strategy 2 (type_annotation) | ✅ | Parameter properties added | -| JavaScript | ✅ `field_definition` | ⚠️ No type annotations in JS | — | Capture added; declaredType requires JSDoc | -| Java | ✅ `field_declaration` | ✅ Strategy 3 (parent type) | ✅ | | -| C# | ✅ `property_declaration` | ✅ Strategy 1 (type field) | ✅ | | -| Go | ✅ `field_declaration` | ✅ Strategy 1 (type field) | ✅ | | -| Kotlin | ✅ `property_declaration` | ✅ Strategy 4 (variable_declaration) | ✅ | New strategy added | -| PHP | ✅ `property_declaration` | ✅ Strategy 1 + PHPDoc @var fallback | ✅ | Strategy 5 for pre-7.4 | -| Rust | ✅ `field_declaration` | ✅ Strategy 1 (type field) | ✅ | `extractMemberAccessParts` handles `field_expression` via `value`/`field` | -| Python | ✅ `assignment` with `type` | ✅ Class-level annotations | ✅ | `self.x` instance pattern not yet supported | -| Ruby | ✅ `attr_*` via call routing | ✅ YARD `@return [Type]` | — | YARD fallback for dynamically typed properties | -| C++ | ✅ `field_declaration` via `field_identifier` | ✅ Strategy 1 (type field) | ✅ | | -| Swift | ✅ `property_declaration` | ⚠️ Untested | — | | +**Approach:** At collection time during `walk()`, resolve the enclosing class name immediately via `findEnclosingClassName()` and substitute it as the receiver. Covers both `fieldAccess` and `methodCallResult`. No fixpoint changes needed. 5-10 lines per extractor. -#### What remains open +#### 11C: Go inc/dec write access -- **8C. Pattern destructuring** dependent on field knowledge -- Python `self.x` instance attribute pattern +**Problem:** `obj.field++`/`obj.field--` produce `inc_statement`/`dec_statement` — write-access tracking doesn't see them. -### Problems this phase addresses +**Approach:** Add these node types to call-processor write detection (~5 lines). -#### 8A. Deep property chains *(delivered)* +#### 11D: Swift assignment chains -```typescript -user.address.city.getName() -``` +**Problem:** Swift has no `extractPendingAssignment` — copy/callResult/fieldAccess/methodCallResult don't work. -✅ `extractFieldChain` recursively walks nested member_expression nodes at parse time, building a `fieldChain: string[]`. At resolution time, the chain is walked step-by-step: `user → User`, `address → Address`, `city → City`, `getName() → City#getName`. Supported across TS, Java, C#, Go, Kotlin, PHP, C++. +**Approach:** Implement `extractPendingAssignment` for Swift covering all 4 binding kinds. -#### 8B. Mixed field+method chain resolution *(delivered)* +**Risks:** Performance of parent chain walking in fixpoint (bounded by `n_pending × depth × iterations`). Interface method ambiguity (mitigated by checking direct class first, parents on miss). -```typescript -svc.getUser().address.save() // call → field → call -user.getAddress().city.getName() // call → field → call -user.address.getCity().save() // field → call → call -user.unwrap().save() // stdlib passthrough → call -``` - -✅ `extractMixedChain` walks both call-expression and field-expression nodes in a single unified pass, producing `MixedChainStep[]`. The resolver walks steps left-to-right: `kind: 'field'` resolves via `resolveFieldAccessType`, `kind: 'call'` resolves via `resolveCallTarget` + return type extraction. Stdlib passthroughs (`unwrap`, `clone`, `expect`, etc.) are recognized as type-preserving identity operations. - -#### 8C. Pattern destructuring that depends on field knowledge - -This is especially relevant for: - -- Rust struct-pattern destructuring -- PHP chained property access -- richer TypeScript or Python object-based destructuring in future work - -### Engineering direction (as implemented) - -- ~~parse field / property declarations per class or struct~~ ✅ -- ~~build a field-type map keyed by owning type~~ ✅ (`fieldByOwner` index) -- ~~teach lookup and chain-resolution logic to walk member segments (deep chains)~~ ✅ (`extractMixedChain` + unified chain-walking loop) -- ~~unify field chains and call chains into a single representation~~ ✅ (`MixedChainStep[]` replaces separate `receiverCallChain` / `receiverFieldAccess`) -- ~~C++ struct member field capture~~ ✅ (`field_declaration` via `field_identifier`) -- ~~C++ `field_expression` receiver extraction~~ ✅ (`argument` field support in `extractMixedChain`) -- ~~Rust unit struct instantiation~~ ✅ (`let svc = TypeName;` recognized by type-env) -- ~~Ruby YARD `@return` for `attr_accessor`~~ ✅ (comment-walking in `call-routing.ts`) -- ~~stdlib passthrough methods~~ ✅ (`TYPE_PRESERVING_METHODS` set in call-processor) -- keep this separate from the base variable-binding layer where possible - -### Delivered impact - -This is the biggest unlock for richer static analysis because it allows the graph to model more than just top-level receivers. - -It materially improved: - -- chained property resolution (up to 3 levels deep) -- mixed field+method chain resolution (e.g. `svc.getUser().address.save()`) -- member-based call disambiguation across 9 languages -- deeper context extraction for downstream tooling -- C++ struct/class field visibility in the knowledge graph -- C++ chained method call resolution (previously blocked by missing `argument` field support) -- Rust nullable receiver chains (`user.unwrap().save()`) -- Ruby field-type resolution via YARD documentation - -### Risk level - -**High** (delivered — risk was managed through incremental delivery across 8, 8A, 8B) - -This phase pushed the system from variable typing into structural object modelling. Remaining work: - -- careful handling of inheritance / embedding / language-specific member semantics -- pattern destructuring dependent on field knowledge (8C) +**Impact: Medium-High | Effort: Medium** --- -## Phase 9: Full Return-Type-Aware Variable Binding +### Phase 12: Destructuring -### Goal +**Problem:** `const { address, name } = user` produces no bindings — LHS is a pattern node, not an identifier. -Make return-type-driven inference a first-class input to `TypeEnv`, not just a downstream verification path. +**Approach:** Add `{ kind: 'destructure', source, bindings: [{ varName, fieldName }] }` to `PendingAssignment`. In the fixpoint, when source's type resolves, look up each field via `lookupFieldByOwner` and bind each variable. -### Problems this phase addresses +Works without Phase 11A (direct fields only). Phase 11A MRO enhances it to resolve inherited fields too. -#### 9A. Binding variables from call results +**Phased delivery:** -```typescript -const users = repo.getUsers() -``` +| Sub-phase | Scope | Languages | +|-----------|-------|-----------| +| **12A** | Object destructuring | TS/JS (`object_pattern`) | +| **12B** | Struct pattern destructuring | Rust (`struct_pattern`) | +| **12C** (deferred) | Positional destructuring | Python, Kotlin, C#, C++ — needs tuple-position-to-field mapping | -Desired binding: +Skip: computed properties, rest elements, nested destructuring (`{ address: { city } }` — deferred). -- `users -> List` +**Risks:** Nested destructuring requires recursive resolution (explicitly deferred). -#### 9B. Looping directly over call results - -```typescript -for (const user of getUsers()) { - user.save() -} -``` - -Desired binding: - -- `user -> User` - -#### 9C. Broader method-chain inference - -```typescript -repo.getUsers().first() -``` - -If return types can propagate more systematically, later chain stages become much more resolvable. - -### Engineering direction - -- expose return types as reusable inference inputs inside `TypeEnv` -- distinguish raw textual return types from normalized receiver-usable types -- make method-call return inference receiver-aware where necessary -- avoid over-eager propagation when multiple call targets remain ambiguous - -### Expected impact - -This phase would make the type system feel much closer to a static-analysis substrate rather than a set of local heuristics. - -It will especially improve codebases that rely heavily on: - -- service-returned collections -- builder APIs -- repository methods -- chain-heavy fluent interfaces - -### Risk level - -**Medium to High** - -The conceptual basis already exists, but generalising it without introducing false bindings requires careful ambiguity rules. +**Impact: Medium | Effort: Medium** --- -## Language-Specific Gaps +### Phase 13: Branch-Sensitive Narrowing + +**Design principle:** Targeted narrowing, not general control-flow analysis. Skip anything that requires a control-flow graph. + +**Phased delivery:** + +#### 13A: Type predicate functions (TS only) + +`function isUser(x: unknown): x is User` — detect `type_predicate` return type. When called in an `if` condition, emit pattern binding for the narrowed parameter. + +#### 13B: Nullability narrowing (TS/Kotlin/C#/Swift) + +`if (x != null)` → strip nullable wrapper in truthy branch. Uses existing `patternOverrides` mechanism (position-indexed, scope-aware). Swift `guard let` uses standard scopeEnv (narrowing persists for rest of function). Swift work requires Phase 11D (assignment chains) first. + +#### 13C: Discriminated union narrowing (deferred) + +`if (shape.kind === 'circle')` → needs tagged union metadata not in SymbolTable. Defer. + +**What we skip entirely:** Full control-flow graph, arbitrary conditional narrowing, `typeof` guards, exhaustiveness checking. + +**Risks:** Scope leakage (mitigated by `patternOverrides` position indexing). Swift `guard let` needs scopeEnv path (different from `patternOverrides`). + +**Impact: Medium | Effort: Medium-High** + +--- + +### Phase 14: Cross-File Binding Propagation + +**Problem:** `buildTypeEnv` is per-file. Inferred types don't cross file boundaries. + +```typescript +// file-a.ts — fixpoint resolves: config → Config +export const config = getConfig(); + +// file-b.ts — config has no type +import { config } from './file-a'; +config.validate(); // missed +``` + +**Approach: Export-type index.** After each file's fixpoint, export resolved bindings for exported symbols into `ExportedBindings: Map>`. Subsequent files seed scopeEnv from this index for imported symbols. + +**Details:** +- Process files in topological import order (import-processor already builds the dependency graph) +- Re-exports: follow import chain transitively in `ExportedBindings` +- Barrel files (`index.ts`): chain of re-exports — same mechanism +- Default exports: keyed as `"default"` in the map, mapped to local name at import site +- Dynamic imports (`import()`, conditional `require()`): excluded — runtime-only edges +- Circular imports: files in a cycle processed in arbitrary order within the cycle; cross-cycle bindings don't propagate (conservative) +- Parallelism preserved within topological levels + +**Why this is last:** Every earlier phase makes the per-file fixpoint stronger, reducing cases where cross-file propagation is needed. This is also the highest-risk architectural change. + +**Risks:** Topological ordering correctness (mitigated by reusing import-processor's existing graph). Re-export chain depth (bounded by import depth, typically 2-3). Memory for `ExportedBindings` (~100K entries for 10K-file monorepo — negligible). + +**Impact: High | Effort: High** + +--- + +## Dependency Graph + +``` +Phase 10 (loops) ──────────────────────┐ + │ +Phase 11 (MRO + this + Go + Swift) ───┤ + ├──→ Phase 14 (cross-file) +Phase 12 (destructuring) ─────────────┤ + │ +Phase 13 (branch narrowing) ───────────┘ + +Phases 10–13 are independent of each other. + Exception: Phase 13B Swift (guard let) requires Phase 11D (Swift assignment chains). + Exception: Phase 12 benefits from Phase 11A (MRO) but works without it. +Phase 14 depends on all of 10–13 being stable. +Swift parity threaded through Phases 10–13 incrementally. +``` + +--- + +## Language-Specific Gaps (remaining) ### Swift - -Current support remains relatively minimal. - -Missing or weak areas include: - -- for-loop element binding -- pattern binding -- assignment-chain propagation -- broader expression-based inference - -**Priority:** Medium -**Reason:** It matters for parity, but the biggest global analysis gains are elsewhere. +- For-loop element binding → Phase 10 +- Assignment chains (copy, callResult, fieldAccess, methodCallResult) → Phase 11D +- Pattern binding → Phase 13B (`guard let`) ### Go - -Key remaining gaps: - -- ~~iterable call expressions in range loops~~ ✓ shipped in Phase 7.3 -- `obj.field++` / `obj.field--` produce `inc_statement`/`dec_statement` nodes (not `assignment_statement`), so write ACCESSES edges are not emitted for increment/decrement on struct fields - -**Priority:** Medium (chained property access remains for Phase 8) - -### PHP - -Key remaining gaps: - -- ~~file/class-scope iterable propagation~~ ✓ shipped in Phase 7.4 (Strategy C) -- chained property access - -**Priority:** High -**Reason:** PHP heavily benefits from doc-comment-aware field and property modelling. +- `obj.field++`/`obj.field--` write ACCESSES → Phase 11C ### Rust - -Key remaining gap: - -- struct-pattern field destructuring - -**Priority:** Medium -**Reason:** Important for completeness, but field-type infrastructure is the real prerequisite. +- Struct-pattern field destructuring → Phase 12B ### All languages - -Shared missing capabilities: - -- ~~field / property type resolution~~ ✓ shipped in Phase 8 + 8A (10 languages) -- ~~mixed field+method chain resolution~~ ✓ shipped in Phase 8B (unified `MixedChainStep[]`) -- generalised return-type-aware binding in `TypeEnv` (Phase 9) - -**Priority:** High -**Reason:** Return-type propagation is the biggest remaining blocker to deeper static analysis. +- Inherited field/method resolution → Phase 11A +- `this`/`self` in fixpoint → Phase 11B +- Cross-file binding propagation → Phase 14 --- -## Recommended Delivery Order +## Milestones -### ~~1. Generalise existing return and loop inference~~ ✅ Phase 7 +### Milestone A — Inference Expansion ✅ (Phase 7) -Delivered. Iterable call-expression support, `ReturnTypeLookup`, file-scope binding, PHP Strategy C. +Loop inference, `ReturnTypeLookup`, PHP Strategy C. -### ~~2. Add field / property type maps~~ ✅ Phase 8 + 8A + 8B +### Milestone B — Structural Member Typing ✅ (Phase 8) -Delivered. Per-type field metadata, deep chain resolution (up to 3 levels), mixed field+method chains, type-preserving stdlib passthrough, C++ and Rust fixes. +Field/property maps, deep chains, mixed chains, stdlib passthroughs. -### 3. Promote return types into first-class `TypeEnv` inputs ← **next** +### Milestone C — Static-Analysis Foundation ✅ (Phase 9 + 9C) -This converts existing downstream validation into a broader inference capability. +Unified fixpoint loop, call-result binding, field access binding, method-call-result binding, arbitrary-depth chain propagation. -Deliverables: +### Milestone D — Completeness ← **next** (Phases 10–13) -- call-result variable binding (`var x = f()` propagation) -- loop inference from call results (already done for direct iterables, pending for assigned results) -- broader chain propagation +Loop-fixpoint bridge, inheritance walking, `this`/`self` resolution, destructuring, branch narrowing, Swift parity. -### 4. Broaden branch-sensitive narrowing where low-risk +### Milestone E — Cross-Boundary (Phase 14) -After the structural work lands, selective branch refinement becomes more valuable and easier to reason about. +Export-type index, cross-file binding propagation. --- -## What “Production-Grade Static Analysis” Means Here +## Open Design Questions -For GitNexus, production-grade does **not** mean replacing a language compiler. - -A realistic target is: - -- strong receiver-constrained call resolution across common language idioms -- reliable handling of typed loops, constructor-like initializers, and common patterns -- useful return-type propagation for service/repository style code -- enough field/property knowledge to support chained-member analysis -- conservative behavior under ambiguity -- predictable performance during indexing - -That would be sufficient for: - -- better call graphs -- more accurate impact analysis -- stronger context assembly for AI workflows -- more trustworthy graph traversal features +| # | Question | Status | +|---|----------|--------| +| 1 | Where should field-type metadata live? | ✅ Resolved: `fieldByOwner` index in SymbolTable | +| 2 | How should ambiguity be represented? | ✅ Resolved: keep `undefined`. Conservative approach proven through 9 phases. | +| 3 | How much receiver context for return types? | ✅ Resolved: Phase 9C `resolveMethodReturnType` filters by `ownerId`. | +| 4 | How much branch sensitivity? | ✅ Resolved: type predicates + null checks only. No control-flow graph. (Phase 13) | +| 5 | Field typing and chain typing — one phase or two? | ✅ Resolved: incremental delivery within phases (Phase 8/8A precedent). | +| 6 | Phase 9B vs Phase 10? | ✅ Resolved: Phase 10 supersedes 9B via post-fixpoint replay. | --- -## Suggested Milestone Definitions +## What "Production-Grade" Means Here -### Milestone A — Inference Expansion ✅ +For GitNexus, production-grade does **not** mean replacing a language compiler. The target: -Delivered in Phase 7. +- Strong receiver-constrained call resolution across common language idioms +- Reliable handling of typed loops, constructors, and common patterns +- Return-type propagation for service/repository code +- Field/property knowledge for chained-member analysis +- Inheritance-aware lookups +- Conservative behavior under ambiguity +- Predictable performance during indexing -- loop inference works for identifier iterables and common call-expression iterables across 7 languages -- `ReturnTypeLookup` threads return-type knowledge into TypeEnv -- PHP class-level `@var` property typing for `$this->property` foreach - -### Milestone B — Structural Member Typing ✅ - -Delivered in Phase 8 + 8A + 8B. - -- field/property maps exist for class-like types across 9 languages -- deep chains resolve up to 3 levels (`user.address.city.getName()`) -- mixed field+method chains resolve interleaved patterns (`svc.getUser().address.save()`) -- stdlib passthroughs (`unwrap`, `clone`, etc.) are type-preserving in chains -- C++ and Rust chain call resolution fixed (field_expression argument, unit struct) - -### Milestone C — Static-Analysis Foundation ← **next** - -Success looks like: - -- return-type-aware variable binding is a first-class part of environment construction -- chains, loops, and assignments share a coherent propagation model -- downstream graph features can rely on more than local receiver heuristics - ---- - -## Open Questions for Future Design - -These should be resolved before or during implementation of the later phases. - -1. **Where should field-type metadata live?** - ✅ Resolved: in `SymbolTable` via the `fieldByOwner` index, keyed by `ownerNodeId\0fieldName`. Properties live alongside other symbols but are excluded from `globalIndex` to prevent namespace pollution. - -2. **How should ambiguity be represented?** - Is `undefined` sufficient, or do later phases need a richer "known ambiguous" state? - -3. **How much receiver context should return-type inference require?** - Some methods only become meaningful once the receiver type is already partially known. - -4. **How much branch sensitivity is worth the complexity?** - Some narrowing gives clear value; full control-flow typing likely does not. - -5. **Should field typing and chain typing be one phase or two?** - ✅ Resolved: delivered as Phase 8 (single-level) + Phase 8A (deep chains) in the same branch, with separate test suites per language. Incremental delivery within one phase worked well. +That supports: better call graphs, more accurate impact analysis, stronger AI context assembly, more trustworthy graph traversal. --- ## Summary -Phases 7 and 8 (including 8A and 8B) are **complete**. The type system now handles: +**Complete:** Phases 7, 8, 9, 9C — explicit types, constructor inference, loop inference, field/property resolution, deep chains, mixed chains, stdlib passthroughs, comment-based types, unified fixpoint with 4 binding kinds, arbitrary-depth chain propagation across 11 languages. -- ✅ explicit type annotations and parameters across 13 languages -- ✅ initializer/constructor inference with SymbolTable validation -- ✅ loop element inference including call-expression iterables (7 languages) -- ✅ field/property type resolution with deep chains (up to 3 levels, 10 languages) -- ✅ mixed field+method chains (`svc.getUser().address.save()`) -- ✅ type-preserving stdlib passthroughs (`unwrap`, `clone`, `expect`, etc.) -- ✅ comment-based types (JSDoc, PHPDoc, YARD) +**Next:** Phase 10 (loop-fixpoint bridge) → Phase 11 (MRO + this/self + Go + Swift) → Phase 12 (destructuring) → Phase 13 (branch narrowing) → Phase 14 (cross-file propagation). -**The next step is Phase 9**: promote return-type-aware inference into `TypeEnv` as a first-class input, enabling `var x = f()` variable binding and broader chain propagation. The `pendingCallResults` infrastructure is already in place (Tier 2b loop + `PendingAssignment` union) — it just needs extractors to emit `{ kind: 'callResult' }` entries. - -That path preserves the current strengths of the system while moving GitNexus the final step toward a robust, production-grade static-analysis foundation. +Each phase is independently deliverable (except Phase 14 which depends on 10–13 being stable). Swift parity is threaded incrementally through Phases 10–13. diff --git a/type-resolution-system.md b/type-resolution-system.md index 9f2357e41..7f6e4da51 100644 --- a/type-resolution-system.md +++ b/type-resolution-system.md @@ -7,7 +7,7 @@ When the code contains a call such as `user.save()`, the resolver tries to deter This system is designed to be: - **Conservative** — it prefers missing a binding over introducing a misleading one -- **Single-pass** — bindings are collected during a single AST walk, with a limited post-pass for assignment propagation +- **Walk + fixpoint** — bindings are collected during a single AST walk, then a unified fixpoint loop iterates over pending assignments (copy, callResult, fieldAccess, methodCallResult) until no new bindings are produced - **Scope-aware** — function-local bindings are isolated from file-level bindings - **Per-file** — the environment is built for one file at a time, though it may consult the global `SymbolTable` for validation in specific cases @@ -292,16 +292,20 @@ const alias = user const other = alias ``` -This is handled after the main walk through a single pass over pending assignments. - -This supports simple forward propagation, but there is no iterative fixpoint step. For example: +This is handled after the main walk through a unified fixpoint loop over all pending assignments (copy, callResult, fieldAccess, methodCallResult). The loop iterates until no new bindings are produced (max 10 iterations), enabling arbitrary-depth mixed chains and reverse-order resolution: ```typescript -const b = a -const a: User = getUser() +const b = a // iteration 2: b → User (a now resolved) +const a: User = getUser() // iteration 1: a → User ``` -will not resolve `b`. +Both `a` and `b` resolve correctly. The fixpoint also handles chains mixing field access and method calls: + +```typescript +const user = getUser() // callResult → User +const addr = user.address // fieldAccess → Address +const city = addr.getCity() // methodCallResult → City +``` --- @@ -359,7 +363,7 @@ A key detail is that some initializer bindings are not fully resolved inside `Ty - validated class / struct constructor candidates - uniquely resolved function or method calls that expose a usable return type -So return-type-aware receiver inference already exists in a constrained downstream form today. Phase 7.3 extended this by threading `ReturnTypeLookup` into `TypeEnv` via `ForLoopExtractorContext`, enabling for-loop call-expression iterables (e.g., `for (const u of getUsers())`) to resolve element types in 7 languages (TS/JS, Java, Kotlin, C#, Go, Rust, Python, PHP). General assignment propagation (`var x = f()` binding the return type of `f` into the scope env) remains pending — the `pendingCallResults` infrastructure exists but is dormant until Phase 9. +So return-type-aware receiver inference already exists in a constrained downstream form today. Phase 7.3 extended this by threading `ReturnTypeLookup` into `TypeEnv` via `ForLoopExtractorContext`, enabling for-loop call-expression iterables (e.g., `for (const u of getUsers())`) to resolve element types in 7 languages (TS/JS, Java, Kotlin, C#, Go, Rust, Python, PHP). Phase 9 activated simple call-result binding (`var x = f()`) across all 11 supported languages (Swift excluded). Phase 9C replaced the sequential Tier 2b/2a with a unified fixpoint loop that handles four binding kinds — `callResult`, `copy`, `fieldAccess`, and `methodCallResult` — iterating until no new bindings are produced. This enables arbitrary-depth mixed chains like `const user = getUser(); const addr = user.address; const city = addr.getCity(); city.save()`. --- @@ -377,6 +381,9 @@ So return-type-aware receiver inference already exists in a constrained downstre | Field/property type resolution | Yes | No† | Yes | Yes | Yes | Yes | Yes | Yes* | Yes | YARD | No | Yes | No‡ | | Comment-based types | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | | Return type extraction | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | +| Call-result variable binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | No | Yes | No | +| Field access binding | Yes | No† | Yes | Yes | Yes | Yes | Yes | No‖ | Yes | N/A | No | Yes | No | +| Method-call-result binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | No | Yes | No | | Write access (ACCESSES write) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes§ | Yes | Yes | Yes | No | \* Python class-level annotated attributes (`address: Address`) now resolve `declaredType` correctly. The `self.x` instance attribute pattern is not yet supported. @@ -385,6 +392,12 @@ So return-type-aware receiver inference already exists in a constrained downstre ‡ C has no `@definition.property` query pattern. Struct member fields are not captured. C++ captures class/struct member fields via `field_declaration`. +¶ Ruby call-result and method-call-result binding work via `call`/`method_call` nodes. Ruby uses method calls for both field access and method calls — there is no separate field access node type. + +‖ Python class-level annotated attributes (`address: Address`) have `declaredType`, but `self.x` instance attributes do not. Field access binding only works for class-level annotated fields. + +**Note on `this`/`self`/`$this` receivers:** Field access and method-call-result binding with `this`/`self`/`$this` as the receiver do not resolve in the fixpoint loop because these keywords are not stored in `scopeEnv`. They are resolved on-demand at call sites via `findEnclosingClassName()` AST walk. This is consistent across all languages and not a regression. + § PHP write access covers instance property writes (`$obj->field = value`) and static property writes (`ClassName::$field = value`). Nullsafe writes (`$obj?->field = value`) are not tracked because this is invalid PHP syntax — null-safe member access on the left-hand side of assignment is a parse error. --- @@ -414,11 +427,12 @@ This is enough to materially improve call-edge precision even without implementi Important gaps still remain: - no general cross-file propagation of inferred bindings -- no fixpoint inference +- `this`/`self`/`$this` receivers are not resolved in the fixpoint loop (resolved on-demand at call sites via AST walk instead) - limited branch-sensitive narrowing outside selected pattern constructs - limited Swift support compared with other languages - no complete destructuring-based field typing -- no broad expression-level return-type propagation inside `TypeEnv` (for-loop call-expression iterables are resolved in 7 languages via `ReturnTypeLookup`, but general `var x = f()` assignment propagation is pending) +- no MRO/inheritance walking for field lookups (`lookupFieldByOwner` is direct-only) +- for-loop variables bound at walk time cannot see fixpoint-resolved types (Phase 9B gap) ---