mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
217efcf015
88 changed files with 2257 additions and 1315 deletions
199
.github/workflows/ci-integration.yml
vendored
199
.github/workflows/ci-integration.yml
vendored
|
|
@ -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
|
||||
522
.github/workflows/ci-report.yml
vendored
522
.github/workflows/ci-report.yml
vendored
|
|
@ -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<filled; i++)); do bar+="█"; done
|
||||
for ((i=0; i<empty; i++)); do bar+="░"; done
|
||||
# Green if >= 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<<GITNEXUS_CI_REPORT_EOF_7f3a"
|
||||
echo "## CI Report"
|
||||
echo ""
|
||||
echo "${OVERALL}"
|
||||
echo ""
|
||||
echo "### Pipeline Status"
|
||||
echo ""
|
||||
echo "| Stage | Status | Details |"
|
||||
echo "|-------|--------|---------|"
|
||||
echo "| $(status_icon "$QUALITY") Typecheck | \`${QUALITY}\` | tsc --noEmit |"
|
||||
echo "| $(status_icon "$UNIT") Unit Tests | \`${UNIT}\` | 3 platforms |"
|
||||
echo "| $(status_icon "$INTEG") Integration | \`${INTEG}\` | 3 OS x 4 groups = 12 jobs |"
|
||||
echo ""
|
||||
|
||||
if [ "$TOTAL" -gt 0 ] 2>/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 "<details>"
|
||||
echo "<summary>${SKIPPED} test(s) skipped — expand for details</summary>"
|
||||
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 "</details>"
|
||||
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 "<details>"
|
||||
echo "<summary>Coverage breakdown by test suite</summary>"
|
||||
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 "</details>"
|
||||
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 "<sub>📋 [View full run](${RUN_URL}) · Generated by CI</sub>"
|
||||
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 }}
|
||||
|
|
@ -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
|
||||
321
.github/workflows/ci.yml
vendored
321
.github/workflows/ci.yml
vendored
|
|
@ -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<details>\n<summary>${skipped} test(s) skipped</summary>\n\n`;
|
||||
body += skippedTests.join('\n') + '\n\n</details>\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<sub>📋 [Full run](${runUrl}) · Coverage from Ubuntu · Generated by CI</sub>`;
|
||||
|
||||
// ── 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 = '<!-- ci-report -->';
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
1
.github/workflows/publish.yml
vendored
1
.github/workflows/publish.yml
vendored
|
|
@ -12,6 +12,7 @@ jobs:
|
|||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
publish:
|
||||
|
|
|
|||
12
AGENTS.md
12
AGENTS.md
|
|
@ -1,7 +1,7 @@
|
|||
<!-- gitnexus:start -->
|
||||
# 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: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — 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
|
||||
|
||||
|
|
|
|||
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -1,7 +1,7 @@
|
|||
<!-- gitnexus:start -->
|
||||
# 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: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<SupportedLanguages>` 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
|
||||
|
|
|
|||
71
gitnexus/package-lock.json
generated
71
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, string>, 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<string, string>, 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<T, E> 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<number>();
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> = new Set([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<void> = Promise.resolve();
|
||||
|
|
|
|||
|
|
@ -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<string, PoolEntry>();
|
||||
|
|
@ -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<string, SharedDB>();
|
||||
|
||||
|
|
@ -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<void> 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<void> {
|
|||
// 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<void> {
|
||||
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<lbug.Connection> {
|
|||
|
||||
/**
|
||||
* 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<any[
|
|||
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
|
||||
}
|
||||
|
||||
if (isWriteQuery(cypher)) {
|
||||
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
|
||||
}
|
||||
|
||||
entry.lastUsed = Date.now();
|
||||
|
||||
const conn = await checkout(entry);
|
||||
|
|
@ -423,3 +526,11 @@ export const closeLbug = async (repoId?: string): Promise<void> => {
|
|||
* 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
6
gitnexus/test/fixtures/lang-resolution/cpp-call-result-binding/app.cpp
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-call-result-binding/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "user.h"
|
||||
|
||||
void processUser() {
|
||||
auto user = getUser("alice");
|
||||
user.save();
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/cpp-call-result-binding/user.h
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/cpp-call-result-binding/user.h
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
|
||||
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);
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/cpp-method-chain-binding/app.cpp
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/cpp-method-chain-binding/app.cpp
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include "models.h"
|
||||
|
||||
void processChain() {
|
||||
auto user = getUser();
|
||||
auto addr = user.address;
|
||||
auto city = addr.getCity();
|
||||
city.save();
|
||||
}
|
||||
26
gitnexus/test/fixtures/lang-resolution/cpp-method-chain-binding/models.h
vendored
Normal file
26
gitnexus/test/fixtures/lang-resolution/cpp-method-chain-binding/models.h
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
|
||||
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")));
|
||||
}
|
||||
22
gitnexus/test/fixtures/lang-resolution/csharp-call-result-binding/App.cs
vendored
Normal file
22
gitnexus/test/fixtures/lang-resolution/csharp-call-result-binding/App.cs
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
29
gitnexus/test/fixtures/lang-resolution/csharp-method-chain-binding/App.cs
vendored
Normal file
29
gitnexus/test/fixtures/lang-resolution/csharp-method-chain-binding/App.cs
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
12
gitnexus/test/fixtures/lang-resolution/go-call-result-binding/cmd/main.go
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/go-call-result-binding/cmd/main.go
vendored
Normal file
|
|
@ -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()
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-call-result-binding/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-call-result-binding/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/callresult
|
||||
|
||||
go 1.21
|
||||
9
gitnexus/test/fixtures/lang-resolution/go-call-result-binding/models/user.go
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/go-call-result-binding/models/user.go
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package models
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (u *User) Save() bool {
|
||||
return true
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/cmd/main.go
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/cmd/main.go
vendored
Normal file
|
|
@ -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()
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/methodchain
|
||||
|
||||
go 1.21
|
||||
21
gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/models/user.go
vendored
Normal file
21
gitnexus/test/fixtures/lang-resolution/go-method-chain-binding/models/user.go
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/java-call-result-binding/App.java
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/java-call-result-binding/App.java
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
public class App {
|
||||
static User getUser(String name) {
|
||||
return new User(name);
|
||||
}
|
||||
|
||||
void processUser() {
|
||||
var user = getUser("alice");
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/java-call-result-binding/User.java
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/java-call-result-binding/User.java
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
public class User {
|
||||
private String name;
|
||||
|
||||
public User(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public boolean save() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
12
gitnexus/test/fixtures/lang-resolution/java-method-chain-binding/App.java
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/java-method-chain-binding/App.java
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
16
gitnexus/test/fixtures/lang-resolution/java-method-chain-binding/Models.java
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/java-method-chain-binding/Models.java
vendored
Normal file
|
|
@ -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; }
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/js-call-result-binding/app.js
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/js-call-result-binding/app.js
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
const { getUser } = require('./service');
|
||||
|
||||
function processUser() {
|
||||
const user = getUser('alice');
|
||||
user.save();
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/js-call-result-binding/models.js
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/js-call-result-binding/models.js
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class User {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
save() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { User };
|
||||
11
gitnexus/test/fixtures/lang-resolution/js-call-result-binding/service.js
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/js-call-result-binding/service.js
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const { User } = require('./models');
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {User}
|
||||
*/
|
||||
function getUser(name) {
|
||||
return new User(name);
|
||||
}
|
||||
|
||||
module.exports = { getUser };
|
||||
14
gitnexus/test/fixtures/lang-resolution/kotlin-call-result-binding/User.kt
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/kotlin-call-result-binding/User.kt
vendored
Normal file
|
|
@ -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()
|
||||
}
|
||||
18
gitnexus/test/fixtures/lang-resolution/kotlin-method-chain-binding/Models.kt
vendored
Normal file
18
gitnexus/test/fixtures/lang-resolution/kotlin-method-chain-binding/Models.kt
vendored
Normal file
|
|
@ -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()
|
||||
}
|
||||
22
gitnexus/test/fixtures/lang-resolution/php-call-result-binding/App.php
vendored
Normal file
22
gitnexus/test/fixtures/lang-resolution/php-call-result-binding/App.php
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
class User {
|
||||
public string $name;
|
||||
|
||||
public function __construct(string $name) {
|
||||
$this->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();
|
||||
}
|
||||
28
gitnexus/test/fixtures/lang-resolution/php-method-chain-binding/App.php
vendored
Normal file
28
gitnexus/test/fixtures/lang-resolution/php-method-chain-binding/App.php
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
class City {
|
||||
public string $name;
|
||||
public function __construct(string $name) { $this->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();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/python-call-result-binding/app.py
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/python-call-result-binding/app.py
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from service import get_user
|
||||
|
||||
def process_user():
|
||||
user = get_user("alice")
|
||||
user.save()
|
||||
6
gitnexus/test/fixtures/lang-resolution/python-call-result-binding/models.py
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/python-call-result-binding/models.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class User:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
def save(self) -> bool:
|
||||
return True
|
||||
4
gitnexus/test/fixtures/lang-resolution/python-call-result-binding/service.py
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/python-call-result-binding/service.py
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from models import User
|
||||
|
||||
def get_user(name: str) -> User:
|
||||
return User(name)
|
||||
9
gitnexus/test/fixtures/lang-resolution/python-method-chain-binding/app.py
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/python-method-chain-binding/app.py
vendored
Normal file
|
|
@ -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()
|
||||
21
gitnexus/test/fixtures/lang-resolution/python-method-chain-binding/models.py
vendored
Normal file
21
gitnexus/test/fixtures/lang-resolution/python-method-chain-binding/models.py
vendored
Normal file
|
|
@ -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
|
||||
15
gitnexus/test/fixtures/lang-resolution/ruby-call-result-binding/app.rb
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/ruby-call-result-binding/app.rb
vendored
Normal file
|
|
@ -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
|
||||
31
gitnexus/test/fixtures/lang-resolution/ruby-method-chain-binding/app.rb
vendored
Normal file
31
gitnexus/test/fixtures/lang-resolution/ruby-method-chain-binding/app.rb
vendored
Normal file
|
|
@ -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
|
||||
7
gitnexus/test/fixtures/lang-resolution/rust-call-result-binding/src/main.rs
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/rust-call-result-binding/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod models;
|
||||
use models::get_user;
|
||||
|
||||
fn process_user() {
|
||||
let user = get_user("alice");
|
||||
user.save();
|
||||
}
|
||||
13
gitnexus/test/fixtures/lang-resolution/rust-call-result-binding/src/models.rs
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/rust-call-result-binding/src/models.rs
vendored
Normal file
|
|
@ -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() }
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/rust-method-chain-binding/src/main.rs
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/rust-method-chain-binding/src/main.rs
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
23
gitnexus/test/fixtures/lang-resolution/rust-method-chain-binding/src/models.rs
vendored
Normal file
23
gitnexus/test/fixtures/lang-resolution/rust-method-chain-binding/src/models.rs
vendored
Normal file
|
|
@ -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() } } }
|
||||
}
|
||||
12
gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/app.ts
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/app.ts
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/models.ts
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/models.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export class User {
|
||||
name: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
save(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/service.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ts-call-result-binding/service.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { User } from './models';
|
||||
|
||||
export function getUser(name: string): User {
|
||||
return new User(name);
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/app.ts
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/app.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { getUser } from './service';
|
||||
|
||||
function processChain() {
|
||||
const user = getUser();
|
||||
const addr = user.address;
|
||||
const city = addr.getCity();
|
||||
city.save();
|
||||
}
|
||||
31
gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/models.ts
vendored
Normal file
31
gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/models.ts
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/service.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ts-method-chain-binding/service.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { User, Address, City } from './models';
|
||||
|
||||
export function getUser(): User {
|
||||
return new User(new Address(new City('NYC')));
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) ──────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<nodeId, nodeId[]>` 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<User>`
|
||||
**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<filePath, Map<symbolName, typeName>>`. 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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue