Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f67d07f7 | ||
|
|
a4863605e1 | ||
|
|
fc58c415f7 | ||
|
|
790d1d5b0f | ||
|
|
8273324f3c | ||
|
|
7b71b64427 | ||
|
|
e6b8edc1ac | ||
|
|
5769872b70 | ||
|
|
60c93d7d4a | ||
|
|
1e19986ef3 | ||
|
|
973c7bfbf0 | ||
|
|
c0b4098c4e | ||
|
|
11a3d0515c | ||
|
|
e0a6c40b45 | ||
|
|
aa1bab597b | ||
|
|
60ede20a11 |
@@ -1,198 +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 — 7 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/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
|
||||
@@ -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
|
||||
+268
-53
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ jobs:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
publish:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,5 +10,6 @@ export enum SupportedLanguages {
|
||||
Rust = 'rust',
|
||||
PHP = 'php',
|
||||
Ruby = 'ruby',
|
||||
Kotlin = 'kotlin',
|
||||
Swift = 'swift',
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export type CallRouter = (
|
||||
const noRouting: CallRouter = () => null;
|
||||
|
||||
/** Per-language call routing. noRouting = no special routing (normal call processing) */
|
||||
export const callRouters: Record<SupportedLanguages, CallRouter> = {
|
||||
export const callRouters = {
|
||||
[SupportedLanguages.JavaScript]: noRouting,
|
||||
[SupportedLanguages.TypeScript]: noRouting,
|
||||
[SupportedLanguages.Python]: noRouting,
|
||||
@@ -40,7 +40,8 @@ export const callRouters: Record<SupportedLanguages, CallRouter> = {
|
||||
[SupportedLanguages.CPlusPlus]: noRouting,
|
||||
[SupportedLanguages.C]: noRouting,
|
||||
[SupportedLanguages.Ruby]: routeRubyCall,
|
||||
};
|
||||
[SupportedLanguages.Kotlin]: noRouting,
|
||||
} satisfies Record<SupportedLanguages, CallRouter>;
|
||||
|
||||
// ── Result types ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -495,6 +495,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
|
||||
[SupportedLanguages.Rust]: RUST_QUERIES,
|
||||
[SupportedLanguages.PHP]: PHP_QUERIES,
|
||||
[SupportedLanguages.Ruby]: RUBY_QUERIES,
|
||||
[SupportedLanguages.Kotlin]: '', // Kotlin WASM parser not yet available for web
|
||||
[SupportedLanguages.Swift]: SWIFT_QUERIES,
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string =>
|
||||
[SupportedLanguages.Rust]: '/wasm/rust/tree-sitter-rust.wasm',
|
||||
[SupportedLanguages.PHP]: '/wasm/php/tree-sitter-php.wasm',
|
||||
[SupportedLanguages.Ruby]: '/wasm/ruby/tree-sitter-ruby.wasm',
|
||||
[SupportedLanguages.Kotlin]: '', // Kotlin WASM parser not yet available for web
|
||||
[SupportedLanguages.Swift]: '/wasm/swift/tree-sitter-swift.wasm',
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+64
-7
@@ -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",
|
||||
|
||||
@@ -80,6 +80,8 @@ export type RelationshipType =
|
||||
| 'IMPLEMENTS'
|
||||
| 'EXTENDS'
|
||||
| 'HAS_METHOD'
|
||||
| 'HAS_PROPERTY'
|
||||
| 'ACCESSES'
|
||||
| 'MEMBER_OF'
|
||||
| 'STEP_IN_PROCESS'
|
||||
|
||||
@@ -96,7 +98,7 @@ export interface GraphRelationship {
|
||||
type: RelationshipType,
|
||||
/** Confidence score 0-1 (1.0 = certain, lower = uncertain resolution) */
|
||||
confidence: number,
|
||||
/** Resolution reason: 'import-resolved', 'same-file', 'fuzzy-global', or empty for non-CALLS */
|
||||
/** Semantics are edge-type-dependent: CALLS uses resolution tier, ACCESSES uses 'read'/'write', OVERRIDES uses MRO reason */
|
||||
reason: string,
|
||||
/** Step number for STEP_IN_PROCESS relationships (1-indexed) */
|
||||
step?: number,
|
||||
|
||||
@@ -20,15 +20,25 @@ import {
|
||||
extractReceiverNode,
|
||||
findEnclosingClassId,
|
||||
CALL_EXPRESSION_TYPES,
|
||||
MAX_CHAIN_DEPTH,
|
||||
extractCallChain,
|
||||
extractMixedChain,
|
||||
type MixedChainStep,
|
||||
} from './utils.js';
|
||||
import { buildTypeEnv } from './type-env.js';
|
||||
import type { ConstructorBinding } from './type-env.js';
|
||||
import { getTreeSitterBufferSize } from './constants.js';
|
||||
import type { ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
|
||||
import type { ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
|
||||
import { callRouters } from './call-routing.js';
|
||||
import { extractReturnTypeName } from './type-extractors/shared.js';
|
||||
import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
|
||||
|
||||
// Stdlib methods that preserve the receiver's type identity. When TypeEnv already
|
||||
// strips nullable wrappers (Option<User> → User), these chain steps are no-ops
|
||||
// for type resolution — the current type passes through unchanged.
|
||||
const TYPE_PRESERVING_METHODS = new Set([
|
||||
'unwrap', 'expect', 'unwrap_or', 'unwrap_or_default', 'unwrap_or_else', // Rust Option/Result
|
||||
'clone', 'to_owned', 'as_ref', 'as_mut', 'borrow', 'borrow_mut', // Rust clone/borrow
|
||||
'get', // Kotlin/Java Optional.get()
|
||||
'orElseThrow', // Java Optional
|
||||
]);
|
||||
|
||||
/**
|
||||
* Walk up the AST from a node to find the enclosing function/method.
|
||||
@@ -128,6 +138,7 @@ export const processCalls = async (
|
||||
): Promise<ExtractedHeritage[]> => {
|
||||
const parser = await loadParser();
|
||||
const collectedHeritage: ExtractedHeritage[] = [];
|
||||
const pendingWrites: { receiverTypeName: string; propertyName: string; filePath: string; srcId: string }[] = [];
|
||||
const logSkipped = isVerboseIngestionEnabled();
|
||||
const skippedByLang = logSkipped ? new Map<string, number>() : null;
|
||||
|
||||
@@ -178,12 +189,50 @@ export const processCalls = async (
|
||||
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
|
||||
? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx)
|
||||
: new Map<string, string>();
|
||||
const receiverIndex = buildReceiverTypeIndex(verifiedReceivers);
|
||||
|
||||
ctx.enableCache(file.path);
|
||||
|
||||
matches.forEach(match => {
|
||||
const captureMap: Record<string, any> = {};
|
||||
match.captures.forEach(c => captureMap[c.name] = c.node);
|
||||
// ── Write access: emit ACCESSES {reason: 'write'} for assignments to member fields ──
|
||||
if (captureMap['assignment'] && captureMap['assignment.receiver'] && captureMap['assignment.property']) {
|
||||
const receiverNode = captureMap['assignment.receiver'];
|
||||
const propertyName: string = captureMap['assignment.property'].text;
|
||||
// Resolve receiver type: simple identifier → TypeEnv lookup or class resolution
|
||||
let receiverTypeName: string | undefined;
|
||||
const receiverText = receiverNode.text;
|
||||
if (receiverText && typeEnv) {
|
||||
receiverTypeName = typeEnv.lookup(receiverText, captureMap['assignment']);
|
||||
}
|
||||
// Fall back to verified constructor bindings (mirrors CALLS resolution tier 2)
|
||||
if (!receiverTypeName && receiverText && receiverIndex.size > 0) {
|
||||
const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx);
|
||||
const funcName = enclosing ? extractFuncNameFromSourceId(enclosing) : '';
|
||||
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverText);
|
||||
}
|
||||
if (!receiverTypeName && receiverText) {
|
||||
const resolved = ctx.resolve(receiverText, file.path);
|
||||
if (resolved?.candidates.some(d =>
|
||||
d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
||||
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl',
|
||||
)) {
|
||||
receiverTypeName = receiverText;
|
||||
}
|
||||
}
|
||||
if (receiverTypeName) {
|
||||
const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx);
|
||||
const srcId = enclosing || generateId('File', file.path);
|
||||
// Defer resolution: Ruby attr_accessor properties are registered during
|
||||
// this same loop, so cross-file lookups fail if the declaring file hasn't
|
||||
// been processed yet. Collect now, resolve after all files are done.
|
||||
pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
|
||||
}
|
||||
// Assignment-only capture (no @call sibling): skip the rest of this
|
||||
// forEach iteration — this acts as a `continue` in the match loop.
|
||||
if (!captureMap['call']) return;
|
||||
}
|
||||
|
||||
if (!captureMap['call']) return;
|
||||
|
||||
@@ -225,8 +274,10 @@ export const processCalls = async (
|
||||
description: item.accessorType,
|
||||
},
|
||||
});
|
||||
ctx.symbols.add(file.path, item.propName, nodeId, 'Property',
|
||||
propEnclosingClassId ? { ownerId: propEnclosingClassId } : undefined);
|
||||
ctx.symbols.add(file.path, item.propName, nodeId, 'Property', {
|
||||
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
|
||||
...(item.declaredType ? { declaredType: item.declaredType } : {}),
|
||||
});
|
||||
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
|
||||
graph.addRelationship({
|
||||
id: relId, sourceId: fileId, targetId: nodeId,
|
||||
@@ -234,9 +285,9 @@ export const processCalls = async (
|
||||
});
|
||||
if (propEnclosingClassId) {
|
||||
graph.addRelationship({
|
||||
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
|
||||
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
|
||||
sourceId: propEnclosingClassId, targetId: nodeId,
|
||||
type: 'HAS_METHOD', confidence: 1.0, reason: '',
|
||||
type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -255,10 +306,10 @@ export const processCalls = async (
|
||||
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
|
||||
let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
|
||||
// Fall back to verified constructor bindings for return type inference
|
||||
if (!receiverTypeName && receiverName && verifiedReceivers.size > 0) {
|
||||
if (!receiverTypeName && receiverName && receiverIndex.size > 0) {
|
||||
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
|
||||
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
|
||||
receiverTypeName = lookupReceiverType(verifiedReceivers, funcName, receiverName);
|
||||
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverName);
|
||||
}
|
||||
// Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()).
|
||||
// When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface
|
||||
@@ -271,32 +322,39 @@ export const processCalls = async (
|
||||
receiverTypeName = receiverName;
|
||||
}
|
||||
}
|
||||
// Fall back to chained call resolution when the receiver is a call expression
|
||||
// (e.g. svc.getUser().save() — receiver of save() is getUser(), not a simple identifier).
|
||||
// Hoist sourceId so it's available for ACCESSES edge emission during chain walk.
|
||||
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
|
||||
const sourceId = enclosingFuncId || generateId('File', file.path);
|
||||
|
||||
// Fall back to mixed chain resolution when the receiver is a complex expression
|
||||
// (field chain, call chain, or interleaved — e.g. user.address.city.save() or
|
||||
// svc.getUser().address.save()). Handles all cases with a single unified walk.
|
||||
if (callForm === 'member' && !receiverTypeName && !receiverName) {
|
||||
const receiverNode = extractReceiverNode(nameNode);
|
||||
if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
|
||||
const extracted = extractCallChain(receiverNode);
|
||||
if (extracted) {
|
||||
// Resolve the base receiver type if possible
|
||||
let baseType = extracted.baseReceiverName && typeEnv
|
||||
if (receiverNode) {
|
||||
const extracted = extractMixedChain(receiverNode);
|
||||
if (extracted && extracted.chain.length > 0) {
|
||||
let currentType = extracted.baseReceiverName && typeEnv
|
||||
? typeEnv.lookup(extracted.baseReceiverName, callNode)
|
||||
: undefined;
|
||||
if (!baseType && extracted.baseReceiverName && verifiedReceivers.size > 0) {
|
||||
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
|
||||
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
|
||||
baseType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName);
|
||||
if (!currentType && extracted.baseReceiverName && receiverIndex.size > 0) {
|
||||
const funcName = enclosingFuncId ? extractFuncNameFromSourceId(enclosingFuncId) : '';
|
||||
currentType = lookupReceiverType(receiverIndex, funcName, extracted.baseReceiverName);
|
||||
}
|
||||
// Class-as-receiver for chain base (e.g. UserService.find_user().save())
|
||||
if (!baseType && extracted.baseReceiverName) {
|
||||
if (!currentType && extracted.baseReceiverName) {
|
||||
const cr = ctx.resolve(extracted.baseReceiverName, file.path);
|
||||
if (cr?.candidates.some(d =>
|
||||
d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum',
|
||||
)) {
|
||||
baseType = extracted.baseReceiverName;
|
||||
currentType = extracted.baseReceiverName;
|
||||
}
|
||||
}
|
||||
receiverTypeName = resolveChainedReceiver(extracted.chain, baseType, file.path, ctx);
|
||||
if (currentType) {
|
||||
receiverTypeName = walkMixedChain(
|
||||
extracted.chain, currentType, file.path, ctx,
|
||||
makeAccessEmitter(graph, sourceId),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,9 +367,6 @@ export const processCalls = async (
|
||||
}, file.path, ctx);
|
||||
|
||||
if (!resolved) return;
|
||||
|
||||
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
|
||||
const sourceId = enclosingFuncId || generateId('File', file.path);
|
||||
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
|
||||
|
||||
graph.addRelationship({
|
||||
@@ -327,6 +382,22 @@ export const processCalls = async (
|
||||
ctx.clearCache();
|
||||
}
|
||||
|
||||
// ── Resolve deferred write-access edges ──
|
||||
// All properties (including Ruby attr_accessor) are now registered.
|
||||
for (const pw of pendingWrites) {
|
||||
const fieldOwner = resolveFieldOwnership(pw.receiverTypeName, pw.propertyName, pw.filePath, ctx);
|
||||
if (fieldOwner) {
|
||||
graph.addRelationship({
|
||||
id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write`),
|
||||
sourceId: pw.srcId,
|
||||
targetId: fieldOwner.nodeId,
|
||||
type: 'ACCESSES',
|
||||
confidence: 1.0,
|
||||
reason: 'write',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedByLang && skippedByLang.size > 0) {
|
||||
for (const [lang, count] of skippedByLang.entries()) {
|
||||
console.warn(
|
||||
@@ -345,6 +416,7 @@ interface ResolveResult {
|
||||
nodeId: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
returnType?: string;
|
||||
}
|
||||
|
||||
const CALLABLE_SYMBOL_TYPES = new Set([
|
||||
@@ -394,48 +466,9 @@ const toResolveResult = (
|
||||
nodeId: definition.nodeId,
|
||||
confidence: TIER_CONFIDENCE[tier],
|
||||
reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global',
|
||||
returnType: definition.returnType,
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a chain of intermediate method calls to find the receiver type for a
|
||||
* final member call. Called when the receiver of a call is itself a call
|
||||
* expression (e.g. `svc.getUser().save()`).
|
||||
*
|
||||
* @param chainNames Ordered list of method names from outermost to innermost
|
||||
* intermediate call (e.g. ['getUser'] for `svc.getUser().save()`).
|
||||
* @param baseReceiverTypeName The already-resolved type of the base receiver
|
||||
* (e.g. 'UserService' for `svc`), or undefined.
|
||||
* @param currentFile The file path for resolution context.
|
||||
* @param ctx The resolution context for symbol lookup.
|
||||
* @returns The type name of the final intermediate call's return type, or undefined
|
||||
* if resolution fails at any step.
|
||||
*/
|
||||
function resolveChainedReceiver(
|
||||
chainNames: string[],
|
||||
baseReceiverTypeName: string | undefined,
|
||||
currentFile: string,
|
||||
ctx: ResolutionContext,
|
||||
): string | undefined {
|
||||
let currentType = baseReceiverTypeName;
|
||||
for (const name of chainNames) {
|
||||
const resolved = resolveCallTarget(
|
||||
{ calledName: name, callForm: 'member', receiverTypeName: currentType },
|
||||
currentFile,
|
||||
ctx,
|
||||
);
|
||||
if (!resolved) return undefined;
|
||||
|
||||
const candidates = ctx.symbols.lookupFuzzy(name);
|
||||
const symDef = candidates.find(c => c.nodeId === resolved.nodeId);
|
||||
if (!symDef?.returnType) return undefined;
|
||||
|
||||
const returnTypeName = extractReturnTypeName(symDef.returnType);
|
||||
if (!returnTypeName) return undefined;
|
||||
|
||||
currentType = returnTypeName;
|
||||
}
|
||||
return currentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a function call to its target node ID using priority strategy:
|
||||
@@ -529,48 +562,196 @@ const receiverKey = (scope: string, varName: string): string =>
|
||||
`${scope}\0${varName}`;
|
||||
|
||||
/**
|
||||
* Look up a receiver type from a verified receiver map.
|
||||
* The map is keyed by `scope\0varName` (full scope with @startIndex).
|
||||
* Since the lookup side only has `funcName` (no startIndex), we scan for
|
||||
* all entries whose key starts with `funcName@` and has the matching varName.
|
||||
* If exactly one unique type is found, return it. If multiple distinct types
|
||||
* exist (true overload collision), return undefined (refuse to guess).
|
||||
* Falls back to the file-level scope key `\0varName` (empty funcName).
|
||||
* Pre-built secondary index for O(1) receiver type lookups.
|
||||
* Built once per file from the verified receiver map, keyed by funcName → varName.
|
||||
*/
|
||||
type ReceiverTypeEntry =
|
||||
| { readonly kind: 'resolved'; readonly value: string }
|
||||
| { readonly kind: 'ambiguous' };
|
||||
type ReceiverTypeIndex = Map<string, Map<string, ReceiverTypeEntry>>;
|
||||
|
||||
/**
|
||||
* Build a two-level secondary index from the verified receiver map.
|
||||
* The verified map is keyed by `scope\0varName` where scope is either
|
||||
* "funcName@startIndex" (inside a function) or "" (file level).
|
||||
* Index structure: Map<funcName, Map<varName, ReceiverTypeEntry>>
|
||||
*/
|
||||
const buildReceiverTypeIndex = (map: Map<string, string>): ReceiverTypeIndex => {
|
||||
const index: ReceiverTypeIndex = new Map();
|
||||
for (const [key, typeName] of map) {
|
||||
const nul = key.indexOf('\0');
|
||||
if (nul < 0) continue;
|
||||
const scope = key.slice(0, nul);
|
||||
const varName = key.slice(nul + 1);
|
||||
if (!varName) continue;
|
||||
if (scope !== '' && !scope.includes('@')) continue;
|
||||
const funcName = scope === '' ? '' : scope.slice(0, scope.indexOf('@'));
|
||||
|
||||
let varMap = index.get(funcName);
|
||||
if (!varMap) { varMap = new Map(); index.set(funcName, varMap); }
|
||||
|
||||
const existing = varMap.get(varName);
|
||||
if (existing === undefined) {
|
||||
varMap.set(varName, { kind: 'resolved', value: typeName });
|
||||
} else if (existing.kind === 'resolved' && existing.value !== typeName) {
|
||||
varMap.set(varName, { kind: 'ambiguous' });
|
||||
}
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
/**
|
||||
* O(1) receiver type lookup using the pre-built secondary index.
|
||||
* Returns the unique type name if unambiguous. Falls back to file-level scope.
|
||||
*/
|
||||
const lookupReceiverType = (
|
||||
map: Map<string, string>,
|
||||
index: ReceiverTypeIndex,
|
||||
funcName: string,
|
||||
varName: string,
|
||||
): string | undefined => {
|
||||
// Fast path: file-level scope (empty funcName — used as fallback)
|
||||
const fileLevelKey = receiverKey('', varName);
|
||||
|
||||
const prefix = `${funcName}@`;
|
||||
const suffix = `\0${varName}`;
|
||||
let found: string | undefined;
|
||||
let ambiguous = false;
|
||||
|
||||
for (const [key, value] of map) {
|
||||
if (key === fileLevelKey) continue; // handled separately below
|
||||
if (key.startsWith(prefix) && key.endsWith(suffix)) {
|
||||
// Verify the key is exactly "funcName@<digits>\0varName" with no extra chars.
|
||||
// The part between prefix and suffix should be the startIndex (digits only),
|
||||
// but we accept any non-empty segment to be forward-compatible.
|
||||
const middle = key.slice(prefix.length, key.length - suffix.length);
|
||||
if (middle.length === 0) continue; // malformed key — skip
|
||||
if (found === undefined) {
|
||||
found = value;
|
||||
} else if (found !== value) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
}
|
||||
const funcBucket = index.get(funcName);
|
||||
if (funcBucket) {
|
||||
const entry = funcBucket.get(varName);
|
||||
if (entry?.kind === 'resolved') return entry.value;
|
||||
if (entry?.kind === 'ambiguous') {
|
||||
// Ambiguous in this function scope — try file-level fallback
|
||||
const fileEntry = index.get('')?.get(varName);
|
||||
return fileEntry?.kind === 'resolved' ? fileEntry.value : undefined;
|
||||
}
|
||||
}
|
||||
// Fallback: file-level scope (funcName "")
|
||||
if (funcName !== '') {
|
||||
const fileEntry = index.get('')?.get(varName);
|
||||
if (fileEntry?.kind === 'resolved') return fileEntry.value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (!ambiguous && found !== undefined) return found;
|
||||
interface FieldResolution {
|
||||
typeName: string; // resolved declared type (continues chain threading)
|
||||
fieldNodeId: string; // nodeId of the Property symbol (for ACCESSES edge target)
|
||||
}
|
||||
|
||||
// Fallback: file-level scope (bindings outside any function)
|
||||
return map.get(fileLevelKey);
|
||||
/**
|
||||
* Resolve the type that results from accessing `receiverName.fieldName`.
|
||||
* Requires declaredType on the Property node (needed for chain walking continuation).
|
||||
*/
|
||||
const resolveFieldAccessType = (
|
||||
receiverName: string,
|
||||
fieldName: string,
|
||||
filePath: string,
|
||||
ctx: ResolutionContext,
|
||||
): FieldResolution | undefined => {
|
||||
const fieldDef = resolveFieldOwnership(receiverName, fieldName, filePath, ctx);
|
||||
if (!fieldDef?.declaredType) return undefined;
|
||||
|
||||
// Use stripNullable (not extractReturnTypeName) — field types like List<User>
|
||||
// should be preserved as-is, not unwrapped to User. Only strip nullable wrappers.
|
||||
return {
|
||||
typeName: stripNullable(fieldDef.declaredType),
|
||||
fieldNodeId: fieldDef.nodeId,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a field's Property node given a receiver type name and field name.
|
||||
* Does NOT require declaredType — used by write-access tracking where only the
|
||||
* fieldNodeId is needed (no chain continuation).
|
||||
*/
|
||||
const resolveFieldOwnership = (
|
||||
receiverName: string,
|
||||
fieldName: string,
|
||||
filePath: string,
|
||||
ctx: ResolutionContext,
|
||||
): { nodeId: string; declaredType?: string } | undefined => {
|
||||
const typeResolved = ctx.resolve(receiverName, filePath);
|
||||
if (!typeResolved) return undefined;
|
||||
const classDef = typeResolved.candidates.find(
|
||||
d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
||||
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl',
|
||||
);
|
||||
if (!classDef) return undefined;
|
||||
|
||||
return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a deduplicated ACCESSES edge emitter for a single source node.
|
||||
* Each (sourceId, fieldNodeId) pair is emitted at most once per source.
|
||||
*/
|
||||
const makeAccessEmitter = (
|
||||
graph: KnowledgeGraph,
|
||||
sourceId: string,
|
||||
): OnFieldResolved => {
|
||||
const emitted = new Set<string>();
|
||||
return (fieldNodeId: string): void => {
|
||||
const key = `${sourceId}\0${fieldNodeId}`;
|
||||
if (emitted.has(key)) return;
|
||||
emitted.add(key);
|
||||
|
||||
graph.addRelationship({
|
||||
id: generateId('ACCESSES', `${sourceId}:${fieldNodeId}:read`),
|
||||
sourceId,
|
||||
targetId: fieldNodeId,
|
||||
type: 'ACCESSES',
|
||||
confidence: 1.0,
|
||||
reason: 'read',
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk a pre-built mixed chain of field/call steps, threading the current type
|
||||
* through each step and returning the final resolved type.
|
||||
*
|
||||
* Returns `undefined` if any step cannot be resolved (chain is broken).
|
||||
* The caller is responsible for seeding `startType` from its own context
|
||||
* (TypeEnv, constructor bindings, or static-class fallback).
|
||||
*/
|
||||
type OnFieldResolved = (fieldNodeId: string) => void;
|
||||
|
||||
const walkMixedChain = (
|
||||
chain: MixedChainStep[],
|
||||
startType: string,
|
||||
filePath: string,
|
||||
ctx: ResolutionContext,
|
||||
onFieldResolved?: OnFieldResolved,
|
||||
): string | undefined => {
|
||||
let currentType: string | undefined = startType;
|
||||
for (const step of chain) {
|
||||
if (!currentType) break;
|
||||
if (step.kind === 'field') {
|
||||
const resolved = resolveFieldAccessType(currentType, step.name, filePath, ctx);
|
||||
if (!resolved) { currentType = undefined; break; }
|
||||
onFieldResolved?.(resolved.fieldNodeId);
|
||||
currentType = resolved.typeName;
|
||||
} else {
|
||||
// Ruby/Python: property access is syntactically identical to method calls.
|
||||
// Try field resolution first — if the name is a known property with declaredType,
|
||||
// use that type directly. Otherwise fall back to method call resolution.
|
||||
const fieldResolved = resolveFieldAccessType(currentType, step.name, filePath, ctx);
|
||||
if (fieldResolved) {
|
||||
onFieldResolved?.(fieldResolved.fieldNodeId);
|
||||
currentType = fieldResolved.typeName;
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveCallTarget(
|
||||
{ calledName: step.name, callForm: 'member', receiverTypeName: currentType },
|
||||
filePath,
|
||||
ctx,
|
||||
);
|
||||
if (!resolved) {
|
||||
// Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type
|
||||
if (TYPE_PRESERVING_METHODS.has(step.name)) continue;
|
||||
currentType = undefined; break;
|
||||
}
|
||||
if (!resolved.returnType) { currentType = undefined; break; }
|
||||
const retType = extractReturnTypeName(resolved.returnType);
|
||||
if (!retType) { currentType = undefined; break; }
|
||||
currentType = retType;
|
||||
}
|
||||
}
|
||||
return currentType;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -587,12 +768,12 @@ export const processCallsFromExtracted = async (
|
||||
// Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName.
|
||||
// The scope dimension prevents collisions when two functions in the same file
|
||||
// have same-named locals pointing to different constructor types.
|
||||
const fileReceiverTypes = new Map<string, Map<string, string>>();
|
||||
const fileReceiverTypes = new Map<string, ReceiverTypeIndex>();
|
||||
if (constructorBindings) {
|
||||
for (const { filePath, bindings } of constructorBindings) {
|
||||
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
|
||||
if (verified.size > 0) {
|
||||
fileReceiverTypes.set(filePath, verified);
|
||||
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,29 +819,32 @@ export const processCallsFromExtracted = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: if the call has a receiver call chain (e.g. svc.getUser().save()),
|
||||
// resolve the chain to determine the final receiver type.
|
||||
// This runs whenever receiverCallChain is present — even when Step 1 set a
|
||||
// receiverTypeName, that type is the BASE receiver (e.g. UserService for svc),
|
||||
// and the chain must be walked to produce the FINAL receiver (e.g. User from
|
||||
// getUser() : User).
|
||||
if (effectiveCall.receiverCallChain?.length) {
|
||||
// Step 1 may have resolved the base receiver type (e.g. svc → UserService).
|
||||
// Use it as the starting point for chain resolution.
|
||||
let baseType = effectiveCall.receiverTypeName;
|
||||
// If Step 1 didn't resolve it, try the receiver map directly.
|
||||
if (!baseType && effectiveCall.receiverName && receiverMap) {
|
||||
// Step 1c: mixed chain resolution (field, call, or interleaved — e.g. svc.getUser().address.save()).
|
||||
// Runs whenever receiverMixedChain is present. Steps 1/1b may have resolved the base receiver
|
||||
// type already; that type is used as the chain's starting point.
|
||||
if (effectiveCall.receiverMixedChain?.length) {
|
||||
// Use the already-resolved base type (from Steps 1/1b) or look it up now.
|
||||
let currentType: string | undefined = effectiveCall.receiverTypeName;
|
||||
if (!currentType && effectiveCall.receiverName && receiverMap) {
|
||||
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
|
||||
baseType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
|
||||
currentType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
|
||||
}
|
||||
const chainedType = resolveChainedReceiver(
|
||||
effectiveCall.receiverCallChain,
|
||||
baseType,
|
||||
effectiveCall.filePath,
|
||||
ctx,
|
||||
);
|
||||
if (chainedType) {
|
||||
effectiveCall = { ...effectiveCall, receiverTypeName: chainedType };
|
||||
if (!currentType && effectiveCall.receiverName) {
|
||||
const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath);
|
||||
if (typeResolved?.candidates.some(d =>
|
||||
d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum',
|
||||
)) {
|
||||
currentType = effectiveCall.receiverName;
|
||||
}
|
||||
}
|
||||
if (currentType) {
|
||||
const walkedType = walkMixedChain(
|
||||
effectiveCall.receiverMixedChain, currentType, effectiveCall.filePath, ctx,
|
||||
makeAccessEmitter(graph, effectiveCall.sourceId),
|
||||
);
|
||||
if (walkedType) {
|
||||
effectiveCall = { ...effectiveCall, receiverTypeName: walkedType };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +868,63 @@ export const processCallsFromExtracted = async (
|
||||
onProgress?.(totalFiles, totalFiles);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve pre-extracted field write assignments to ACCESSES {reason: 'write'} edges.
|
||||
* Accepts optional constructorBindings for return-type-aware receiver inference,
|
||||
* mirroring processCallsFromExtracted's verified binding lookup.
|
||||
*/
|
||||
export const processAssignmentsFromExtracted = (
|
||||
graph: KnowledgeGraph,
|
||||
assignments: ExtractedAssignment[],
|
||||
ctx: ResolutionContext,
|
||||
constructorBindings?: FileConstructorBindings[],
|
||||
): void => {
|
||||
// Build per-file receiver type indexes from verified constructor bindings
|
||||
const fileReceiverTypes = new Map<string, ReceiverTypeIndex>();
|
||||
if (constructorBindings) {
|
||||
for (const { filePath, bindings } of constructorBindings) {
|
||||
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
|
||||
if (verified.size > 0) {
|
||||
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const asn of assignments) {
|
||||
// Resolve the receiver type
|
||||
let receiverTypeName = asn.receiverTypeName;
|
||||
// Tier 2: verified constructor bindings (return-type inference)
|
||||
if (!receiverTypeName && fileReceiverTypes.size > 0) {
|
||||
const receiverMap = fileReceiverTypes.get(asn.filePath);
|
||||
if (receiverMap) {
|
||||
const funcName = extractFuncNameFromSourceId(asn.sourceId);
|
||||
receiverTypeName = lookupReceiverType(receiverMap, funcName, asn.receiverText);
|
||||
}
|
||||
}
|
||||
// Tier 3: static class-as-receiver fallback
|
||||
if (!receiverTypeName) {
|
||||
const resolved = ctx.resolve(asn.receiverText, asn.filePath);
|
||||
if (resolved?.candidates.some(d =>
|
||||
d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
||||
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl',
|
||||
)) {
|
||||
receiverTypeName = asn.receiverText;
|
||||
}
|
||||
}
|
||||
if (!receiverTypeName) continue;
|
||||
const fieldOwner = resolveFieldOwnership(receiverTypeName, asn.propertyName, asn.filePath, ctx);
|
||||
if (!fieldOwner) continue;
|
||||
graph.addRelationship({
|
||||
id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write`),
|
||||
sourceId: asn.sourceId,
|
||||
targetId: fieldOwner.nodeId,
|
||||
type: 'ACCESSES',
|
||||
confidence: 1.0,
|
||||
reason: 'write',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods.
|
||||
*/
|
||||
|
||||
@@ -27,7 +27,7 @@ export type CallRouter = (
|
||||
const noRouting: CallRouter = () => null;
|
||||
|
||||
/** Per-language call routing. noRouting = no special routing (normal call processing) */
|
||||
export const callRouters: Record<SupportedLanguages, CallRouter> = {
|
||||
export const callRouters = {
|
||||
[SupportedLanguages.JavaScript]: noRouting,
|
||||
[SupportedLanguages.TypeScript]: noRouting,
|
||||
[SupportedLanguages.Python]: noRouting,
|
||||
@@ -41,7 +41,7 @@ export const callRouters: Record<SupportedLanguages, CallRouter> = {
|
||||
[SupportedLanguages.CPlusPlus]: noRouting,
|
||||
[SupportedLanguages.C]: noRouting,
|
||||
[SupportedLanguages.Ruby]: routeRubyCall,
|
||||
};
|
||||
} satisfies Record<SupportedLanguages, CallRouter>;
|
||||
|
||||
// ── Result types ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface RubyPropertyItem {
|
||||
accessorType: RubyAccessorType;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
/** YARD @return [Type] annotation preceding the attr_accessor call */
|
||||
declaredType?: string;
|
||||
}
|
||||
|
||||
// ── Pre-allocated singletons for common return values ────────────────────────
|
||||
@@ -129,6 +131,25 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin
|
||||
|
||||
// ── attr_accessor / attr_reader / attr_writer → property definitions ───
|
||||
if (calledName === 'attr_accessor' || calledName === 'attr_reader' || calledName === 'attr_writer') {
|
||||
// Extract YARD @return [Type] from preceding comment (e.g. `# @return [Address]`)
|
||||
let yardType: string | undefined;
|
||||
let sibling = callNode.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment') {
|
||||
const match = /@return\s+\[([^\]]+)\]/.exec(sibling.text);
|
||||
if (match) {
|
||||
const raw = match[1].trim();
|
||||
// Extract simple type name: "User", "Array<User>" → "User"
|
||||
const simple = raw.match(/^([A-Z]\w*)/);
|
||||
if (simple) yardType = simple[1];
|
||||
break;
|
||||
}
|
||||
} else if (sibling.isNamed) {
|
||||
break; // stop at non-comment named sibling
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
|
||||
const items: RubyPropertyItem[] = [];
|
||||
const argList = callNode.childForFieldName?.('arguments');
|
||||
for (const arg of (argList?.children ?? [])) {
|
||||
@@ -138,6 +159,7 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin
|
||||
accessorType: calledName as RubyAccessorType,
|
||||
startLine: arg.startPosition.row,
|
||||
endLine: arg.endPosition.row,
|
||||
...(yardType ? { declaredType: yardType } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.js';
|
||||
import { KnowledgeGraph, GraphNode, GraphRelationship, type NodeLabel } from '../graph/types.js';
|
||||
import Parser from 'tree-sitter';
|
||||
import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js';
|
||||
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { SymbolTable } from './symbol-table.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import { getLanguageFromFilename, yieldToEventLoop, DEFINITION_CAPTURE_KEYS, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js';
|
||||
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js';
|
||||
import { extractPropertyDeclaredType } from './type-extractors/shared.js';
|
||||
import { isNodeExported } from './export-detection.js';
|
||||
import { detectFrameworkFromAST } from './framework-detection.js';
|
||||
import { typeConfigs } from './type-extractors/index.js';
|
||||
import { SupportedLanguages } from '../../config/supported-languages.js';
|
||||
import { WorkerPool } from './workers/worker-pool.js';
|
||||
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
|
||||
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
|
||||
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
|
||||
|
||||
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
|
||||
@@ -18,6 +20,7 @@ export type FileProgressCallback = (current: number, total: number, filePath: st
|
||||
export interface WorkerExtractedData {
|
||||
imports: ExtractedImport[];
|
||||
calls: ExtractedCall[];
|
||||
assignments: ExtractedAssignment[];
|
||||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
constructorBindings: FileConstructorBindings[];
|
||||
@@ -46,7 +49,7 @@ const processParsingWithWorkers = async (
|
||||
if (lang) parseableFiles.push({ path: file.path, content: file.content });
|
||||
}
|
||||
|
||||
if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [], routes: [], constructorBindings: [] };
|
||||
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [] };
|
||||
|
||||
const total = files.length;
|
||||
|
||||
@@ -61,6 +64,7 @@ const processParsingWithWorkers = async (
|
||||
// Merge results from all workers into graph and symbol table
|
||||
const allImports: ExtractedImport[] = [];
|
||||
const allCalls: ExtractedCall[] = [];
|
||||
const allAssignments: ExtractedAssignment[] = [];
|
||||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
const allConstructorBindings: FileConstructorBindings[] = [];
|
||||
@@ -81,12 +85,14 @@ const processParsingWithWorkers = async (
|
||||
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
|
||||
parameterCount: sym.parameterCount,
|
||||
returnType: sym.returnType,
|
||||
declaredType: sym.declaredType,
|
||||
ownerId: sym.ownerId,
|
||||
});
|
||||
}
|
||||
|
||||
allImports.push(...result.imports);
|
||||
allCalls.push(...result.calls);
|
||||
allAssignments.push(...result.assignments);
|
||||
allHeritage.push(...result.heritage);
|
||||
allRoutes.push(...result.routes);
|
||||
allConstructorBindings.push(...result.constructorBindings);
|
||||
@@ -108,7 +114,7 @@ const processParsingWithWorkers = async (
|
||||
|
||||
// Final progress
|
||||
onFileProgress?.(total, total, 'done');
|
||||
return { imports: allImports, calls: allCalls, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings };
|
||||
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -198,9 +204,24 @@ const processParsingSequential = async (
|
||||
if (!nameNode && !captureMap['definition.constructor']) return;
|
||||
const nodeName = nameNode ? nameNode.text : 'init';
|
||||
|
||||
let nodeLabel = 'CodeElement';
|
||||
let nodeLabel: NodeLabel = 'CodeElement';
|
||||
|
||||
if (captureMap['definition.function']) nodeLabel = 'Function';
|
||||
if (captureMap['definition.function']) {
|
||||
// C/C++: @definition.function is broad and also matches inline class methods (inside
|
||||
// a class/struct body). Those are already captured by @definition.method, so skip
|
||||
// the duplicate Function entry to prevent double-indexing in globalIndex.
|
||||
if (language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) {
|
||||
let ancestor = captureMap['definition.function']?.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') {
|
||||
break;
|
||||
}
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
if (ancestor) return; // inside a class body — handled by @definition.method
|
||||
}
|
||||
nodeLabel = 'Function';
|
||||
}
|
||||
else if (captureMap['definition.class']) nodeLabel = 'Class';
|
||||
else if (captureMap['definition.interface']) nodeLabel = 'Interface';
|
||||
else if (captureMap['definition.method']) nodeLabel = 'Method';
|
||||
@@ -275,9 +296,15 @@ const processParsingSequential = async (
|
||||
const needsOwner = nodeLabel === 'Method' || nodeLabel === 'Constructor' || nodeLabel === 'Property' || nodeLabel === 'Function';
|
||||
const enclosingClassId = needsOwner ? findEnclosingClassId(nameNode || definitionNodeForRange, file.path) : null;
|
||||
|
||||
// Extract declared type for Property nodes (field/property type annotations)
|
||||
const declaredType = (nodeLabel === 'Property' && definitionNode)
|
||||
? extractPropertyDeclaredType(definitionNode)
|
||||
: undefined;
|
||||
|
||||
symbolTable.add(file.path, nodeName, nodeId, nodeLabel, {
|
||||
parameterCount: methodSig?.parameterCount,
|
||||
returnType: methodSig?.returnType,
|
||||
declaredType,
|
||||
ownerId: enclosingClassId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -296,13 +323,14 @@ const processParsingSequential = async (
|
||||
|
||||
graph.addRelationship(relationship);
|
||||
|
||||
// ── HAS_METHOD: link method/constructor/property to enclosing class ──
|
||||
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
|
||||
if (enclosingClassId) {
|
||||
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
|
||||
graph.addRelationship({
|
||||
id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`),
|
||||
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
|
||||
sourceId: enclosingClassId,
|
||||
targetId: nodeId,
|
||||
type: 'HAS_METHOD',
|
||||
type: memberEdgeType,
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
processImportsFromExtracted,
|
||||
buildImportResolutionContext
|
||||
} from './import-processor.js';
|
||||
import { processCalls, processCallsFromExtracted, processRoutesFromExtracted } from './call-processor.js';
|
||||
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted } from './call-processor.js';
|
||||
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
|
||||
import { computeMRO } from './mro-processor.js';
|
||||
import { processCommunities } from './community-processor.js';
|
||||
@@ -290,6 +290,10 @@ export const runPipelineFromRepo = async (
|
||||
},
|
||||
),
|
||||
]);
|
||||
// Process field write assignments (synchronous, runs after calls resolve)
|
||||
if (chunkWorkerData.assignments?.length) {
|
||||
processAssignmentsFromExtracted(graph, chunkWorkerData.assignments, ctx, chunkWorkerData.constructorBindings);
|
||||
}
|
||||
} else {
|
||||
await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths);
|
||||
sequentialChunkPaths.push(chunkPaths);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { NodeLabel } from '../graph/types.js';
|
||||
|
||||
export interface SymbolDefinition {
|
||||
nodeId: string;
|
||||
filePath: string;
|
||||
type: string; // 'Function', 'Class', etc.
|
||||
type: NodeLabel;
|
||||
parameterCount?: number;
|
||||
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
|
||||
returnType?: string;
|
||||
/** Links Method/Constructor to owning Class/Struct/Trait nodeId */
|
||||
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
|
||||
declaredType?: string;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
@@ -17,8 +21,8 @@ export interface SymbolTable {
|
||||
filePath: string,
|
||||
name: string,
|
||||
nodeId: string,
|
||||
type: string,
|
||||
metadata?: { parameterCount?: number; returnType?: string; ownerId?: string }
|
||||
type: NodeLabel,
|
||||
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
|
||||
) => void;
|
||||
|
||||
/**
|
||||
@@ -45,7 +49,14 @@ export interface SymbolTable {
|
||||
* Used by ReturnTypeLookup to resolve callee → return type.
|
||||
*/
|
||||
lookupFuzzyCallable: (name: string) => SymbolDefinition[];
|
||||
|
||||
|
||||
/**
|
||||
* Look up a field/property by its owning class nodeId and field name.
|
||||
* O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`.
|
||||
* Returns undefined when no matching property exists or the owner is ambiguous.
|
||||
*/
|
||||
lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined;
|
||||
|
||||
/**
|
||||
* Debugging: See how many symbols are tracked
|
||||
*/
|
||||
@@ -71,14 +82,18 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
// Only Function, Method, Constructor symbols are indexed.
|
||||
let callableIndex: Map<string, SymbolDefinition[]> | null = null;
|
||||
|
||||
// 4. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName".
|
||||
// Only Property symbols with ownerId and declaredType are indexed.
|
||||
const fieldByOwner = new Map<string, SymbolDefinition>();
|
||||
|
||||
const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']);
|
||||
|
||||
const add = (
|
||||
filePath: string,
|
||||
name: string,
|
||||
nodeId: string,
|
||||
type: string,
|
||||
metadata?: { parameterCount?: number; returnType?: string; ownerId?: string }
|
||||
type: NodeLabel,
|
||||
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
|
||||
) => {
|
||||
const def: SymbolDefinition = {
|
||||
nodeId,
|
||||
@@ -86,6 +101,7 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
type,
|
||||
...(metadata?.parameterCount !== undefined ? { parameterCount: metadata.parameterCount } : {}),
|
||||
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
|
||||
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
|
||||
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
|
||||
};
|
||||
|
||||
@@ -95,14 +111,26 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
}
|
||||
fileIndex.get(filePath)!.set(name, def);
|
||||
|
||||
// B. Add to Global Index (same object reference)
|
||||
// B. Properties go to fieldByOwner index only — skip globalIndex to prevent
|
||||
// namespace pollution for common names like 'id', 'name', 'type'.
|
||||
// Index ALL properties (even without declaredType) so write-access tracking
|
||||
// can resolve field ownership for dynamically-typed languages (Ruby, JS).
|
||||
if (type === 'Property' && metadata?.ownerId) {
|
||||
fieldByOwner.set(`${metadata.ownerId}\0${name}`, def);
|
||||
// Still add to fileIndex above (for lookupExact), but skip globalIndex
|
||||
return;
|
||||
}
|
||||
|
||||
// C. Add to Global Index (same object reference)
|
||||
if (!globalIndex.has(name)) {
|
||||
globalIndex.set(name, []);
|
||||
}
|
||||
globalIndex.get(name)!.push(def);
|
||||
|
||||
// Invalidate the lazy callable index — it will be rebuilt on next use
|
||||
callableIndex = null;
|
||||
// D. Invalidate the lazy callable index only when adding callable types
|
||||
if (CALLABLE_TYPES.has(type)) {
|
||||
callableIndex = null;
|
||||
}
|
||||
};
|
||||
|
||||
const lookupExact = (filePath: string, name: string): string | undefined => {
|
||||
@@ -129,6 +157,10 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
return callableIndex.get(name) ?? [];
|
||||
};
|
||||
|
||||
const lookupFieldByOwner = (ownerNodeId: string, fieldName: string): SymbolDefinition | undefined => {
|
||||
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
|
||||
};
|
||||
|
||||
const getStats = () => ({
|
||||
fileCount: fileIndex.size,
|
||||
globalSymbolCount: globalIndex.size
|
||||
@@ -138,7 +170,8 @@ export const createSymbolTable = (): SymbolTable => {
|
||||
fileIndex.clear();
|
||||
globalIndex.clear();
|
||||
callableIndex = null;
|
||||
fieldByOwner.clear();
|
||||
};
|
||||
|
||||
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, getStats, clear };
|
||||
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, lookupFieldByOwner, getStats, clear };
|
||||
};
|
||||
|
||||
@@ -62,6 +62,19 @@ export const TYPESCRIPT_QUERIES = `
|
||||
(new_expression
|
||||
constructor: (identifier) @call.name) @call
|
||||
|
||||
; Class properties — public_field_definition covers most TS class fields
|
||||
(public_field_definition
|
||||
name: (property_identifier) @name) @definition.property
|
||||
|
||||
; Private class fields: #address: Address
|
||||
(public_field_definition
|
||||
name: (private_property_identifier) @name) @definition.property
|
||||
|
||||
; Constructor parameter properties: constructor(public address: Address)
|
||||
(required_parameter
|
||||
(accessibility_modifier)
|
||||
pattern: (identifier) @name) @definition.property
|
||||
|
||||
; Heritage queries - class extends
|
||||
(class_declaration
|
||||
name: (type_identifier) @heritage.class
|
||||
@@ -75,6 +88,20 @@ export const TYPESCRIPT_QUERIES = `
|
||||
(class_heritage
|
||||
(implements_clause
|
||||
(type_identifier) @heritage.implements))) @heritage.impl
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (member_expression
|
||||
object: (_) @assignment.receiver
|
||||
property: (property_identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
|
||||
; Write access: obj.field += value (compound assignment)
|
||||
(augmented_assignment_expression
|
||||
left: (member_expression
|
||||
object: (_) @assignment.receiver
|
||||
property: (property_identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
`;
|
||||
|
||||
// JavaScript queries - works with tree-sitter-javascript
|
||||
@@ -128,12 +155,30 @@ export const JAVASCRIPT_QUERIES = `
|
||||
(new_expression
|
||||
constructor: (identifier) @call.name) @call
|
||||
|
||||
; Class fields — field_definition captures JS class fields (class User { address = ... })
|
||||
(field_definition
|
||||
property: (property_identifier) @name) @definition.property
|
||||
|
||||
; Heritage queries - class extends (JavaScript uses different AST than TypeScript)
|
||||
; In tree-sitter-javascript, class_heritage directly contains the parent identifier
|
||||
(class_declaration
|
||||
name: (identifier) @heritage.class
|
||||
(class_heritage
|
||||
(identifier) @heritage.extends)) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (member_expression
|
||||
object: (_) @assignment.receiver
|
||||
property: (property_identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
|
||||
; Write access: obj.field += value (compound assignment)
|
||||
(augmented_assignment_expression
|
||||
left: (member_expression
|
||||
object: (_) @assignment.receiver
|
||||
property: (property_identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
`;
|
||||
|
||||
// Python queries - works with tree-sitter-python
|
||||
@@ -160,11 +205,33 @@ export const PYTHON_QUERIES = `
|
||||
function: (attribute
|
||||
attribute: (identifier) @call.name)) @call
|
||||
|
||||
; Class attribute type annotations — PEP 526: address: Address or address: Address = Address()
|
||||
; Both bare annotations (address: Address) and annotated assignments (name: str = "test")
|
||||
; are parsed as (assignment left: ... type: ...) in tree-sitter-python.
|
||||
(expression_statement
|
||||
(assignment
|
||||
left: (identifier) @name
|
||||
type: (type)) @definition.property)
|
||||
|
||||
; Heritage queries - Python class inheritance
|
||||
(class_definition
|
||||
name: (identifier) @heritage.class
|
||||
superclasses: (argument_list
|
||||
(identifier) @heritage.extends)) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment
|
||||
left: (attribute
|
||||
object: (_) @assignment.receiver
|
||||
attribute: (identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
|
||||
; Write access: obj.field += value (compound assignment)
|
||||
(augmented_assignment
|
||||
left: (attribute
|
||||
object: (_) @assignment.receiver
|
||||
attribute: (identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
`;
|
||||
|
||||
// Java queries - works with tree-sitter-java
|
||||
@@ -179,6 +246,11 @@ export const JAVA_QUERIES = `
|
||||
(method_declaration name: (identifier) @name) @definition.method
|
||||
(constructor_declaration name: (identifier) @name) @definition.constructor
|
||||
|
||||
; Fields — typed field declarations inside class bodies
|
||||
(field_declaration
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @name)) @definition.property
|
||||
|
||||
; Imports - capture any import declaration child as source
|
||||
(import_declaration (_) @import.source) @import
|
||||
|
||||
@@ -196,6 +268,13 @@ export const JAVA_QUERIES = `
|
||||
; Heritage - implements interfaces
|
||||
(class_declaration name: (identifier) @heritage.class
|
||||
(super_interfaces (type_list (type_identifier) @heritage.implements))) @heritage.impl
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (field_access
|
||||
object: (_) @assignment.receiver
|
||||
field: (identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
`;
|
||||
|
||||
// C queries - works with tree-sitter-c
|
||||
@@ -243,6 +322,11 @@ export const GO_QUERIES = `
|
||||
(import_declaration (import_spec path: (interpreted_string_literal) @import.source)) @import
|
||||
(import_declaration (import_spec_list (import_spec path: (interpreted_string_literal) @import.source))) @import
|
||||
|
||||
; Struct fields — named field declarations inside struct types
|
||||
(field_declaration_list
|
||||
(field_declaration
|
||||
name: (field_identifier) @name) @definition.property)
|
||||
|
||||
; Struct embedding (anonymous fields = inheritance)
|
||||
(type_declaration
|
||||
(type_spec
|
||||
@@ -258,6 +342,14 @@ export const GO_QUERIES = `
|
||||
|
||||
; Struct literal construction: User{Name: "Alice"}
|
||||
(composite_literal type: (type_identifier) @call.name) @call
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_statement
|
||||
left: (expression_list
|
||||
(selector_expression
|
||||
operand: (_) @assignment.receiver
|
||||
field: (field_identifier) @assignment.property))
|
||||
right: (_)) @assignment
|
||||
`;
|
||||
|
||||
// C++ queries - works with tree-sitter-cpp
|
||||
@@ -299,6 +391,21 @@ export const CPP_QUERIES = `
|
||||
(declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.function
|
||||
(declaration declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function
|
||||
|
||||
; Class/struct data member fields (Address address; int count;)
|
||||
; Uses field_identifier to exclude method declarations (which use function_declarator)
|
||||
(field_declaration
|
||||
declarator: (field_identifier) @name) @definition.property
|
||||
|
||||
; Pointer member fields (Address* address;)
|
||||
(field_declaration
|
||||
declarator: (pointer_declarator
|
||||
declarator: (field_identifier) @name)) @definition.property
|
||||
|
||||
; Reference member fields (Address& address;)
|
||||
(field_declaration
|
||||
declarator: (reference_declarator
|
||||
(field_identifier) @name)) @definition.property
|
||||
|
||||
; Inline class method declarations (inside class body, no body: void Foo();)
|
||||
(field_declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.method
|
||||
|
||||
@@ -329,6 +436,14 @@ export const CPP_QUERIES = `
|
||||
(base_class_clause (type_identifier) @heritage.extends)) @heritage
|
||||
(class_specifier name: (type_identifier) @heritage.class
|
||||
(base_class_clause (access_specifier) (type_identifier) @heritage.extends)) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (field_expression
|
||||
argument: (_) @assignment.receiver
|
||||
field: (field_identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
|
||||
`;
|
||||
|
||||
// C# queries - works with tree-sitter-c-sharp
|
||||
@@ -383,6 +498,13 @@ export const CSHARP_QUERIES = `
|
||||
(base_list (identifier) @heritage.extends)) @heritage
|
||||
(class_declaration name: (identifier) @heritage.class
|
||||
(base_list (generic_name (identifier) @heritage.extends))) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (member_access_expression
|
||||
expression: (_) @assignment.receiver
|
||||
name: (identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
`;
|
||||
|
||||
// Rust queries - works with tree-sitter-rust
|
||||
@@ -414,11 +536,30 @@ export const RUST_QUERIES = `
|
||||
; Struct literal construction: User { name: value }
|
||||
(struct_expression name: (type_identifier) @call.name) @call
|
||||
|
||||
; Struct fields — named field declarations inside struct bodies
|
||||
(field_declaration_list
|
||||
(field_declaration
|
||||
name: (field_identifier) @name) @definition.property)
|
||||
|
||||
; Heritage (trait implementation) — all combinations of concrete/generic trait × concrete/generic type
|
||||
(impl_item trait: (type_identifier) @heritage.trait type: (type_identifier) @heritage.class) @heritage
|
||||
(impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (type_identifier) @heritage.class) @heritage
|
||||
(impl_item trait: (type_identifier) @heritage.trait type: (generic_type type: (type_identifier) @heritage.class)) @heritage
|
||||
(impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (generic_type type: (type_identifier) @heritage.class)) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment_expression
|
||||
left: (field_expression
|
||||
value: (_) @assignment.receiver
|
||||
field: (field_identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
|
||||
; Write access: obj.field += value (compound assignment)
|
||||
(compound_assignment_expr
|
||||
left: (field_expression
|
||||
value: (_) @assignment.receiver
|
||||
field: (field_identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
`;
|
||||
|
||||
// PHP queries - works with tree-sitter-php (php_only grammar)
|
||||
@@ -457,6 +598,13 @@ export const PHP_QUERIES = `
|
||||
(variable_name
|
||||
(name) @name))) @definition.property
|
||||
|
||||
; Constructor property promotion (PHP 8.0+: public Address $address in __construct)
|
||||
(method_declaration
|
||||
parameters: (formal_parameters
|
||||
(property_promotion_parameter
|
||||
name: (variable_name
|
||||
(name) @name)))) @definition.property
|
||||
|
||||
; ── Imports: use statements ──────────────────────────────────────────────────
|
||||
; Simple: use App\\Models\\User;
|
||||
(namespace_use_declaration
|
||||
@@ -501,6 +649,20 @@ export const PHP_QUERIES = `
|
||||
body: (declaration_list
|
||||
(use_declaration
|
||||
[(name) (qualified_name)] @heritage.trait))) @heritage
|
||||
|
||||
; Write access: $obj->field = value
|
||||
(assignment_expression
|
||||
left: (member_access_expression
|
||||
object: (_) @assignment.receiver
|
||||
name: (name) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
|
||||
; Write access: ClassName::$field = value (static property)
|
||||
(assignment_expression
|
||||
left: (scoped_property_access_expression
|
||||
scope: (_) @assignment.receiver
|
||||
name: (variable_name (name) @assignment.property))
|
||||
right: (_)) @assignment
|
||||
`;
|
||||
|
||||
// Ruby queries - works with tree-sitter-ruby
|
||||
@@ -546,6 +708,20 @@ export const RUBY_QUERIES = `
|
||||
name: (constant) @heritage.class
|
||||
superclass: (superclass
|
||||
(constant) @heritage.extends)) @heritage
|
||||
|
||||
; Write access: obj.field = value (Ruby setter — syntactically a method call to field=)
|
||||
(assignment
|
||||
left: (call
|
||||
receiver: (_) @assignment.receiver
|
||||
method: (identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
|
||||
; Write access: obj.field += value (compound assignment — operator_assignment node, not assignment)
|
||||
(operator_assignment
|
||||
left: (call
|
||||
receiver: (_) @assignment.receiver
|
||||
method: (identifier) @assignment.property)
|
||||
right: (_)) @assignment
|
||||
`;
|
||||
|
||||
// Kotlin queries - works with tree-sitter-kotlin (fwcd/tree-sitter-kotlin)
|
||||
@@ -582,6 +758,12 @@ export const KOTLIN_QUERIES = `
|
||||
(variable_declaration
|
||||
(simple_identifier) @name)) @definition.property
|
||||
|
||||
; Primary constructor val/var parameters (data class, value class, regular class)
|
||||
; binding_pattern_kind contains "val" or "var" — without it, the param is not a property
|
||||
(class_parameter
|
||||
(binding_pattern_kind)
|
||||
(simple_identifier) @name) @definition.property
|
||||
|
||||
; ── Enum entries ─────────────────────────────────────────────────────────
|
||||
(enum_entry
|
||||
(simple_identifier) @name) @definition.enum
|
||||
@@ -626,6 +808,15 @@ export const KOTLIN_QUERIES = `
|
||||
(delegation_specifier
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @heritage.extends)))) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment
|
||||
(directly_assignable_expression
|
||||
(_) @assignment.receiver
|
||||
(navigation_suffix
|
||||
(simple_identifier) @assignment.property))
|
||||
(_)) @assignment
|
||||
|
||||
`;
|
||||
|
||||
// Swift queries - works with tree-sitter-swift
|
||||
@@ -684,6 +875,15 @@ export const SWIFT_QUERIES = `
|
||||
; Extensions wrap the name in user_type unlike class/struct/enum declarations
|
||||
(class_declaration "extension" name: (user_type (type_identifier) @heritage.class)
|
||||
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment
|
||||
(directly_assignable_expression
|
||||
(_) @assignment.receiver
|
||||
(navigation_suffix
|
||||
(simple_identifier) @assignment.property))
|
||||
(_)) @assignment
|
||||
|
||||
`;
|
||||
|
||||
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
|
||||
|
||||
@@ -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`).
|
||||
@@ -439,7 +478,9 @@ export const buildTypeEnv = (
|
||||
let typeNode = node.childForFieldName('type');
|
||||
if (typeNode) {
|
||||
const nameNode = node.childForFieldName('name')
|
||||
?? node.childForFieldName('pattern');
|
||||
?? node.childForFieldName('pattern')
|
||||
// Python typed_parameter: name is a positional child (identifier), not a named field
|
||||
?? (node.firstNamedChild?.type === 'identifier' ? node.firstNamedChild : null);
|
||||
if (nameNode) {
|
||||
const varName = extractVarName(nameNode);
|
||||
if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
|
||||
@@ -609,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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -639,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([
|
||||
|
||||
@@ -62,6 +62,10 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
|
||||
} else {
|
||||
nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern');
|
||||
typeNode = node.childForFieldName('type');
|
||||
// Python typed_parameter: name is a positional child (identifier), not a named field
|
||||
if (!nameNode && node.type === 'typed_parameter') {
|
||||
nameNode = node.firstNamedChild?.type === 'identifier' ? node.firstNamedChild : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!nameNode || !typeNode) return;
|
||||
@@ -219,6 +223,37 @@ const findPyParamElementType = (iterableName: string, startNode: SyntaxNode, pos
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts iterableName and methodName from a call expression like `data.items()`.
|
||||
* Returns undefined if the call doesn't match the expected pattern.
|
||||
*/
|
||||
const extractMethodCall = (callNode: SyntaxNode): { iterableName: string; methodName?: string } | undefined => {
|
||||
const fn = callNode.childForFieldName('function');
|
||||
if (fn?.type !== 'attribute') return undefined;
|
||||
const obj = fn.firstNamedChild;
|
||||
if (obj?.type !== 'identifier') return undefined;
|
||||
const method = fn.lastNamedChild;
|
||||
const methodName = (method?.type === 'identifier' && method !== obj) ? method.text : undefined;
|
||||
return { iterableName: obj.text, methodName };
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects all identifier nodes from a pattern, descending into nested tuple_patterns.
|
||||
* For `i, (k, v)` returns [i, k, v]. For `key, value` returns [key, value].
|
||||
*/
|
||||
const collectPatternIdentifiers = (pattern: SyntaxNode): SyntaxNode[] => {
|
||||
const vars: SyntaxNode[] = [];
|
||||
for (let i = 0; i < pattern.namedChildCount; i++) {
|
||||
const child = pattern.namedChild(i);
|
||||
if (child?.type === 'identifier') {
|
||||
vars.push(child);
|
||||
} else if (child?.type === 'tuple_pattern') {
|
||||
vars.push(...collectPatternIdentifiers(child));
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
};
|
||||
|
||||
/**
|
||||
* Python: for user in users: where users has a known container type annotation.
|
||||
*
|
||||
@@ -228,32 +263,43 @@ const findPyParamElementType = (iterableName: string, startNode: SyntaxNode, pos
|
||||
* 1. declarationTypeNodes — raw type annotation AST node (covers stored container types)
|
||||
* 2. scopeEnv string — extractElementTypeFromString on the stored type
|
||||
* 3. AST walk — walks up to the enclosing function's parameters to read List[User] directly
|
||||
*
|
||||
* Also handles `enumerate(iterable)` — unwraps the outer call and skips the integer
|
||||
* index variable so the value variable still resolves to the element type.
|
||||
*/
|
||||
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
|
||||
if (node.type !== 'for_statement') return;
|
||||
|
||||
// The iterable is the `right` field — may be identifier, attribute, or call.
|
||||
const rightNode = node.childForFieldName('right');
|
||||
let iterableName: string | undefined;
|
||||
let methodName: string | undefined;
|
||||
let callExprElementType: string | undefined;
|
||||
let isEnumerate = false;
|
||||
|
||||
// Extract iterable info from the `right` field — may be identifier, attribute, or call.
|
||||
if (rightNode?.type === 'identifier') {
|
||||
iterableName = rightNode.text;
|
||||
} else if (rightNode?.type === 'attribute') {
|
||||
const prop = rightNode.lastNamedChild;
|
||||
if (prop) iterableName = prop.text;
|
||||
} else if (rightNode?.type === 'call') {
|
||||
// data.items() → call > function: attribute > identifier('data') + identifier('items')
|
||||
// get_users() → call > function: identifier (Phase 7.3 — return-type path)
|
||||
const fn = rightNode.childForFieldName('function');
|
||||
if (fn?.type === 'attribute') {
|
||||
const obj = fn.firstNamedChild;
|
||||
if (obj?.type === 'identifier') iterableName = obj.text;
|
||||
// Extract method name: items, keys, values
|
||||
const method = fn.lastNamedChild;
|
||||
if (method?.type === 'identifier' && method !== obj) methodName = method.text;
|
||||
if (fn?.type === 'identifier' && fn.text === 'enumerate') {
|
||||
// enumerate(iterable) or enumerate(d.items()) — unwrap to inner iterable.
|
||||
isEnumerate = true;
|
||||
const innerArg = rightNode.childForFieldName('arguments')?.firstNamedChild;
|
||||
if (innerArg?.type === 'identifier') {
|
||||
iterableName = innerArg.text;
|
||||
} else if (innerArg?.type === 'call') {
|
||||
const extracted = extractMethodCall(innerArg);
|
||||
if (extracted) ({ iterableName, methodName } = extracted);
|
||||
}
|
||||
} else if (fn?.type === 'attribute') {
|
||||
// data.items() → call > function: attribute > identifier('data') + identifier('items')
|
||||
const extracted = extractMethodCall(rightNode);
|
||||
if (extracted) ({ iterableName, methodName } = extracted);
|
||||
} else if (fn?.type === 'identifier') {
|
||||
// Direct function call: for user in get_users()
|
||||
// Direct function call: for user in get_users() (Phase 7.3 — return-type path)
|
||||
const rawReturn = returnTypeLookup.lookupRawReturnType(fn.text);
|
||||
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
|
||||
}
|
||||
@@ -278,11 +324,12 @@ const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTy
|
||||
const leftNode = node.childForFieldName('left');
|
||||
if (!leftNode) return;
|
||||
|
||||
// Handle tuple unpacking: for key, value in data.items()
|
||||
if (leftNode.type === 'pattern_list') {
|
||||
const lastChild = leftNode.lastNamedChild;
|
||||
if (lastChild?.type === 'identifier') {
|
||||
scopeEnv.set(lastChild.text, elementType);
|
||||
if (leftNode.type === 'pattern_list' || leftNode.type === 'tuple_pattern') {
|
||||
// Tuple unpacking: `key, value` or `i, (k, v)` or `(k, v)` — bind the last identifier to element type.
|
||||
// With enumerate, skip binding if there's only one var (just the index, no value to bind).
|
||||
const vars = collectPatternIdentifiers(leftNode);
|
||||
if (vars.length > 0 && (!isEnumerate || vars.length > 1)) {
|
||||
scopeEnv.set(vars[vars.length - 1].text, elementType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -311,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 = {
|
||||
|
||||
@@ -95,7 +95,7 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
|
||||
};
|
||||
|
||||
/** Rust: let x = User::new(), let x = User::default(), or let x = User { ... } */
|
||||
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, _classNames: ClassNameLookup): void => {
|
||||
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
|
||||
// Skip if there's an explicit type annotation — Tier 0 already handled it
|
||||
if (node.childForFieldName('type') !== null) return;
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
@@ -116,6 +116,13 @@ const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<str
|
||||
return;
|
||||
}
|
||||
|
||||
// Unit struct instantiation: let svc = UserService; (bare identifier, no braces or call)
|
||||
if (value.type === 'identifier' && classNames.has(value.text)) {
|
||||
const varName = extractVarName(pattern);
|
||||
if (varName) env.set(varName, value.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.type !== 'call_expression') return;
|
||||
const func = value.childForFieldName('function');
|
||||
if (!func || func.type !== 'scoped_identifier') return;
|
||||
@@ -190,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;
|
||||
};
|
||||
|
||||
|
||||
@@ -263,8 +263,14 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode, depth = 0): string |
|
||||
|
||||
// Pointer/reference types (C++, Rust): User*, &User, &mut User
|
||||
if (typeNode.type === 'pointer_type' || typeNode.type === 'reference_type') {
|
||||
const inner = typeNode.firstNamedChild;
|
||||
if (inner) return extractSimpleTypeName(inner, depth + 1);
|
||||
// Skip mutable_specifier for Rust &mut references — firstNamedChild would be
|
||||
// `mutable_specifier` not the actual type. Walk named children to find the type.
|
||||
for (let i = 0; i < typeNode.namedChildCount; i++) {
|
||||
const child = typeNode.namedChild(i);
|
||||
if (child && child.type !== 'mutable_specifier') {
|
||||
return extractSimpleTypeName(child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Primitive/predefined types: string, int, float, bool, number, unknown, any
|
||||
@@ -745,3 +751,102 @@ export const extractReturnTypeName = (raw: string, depth = 0): string | undefine
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
// ── Property declared-type extraction ────────────────────────────────────
|
||||
// Shared between parse-worker (worker path) and parsing-processor (sequential path).
|
||||
|
||||
/**
|
||||
* Extract the declared type of a property/field from its AST definition node.
|
||||
* Handles cross-language patterns:
|
||||
* - TypeScript: `name: Type` → type_annotation child
|
||||
* - Java: `Type name` → type child on field_declaration
|
||||
* - C#: `Type Name { get; set; }` → type child on property_declaration
|
||||
* - Go: `Name Type` → type child on field_declaration
|
||||
* - Kotlin: `var name: Type` → variable_declaration child with type field
|
||||
*
|
||||
* Returns the normalized type name, or undefined if no type can be extracted.
|
||||
*/
|
||||
export const extractPropertyDeclaredType = (definitionNode: SyntaxNode | null): string | undefined => {
|
||||
if (!definitionNode) return undefined;
|
||||
|
||||
// Strategy 1: Look for a `type` or `type_annotation` named field
|
||||
const typeNode = definitionNode.childForFieldName?.('type');
|
||||
if (typeNode) {
|
||||
const typeName = extractSimpleTypeName(typeNode);
|
||||
if (typeName) return typeName;
|
||||
// Fallback: use the raw text (for complex types like User[] or List<User>)
|
||||
const text = typeNode.text?.trim();
|
||||
if (text && text.length < 100) return text;
|
||||
}
|
||||
|
||||
// Strategy 2: Walk children looking for type_annotation (TypeScript pattern)
|
||||
for (let i = 0; i < definitionNode.childCount; i++) {
|
||||
const child = definitionNode.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === 'type_annotation') {
|
||||
// Type annotation has the actual type as a child
|
||||
for (let j = 0; j < child.childCount; j++) {
|
||||
const typeChild = child.child(j);
|
||||
if (typeChild && typeChild.type !== ':') {
|
||||
const typeName = extractSimpleTypeName(typeChild);
|
||||
if (typeName) return typeName;
|
||||
const text = typeChild.text?.trim();
|
||||
if (text && text.length < 100) return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: For Java field_declaration, the type is a sibling of variable_declarator
|
||||
// AST: (field_declaration type: (type_identifier) declarator: (variable_declarator ...))
|
||||
const parentDecl = definitionNode.parent;
|
||||
if (parentDecl) {
|
||||
const parentType = parentDecl.childForFieldName?.('type');
|
||||
if (parentType) {
|
||||
const typeName = extractSimpleTypeName(parentType);
|
||||
if (typeName) return typeName;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Kotlin property_declaration — type is nested inside variable_declaration child
|
||||
// AST: (property_declaration (variable_declaration (simple_identifier) ":" (user_type (type_identifier))))
|
||||
// Kotlin's variable_declaration has NO named 'type' field — children are all positional.
|
||||
for (let i = 0; i < definitionNode.childCount; i++) {
|
||||
const child = definitionNode.child(i);
|
||||
if (child?.type === 'variable_declaration') {
|
||||
// Try named field first (works for other languages sharing this strategy)
|
||||
const varType = child.childForFieldName?.('type');
|
||||
if (varType) {
|
||||
const typeName = extractSimpleTypeName(varType);
|
||||
if (typeName) return typeName;
|
||||
const text = varType.text?.trim();
|
||||
if (text && text.length < 100) return text;
|
||||
}
|
||||
// Fallback: walk unnamed children for user_type / type_identifier (Kotlin)
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const varChild = child.namedChild(j);
|
||||
if (varChild && (varChild.type === 'user_type' || varChild.type === 'type_identifier'
|
||||
|| varChild.type === 'nullable_type' || varChild.type === 'generic_type')) {
|
||||
const typeName = extractSimpleTypeName(varChild);
|
||||
if (typeName) return typeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 5: PHP @var PHPDoc — look for preceding comment with @var Type
|
||||
// Handles pre-PHP-7.4 code: /** @var Address */ public $address;
|
||||
const prevSibling = definitionNode.previousNamedSibling ?? definitionNode.parent?.previousNamedSibling;
|
||||
if (prevSibling?.type === 'comment') {
|
||||
const commentText = prevSibling.text;
|
||||
const varMatch = commentText?.match(/@var\s+([A-Z][\w\\]*)/);
|
||||
if (varMatch) {
|
||||
// Strip namespace prefix: \App\Models\User → User
|
||||
const raw = varMatch[1];
|
||||
const base = raw.includes('\\') ? raw.split('\\').pop()! : raw;
|
||||
if (base && /^[A-Z]\w*$/.test(base)) return base;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -264,7 +264,7 @@ export const CLASS_CONTAINER_TYPES = new Set([
|
||||
'class_declaration', 'abstract_class_declaration',
|
||||
'interface_declaration', 'struct_declaration', 'record_declaration',
|
||||
'class_specifier', 'struct_specifier',
|
||||
'impl_item', 'trait_item',
|
||||
'impl_item', 'trait_item', 'struct_item', 'enum_item',
|
||||
'class_definition',
|
||||
'trait_declaration',
|
||||
'protocol_declaration',
|
||||
@@ -286,6 +286,8 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
|
||||
class_definition: 'Class',
|
||||
impl_item: 'Impl',
|
||||
trait_item: 'Trait',
|
||||
struct_item: 'Struct',
|
||||
enum_item: 'Enum',
|
||||
trait_declaration: 'Trait',
|
||||
record_declaration: 'Record',
|
||||
protocol_declaration: 'Interface',
|
||||
@@ -318,6 +320,21 @@ export const findEnclosingClassId = (node: any, filePath: string): string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
// Go: type_declaration wrapping a struct_type (type User struct { ... })
|
||||
// field_declaration → field_declaration_list → struct_type → type_spec → type_declaration
|
||||
if (current.type === 'type_declaration') {
|
||||
const typeSpec = current.children?.find((c: any) => c.type === 'type_spec');
|
||||
if (typeSpec) {
|
||||
const typeBody = typeSpec.childForFieldName?.('type');
|
||||
if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') {
|
||||
const nameNode = typeSpec.childForFieldName?.('name');
|
||||
if (nameNode) {
|
||||
const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface';
|
||||
return generateId(label, `${filePath}:${nameNode.text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (CLASS_CONTAINER_TYPES.has(current.type)) {
|
||||
// Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for`
|
||||
if (current.type === 'impl_item') {
|
||||
@@ -1156,6 +1173,143 @@ export function extractCallChain(
|
||||
return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined;
|
||||
}
|
||||
|
||||
/** Node types representing member/field access across languages. */
|
||||
const FIELD_ACCESS_NODE_TYPES = new Set([
|
||||
'member_expression', // TS/JS
|
||||
'member_access_expression', // C#
|
||||
'selector_expression', // Go
|
||||
'field_expression', // Rust/C++
|
||||
'field_access', // Java
|
||||
'attribute', // Python
|
||||
'navigation_expression', // Kotlin/Swift
|
||||
'member_binding_expression', // C# null-conditional (user?.Address)
|
||||
]);
|
||||
|
||||
/** One step in a mixed receiver chain. */
|
||||
export type MixedChainStep = { kind: 'field' | 'call'; name: string };
|
||||
|
||||
/**
|
||||
* Walk a receiver AST node that may interleave field accesses and method calls,
|
||||
* building a unified chain of steps up to MAX_CHAIN_DEPTH.
|
||||
*
|
||||
* For `svc.getUser().address.save()`, called with the receiver of `save`
|
||||
* (`svc.getUser().address`, a field access node):
|
||||
* returns { chain: [{ kind:'call', name:'getUser' }, { kind:'field', name:'address' }],
|
||||
* baseReceiverName: 'svc' }
|
||||
*
|
||||
* For `user.getAddress().city.getName()`, called with receiver of `getName`
|
||||
* (`user.getAddress().city`):
|
||||
* returns { chain: [{ kind:'call', name:'getAddress' }, { kind:'field', name:'city' }],
|
||||
* baseReceiverName: 'user' }
|
||||
*
|
||||
* Pure field chains and pure call chains are special cases (all steps same kind).
|
||||
*/
|
||||
export function extractMixedChain(
|
||||
receiverNode: SyntaxNode,
|
||||
): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined {
|
||||
const chain: MixedChainStep[] = [];
|
||||
let current: SyntaxNode = receiverNode;
|
||||
|
||||
while (chain.length < MAX_CHAIN_DEPTH) {
|
||||
if (CALL_EXPRESSION_TYPES.has(current.type)) {
|
||||
// ── Call expression: extract method name + inner receiver ────────────
|
||||
const funcNode = current.childForFieldName?.('function')
|
||||
?? current.childForFieldName?.('name')
|
||||
?? current.childForFieldName?.('method');
|
||||
let methodName: string | undefined;
|
||||
let innerReceiver: SyntaxNode | null = null;
|
||||
|
||||
if (funcNode) {
|
||||
methodName = funcNode.lastNamedChild?.text ?? funcNode.text;
|
||||
}
|
||||
// Kotlin/Swift: call_expression → navigation_expression
|
||||
if (!funcNode && current.type === 'call_expression') {
|
||||
const callee = current.firstNamedChild;
|
||||
if (callee?.type === 'navigation_expression') {
|
||||
const suffix = callee.lastNamedChild;
|
||||
if (suffix?.type === 'navigation_suffix') {
|
||||
methodName = suffix.lastNamedChild?.text;
|
||||
for (let i = 0; i < callee.namedChildCount; i++) {
|
||||
const child = callee.namedChild(i);
|
||||
if (child && child.type !== 'navigation_suffix') { innerReceiver = child; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!methodName) break;
|
||||
chain.unshift({ kind: 'call', name: methodName });
|
||||
|
||||
if (!innerReceiver && funcNode) {
|
||||
innerReceiver = funcNode.childForFieldName?.('object')
|
||||
?? funcNode.childForFieldName?.('value')
|
||||
?? funcNode.childForFieldName?.('operand')
|
||||
?? funcNode.childForFieldName?.('argument') // C/C++ field_expression
|
||||
?? funcNode.childForFieldName?.('expression')
|
||||
?? null;
|
||||
}
|
||||
if (!innerReceiver && current.type === 'method_invocation') {
|
||||
innerReceiver = current.childForFieldName?.('object') ?? null;
|
||||
}
|
||||
if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) {
|
||||
innerReceiver = current.childForFieldName?.('object') ?? null;
|
||||
}
|
||||
if (!innerReceiver && current.type === 'call') {
|
||||
innerReceiver = current.childForFieldName?.('receiver') ?? null;
|
||||
}
|
||||
if (!innerReceiver) break;
|
||||
|
||||
if (CALL_EXPRESSION_TYPES.has(innerReceiver.type) || FIELD_ACCESS_NODE_TYPES.has(innerReceiver.type)) {
|
||||
current = innerReceiver;
|
||||
} else {
|
||||
return { chain, baseReceiverName: innerReceiver.text || undefined };
|
||||
}
|
||||
} else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) {
|
||||
// ── Field/member access: extract property name + inner object ─────────
|
||||
let propertyName: string | undefined;
|
||||
let innerObject: SyntaxNode | null = null;
|
||||
|
||||
if (current.type === 'navigation_expression') {
|
||||
for (const child of current.children ?? []) {
|
||||
if (child.type === 'navigation_suffix') {
|
||||
for (const sc of child.children ?? []) {
|
||||
if (sc.isNamed && sc.type !== '.') { propertyName = sc.text; break; }
|
||||
}
|
||||
} else if (child.isNamed && !innerObject) {
|
||||
innerObject = child;
|
||||
}
|
||||
}
|
||||
} else if (current.type === 'attribute') {
|
||||
innerObject = current.childForFieldName?.('object') ?? null;
|
||||
propertyName = current.childForFieldName?.('attribute')?.text;
|
||||
} else {
|
||||
innerObject = current.childForFieldName?.('object')
|
||||
?? current.childForFieldName?.('value')
|
||||
?? current.childForFieldName?.('operand')
|
||||
?? current.childForFieldName?.('argument') // C/C++ field_expression
|
||||
?? current.childForFieldName?.('expression')
|
||||
?? null;
|
||||
propertyName = (current.childForFieldName?.('property')
|
||||
?? current.childForFieldName?.('field')
|
||||
?? current.childForFieldName?.('name'))?.text;
|
||||
}
|
||||
|
||||
if (!propertyName) break;
|
||||
chain.unshift({ kind: 'field', name: propertyName });
|
||||
|
||||
if (!innerObject) break;
|
||||
|
||||
if (CALL_EXPRESSION_TYPES.has(innerObject.type) || FIELD_ACCESS_NODE_TYPES.has(innerObject.type)) {
|
||||
current = innerObject;
|
||||
} else {
|
||||
return { chain, baseReceiverName: innerObject.text || undefined };
|
||||
}
|
||||
} else {
|
||||
// Simple identifier — this is the base receiver
|
||||
return chain.length > 0
|
||||
? { chain, baseReceiverName: current.text || undefined }
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined;
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ import {
|
||||
inferCallForm,
|
||||
extractReceiverName,
|
||||
extractReceiverNode,
|
||||
CALL_EXPRESSION_TYPES,
|
||||
extractCallChain,
|
||||
extractMixedChain,
|
||||
type MixedChainStep,
|
||||
} from '../utils.js';
|
||||
import { buildTypeEnv } from '../type-env.js';
|
||||
import type { ConstructorBinding } from '../type-env.js';
|
||||
@@ -48,6 +48,8 @@ import { generateId } from '../../../lib/utils.js';
|
||||
import { extractNamedBindings } from '../named-binding-extraction.js';
|
||||
import { appendKotlinWildcard } from '../resolvers/index.js';
|
||||
import { callRouters } from '../call-routing.js';
|
||||
import { extractPropertyDeclaredType } from '../type-extractors/shared.js';
|
||||
import type { NodeLabel } from '../../graph/types.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types for serializable results
|
||||
@@ -75,7 +77,7 @@ interface ParsedRelationship {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
type: 'DEFINES' | 'HAS_METHOD';
|
||||
type: 'DEFINES' | 'HAS_METHOD' | 'HAS_PROPERTY';
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}
|
||||
@@ -84,9 +86,10 @@ interface ParsedSymbol {
|
||||
filePath: string;
|
||||
name: string;
|
||||
nodeId: string;
|
||||
type: string;
|
||||
type: NodeLabel;
|
||||
parameterCount?: number;
|
||||
returnType?: string;
|
||||
declaredType?: string;
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
@@ -111,13 +114,26 @@ export interface ExtractedCall {
|
||||
/** Resolved type name of the receiver (e.g., 'User' for user.save() when user: User) */
|
||||
receiverTypeName?: string;
|
||||
/**
|
||||
* Chained call names when the receiver is itself a call expression.
|
||||
* For `svc.getUser().save()`, the `save` ExtractedCall gets receiverCallChain = ['getUser']
|
||||
* with receiverName = 'svc'. The chain is ordered outermost-last, e.g.:
|
||||
* `a.b().c().d()` → calledName='d', receiverCallChain=['b','c'], receiverName='a'
|
||||
* Unified mixed chain when the receiver is a chain of field accesses and/or method calls.
|
||||
* Steps are ordered base-first (innermost to outermost). Examples:
|
||||
* `svc.getUser().save()` → chain=[{kind:'call',name:'getUser'}], receiverName='svc'
|
||||
* `user.address.save()` → chain=[{kind:'field',name:'address'}], receiverName='user'
|
||||
* `svc.getUser().address.save()` → chain=[{kind:'call',name:'getUser'},{kind:'field',name:'address'}]
|
||||
* Length is capped at MAX_CHAIN_DEPTH (3).
|
||||
*/
|
||||
receiverCallChain?: string[];
|
||||
receiverMixedChain?: MixedChainStep[];
|
||||
}
|
||||
|
||||
export interface ExtractedAssignment {
|
||||
filePath: string;
|
||||
/** generateId of enclosing function, or generateId('File', filePath) for top-level */
|
||||
sourceId: string;
|
||||
/** Receiver text (e.g., 'user' from user.address = value) */
|
||||
receiverText: string;
|
||||
/** Property name being written (e.g., 'address') */
|
||||
propertyName: string;
|
||||
/** Resolved type name of the receiver if available from TypeEnv */
|
||||
receiverTypeName?: string;
|
||||
}
|
||||
|
||||
export interface ExtractedHeritage {
|
||||
@@ -151,6 +167,7 @@ export interface ParseWorkerResult {
|
||||
symbols: ParsedSymbol[];
|
||||
imports: ExtractedImport[];
|
||||
calls: ExtractedCall[];
|
||||
assignments: ExtractedAssignment[];
|
||||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
constructorBindings: FileConstructorBindings[];
|
||||
@@ -233,7 +250,7 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
|
||||
// Label detection from capture map
|
||||
// ============================================================================
|
||||
|
||||
const getLabelFromCaptures = (captureMap: Record<string, any>): string | null => {
|
||||
const getLabelFromCaptures = (captureMap: Record<string, any>): NodeLabel | null => {
|
||||
// Skip imports (handled separately) and calls
|
||||
if (captureMap['import'] || captureMap['call']) return null;
|
||||
if (!captureMap['name']) return null;
|
||||
@@ -277,6 +294,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
|
||||
symbols: [],
|
||||
imports: [],
|
||||
calls: [],
|
||||
assignments: [],
|
||||
heritage: [],
|
||||
routes: [],
|
||||
constructorBindings: [],
|
||||
@@ -910,6 +928,28 @@ const processFileGroup = (
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract assignment sites (field write access)
|
||||
if (captureMap['assignment'] && captureMap['assignment.receiver'] && captureMap['assignment.property']) {
|
||||
const receiverText = captureMap['assignment.receiver'].text;
|
||||
const propertyName = captureMap['assignment.property'].text;
|
||||
if (receiverText && propertyName) {
|
||||
const srcId = findEnclosingFunctionId(captureMap['assignment'], file.path)
|
||||
|| generateId('File', file.path);
|
||||
let receiverTypeName: string | undefined;
|
||||
if (typeEnv) {
|
||||
receiverTypeName = typeEnv.lookup(receiverText, captureMap['assignment']) ?? undefined;
|
||||
}
|
||||
result.assignments.push({
|
||||
filePath: file.path,
|
||||
sourceId: srcId,
|
||||
receiverText,
|
||||
propertyName,
|
||||
...(receiverTypeName ? { receiverTypeName } : {}),
|
||||
});
|
||||
}
|
||||
if (!captureMap['call']) continue;
|
||||
}
|
||||
|
||||
// Extract call sites
|
||||
if (captureMap['call']) {
|
||||
const callNameNode = captureMap['call.name'];
|
||||
@@ -965,6 +1005,7 @@ const processFileGroup = (
|
||||
nodeId,
|
||||
type: 'Property',
|
||||
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
|
||||
...(item.declaredType ? { declaredType: item.declaredType } : {}),
|
||||
});
|
||||
const fileId = generateId('File', file.path);
|
||||
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
|
||||
@@ -978,10 +1019,10 @@ const processFileGroup = (
|
||||
});
|
||||
if (propEnclosingClassId) {
|
||||
result.relationships.push({
|
||||
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
|
||||
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
|
||||
sourceId: propEnclosingClassId,
|
||||
targetId: nodeId,
|
||||
type: 'HAS_METHOD',
|
||||
type: 'HAS_PROPERTY',
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
@@ -1000,27 +1041,20 @@ const processFileGroup = (
|
||||
const callForm = inferCallForm(callNode, callNameNode);
|
||||
let receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined;
|
||||
let receiverTypeName = receiverName ? typeEnv.lookup(receiverName, callNode) : undefined;
|
||||
let receiverCallChain: string[] | undefined;
|
||||
let receiverMixedChain: MixedChainStep[] | undefined;
|
||||
|
||||
// When the receiver is a call_expression (e.g. svc.getUser().save()),
|
||||
// extractReceiverName returns undefined because it refuses complex expressions.
|
||||
// Instead, walk the receiver node to build a call chain for deferred resolution.
|
||||
// We capture the base receiver name so processCallsFromExtracted can look it up
|
||||
// from constructor bindings. receiverTypeName is intentionally left unset here —
|
||||
// the chain resolver in processCallsFromExtracted needs the base type as input and
|
||||
// produces the final receiver type as output.
|
||||
// When the receiver is a complex expression (call chain, field chain, or mixed),
|
||||
// extractReceiverName returns undefined. Walk the receiver node to build a unified
|
||||
// mixed chain for deferred resolution in processCallsFromExtracted.
|
||||
if (callForm === 'member' && receiverName === undefined && !receiverTypeName) {
|
||||
const receiverNode = extractReceiverNode(callNameNode);
|
||||
if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
|
||||
const extracted = extractCallChain(receiverNode);
|
||||
if (extracted) {
|
||||
receiverCallChain = extracted.chain;
|
||||
// Set receiverName to the base object so Step 1 in processCallsFromExtracted
|
||||
// can resolve it via constructor bindings to a base type for the chain.
|
||||
if (receiverNode) {
|
||||
const extracted = extractMixedChain(receiverNode);
|
||||
if (extracted && extracted.chain.length > 0) {
|
||||
receiverMixedChain = extracted.chain;
|
||||
receiverName = extracted.baseReceiverName;
|
||||
// Also try the type environment immediately (covers explicitly-typed locals
|
||||
// and annotated parameters like `fn process(svc: &UserService)`).
|
||||
// This sets a base type that chain resolution (Step 2) will use as input.
|
||||
// Try the type environment immediately for the base receiver
|
||||
// (covers explicitly-typed locals and annotated parameters).
|
||||
if (receiverName) {
|
||||
receiverTypeName = typeEnv.lookup(receiverName, callNode);
|
||||
}
|
||||
@@ -1036,7 +1070,7 @@ const processFileGroup = (
|
||||
...(callForm !== undefined ? { callForm } : {}),
|
||||
...(receiverName !== undefined ? { receiverName } : {}),
|
||||
...(receiverTypeName !== undefined ? { receiverTypeName } : {}),
|
||||
...(receiverCallChain !== undefined ? { receiverCallChain } : {}),
|
||||
...(receiverMixedChain !== undefined ? { receiverMixedChain } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1086,6 +1120,23 @@ const processFileGroup = (
|
||||
const nodeLabel = getLabelFromCaptures(captureMap);
|
||||
if (!nodeLabel) continue;
|
||||
|
||||
// C/C++: @definition.function is broad and also matches inline class methods (inside
|
||||
// a class/struct body). Those are already captured by @definition.method, so skip
|
||||
// the duplicate Function entry to prevent double-indexing in globalIndex.
|
||||
if (
|
||||
(language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) &&
|
||||
nodeLabel === 'Function'
|
||||
) {
|
||||
let ancestor = captureMap['definition.function']?.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') {
|
||||
break; // inside a class body — duplicate of @definition.method
|
||||
}
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
if (ancestor) continue; // found a class/struct ancestor → skip
|
||||
}
|
||||
|
||||
const nameNode = captureMap['name'];
|
||||
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
|
||||
if (!nameNode && nodeLabel !== 'Constructor') continue;
|
||||
@@ -1109,6 +1160,7 @@ const processFileGroup = (
|
||||
|
||||
let parameterCount: number | undefined;
|
||||
let returnType: string | undefined;
|
||||
let declaredType: string | undefined;
|
||||
if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') {
|
||||
const sig = extractMethodSignature(definitionNode);
|
||||
parameterCount = sig.parameterCount;
|
||||
@@ -1123,6 +1175,10 @@ const processFileGroup = (
|
||||
if (docReturn) returnType = docReturn;
|
||||
}
|
||||
}
|
||||
} else if (nodeLabel === 'Property' && definitionNode) {
|
||||
// Extract the declared type for property/field nodes.
|
||||
// Walk the definition node for type annotation children.
|
||||
declaredType = extractPropertyDeclaredType(definitionNode);
|
||||
}
|
||||
|
||||
result.nodes.push({
|
||||
@@ -1157,6 +1213,7 @@ const processFileGroup = (
|
||||
type: nodeLabel,
|
||||
...(parameterCount !== undefined ? { parameterCount } : {}),
|
||||
...(returnType !== undefined ? { returnType } : {}),
|
||||
...(declaredType !== undefined ? { declaredType } : {}),
|
||||
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
|
||||
});
|
||||
|
||||
@@ -1171,13 +1228,14 @@ const processFileGroup = (
|
||||
reason: '',
|
||||
});
|
||||
|
||||
// ── HAS_METHOD: link method/constructor/property to enclosing class ──
|
||||
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
|
||||
if (enclosingClassId) {
|
||||
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
|
||||
result.relationships.push({
|
||||
id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`),
|
||||
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
|
||||
sourceId: enclosingClassId,
|
||||
targetId: nodeId,
|
||||
type: 'HAS_METHOD',
|
||||
type: memberEdgeType,
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
});
|
||||
@@ -1199,7 +1257,7 @@ const processFileGroup = (
|
||||
/** Accumulated result across sub-batches */
|
||||
let accumulated: ParseWorkerResult = {
|
||||
nodes: [], relationships: [], symbols: [],
|
||||
imports: [], calls: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0,
|
||||
imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0,
|
||||
};
|
||||
let cumulativeProcessed = 0;
|
||||
|
||||
@@ -1209,6 +1267,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
|
||||
target.symbols.push(...src.symbols);
|
||||
target.imports.push(...src.imports);
|
||||
target.calls.push(...src.calls);
|
||||
target.assignments.push(...src.assignments);
|
||||
target.heritage.push(...src.heritage);
|
||||
target.routes.push(...src.routes);
|
||||
target.constructorBindings.push(...src.constructorBindings);
|
||||
@@ -1236,7 +1295,7 @@ parentPort!.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'flush') {
|
||||
parentPort!.postMessage({ type: 'result', data: accumulated });
|
||||
// Reset for potential reuse
|
||||
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0 };
|
||||
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0 };
|
||||
cumulativeProcessed = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -26,7 +26,7 @@ export type NodeTableName = typeof NODE_TABLES[number];
|
||||
export const REL_TABLE_NAME = 'CodeRelation';
|
||||
|
||||
// Valid relation types
|
||||
export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const;
|
||||
export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'ACCESSES', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const;
|
||||
export type RelType = typeof REL_TYPES[number];
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -49,14 +53,14 @@ const MAX_POOL_SIZE = 5;
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
/** Max connections per repo (caps concurrent queries per repo) */
|
||||
const MAX_CONNS_PER_REPO = 8;
|
||||
/** Connections created eagerly on init */
|
||||
const INITIAL_CONNS_PER_REPO = 2;
|
||||
|
||||
let idleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Saved real stdout.write — used to silence LadybugDB native output without race conditions */
|
||||
const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
export const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
let stdoutSilenceCount = 0;
|
||||
/** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */
|
||||
let preWarmActive = false;
|
||||
|
||||
/**
|
||||
* Start the idle cleanup timer (runs every 60s)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -131,6 +161,15 @@ function restoreStdout(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash
|
||||
// inside createConnection before restoreStdout runs).
|
||||
setInterval(() => {
|
||||
if (stdoutSilenceCount > 0 && !preWarmActive) {
|
||||
stdoutSilenceCount = 0;
|
||||
process.stdout.write = realStdoutWrite;
|
||||
}
|
||||
}, 1000).unref();
|
||||
|
||||
function createConnection(db: lbug.Database): lbug.Connection {
|
||||
silenceStdout();
|
||||
try {
|
||||
@@ -148,9 +187,15 @@ const WAITER_TIMEOUT_MS = 15_000;
|
||||
const LOCK_RETRY_ATTEMPTS = 3;
|
||||
const LOCK_RETRY_DELAY_MS = 2000;
|
||||
|
||||
/** Deduplicates concurrent initLbug calls for the same repoId */
|
||||
const initPromises = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Initialize (or reuse) a Database + connection pool for a specific repo.
|
||||
* Retries on lock errors (e.g., when `gitnexus analyze` is running).
|
||||
*
|
||||
* Concurrent calls for the same repoId are deduplicated — the second caller
|
||||
* awaits the first's in-progress init rather than starting a redundant one.
|
||||
*/
|
||||
export const initLbug = async (repoId: string, dbPath: string): Promise<void> => {
|
||||
const existing = pool.get(repoId);
|
||||
@@ -159,6 +204,27 @@ export const initLbug = async (repoId: string, dbPath: string): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent init calls for the same repoId —
|
||||
// prevents double-init race when multiple parallel tool calls
|
||||
// trigger initialization for the same repo simultaneously.
|
||||
const pending = initPromises.get(repoId);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = doInitLbug(repoId, dbPath);
|
||||
initPromises.set(repoId, promise);
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
initPromises.delete(repoId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal init — creates DB, pre-warms connections, loads FTS, then registers pool.
|
||||
* Pool entry is registered LAST so concurrent executeQuery calls see either
|
||||
* "not initialized" (and throw) or a fully ready pool — never a half-built one.
|
||||
*/
|
||||
async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
|
||||
// Check if database exists
|
||||
try {
|
||||
await fs.stat(dbPath);
|
||||
@@ -210,16 +276,22 @@ export const initLbug = async (repoId: string, dbPath: string): Promise<void> =>
|
||||
shared.refCount++;
|
||||
const db = shared.db;
|
||||
|
||||
// Pre-create a small pool of connections
|
||||
// Pre-create the full pool upfront so createConnection() (which silences
|
||||
// stdout) is never called lazily during active query execution.
|
||||
// Mark preWarmActive so the watchdog timer doesn't interfere.
|
||||
preWarmActive = true;
|
||||
const available: lbug.Connection[] = [];
|
||||
for (let i = 0; i < INITIAL_CONNS_PER_REPO; i++) {
|
||||
available.push(createConnection(db));
|
||||
try {
|
||||
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
|
||||
available.push(createConnection(db));
|
||||
}
|
||||
} finally {
|
||||
preWarmActive = false;
|
||||
}
|
||||
|
||||
pool.set(repoId, { db, available, checkedOut: 0, waiters: [], lastUsed: Date.now(), dbPath });
|
||||
ensureIdleTimer();
|
||||
|
||||
// Load FTS extension once per shared Database
|
||||
// Load FTS extension once per shared Database.
|
||||
// Done BEFORE pool registration so no concurrent checkout can grab
|
||||
// the connection while the async FTS load is in progress.
|
||||
if (!shared.ftsLoaded) {
|
||||
try {
|
||||
await available[0].query('LOAD EXTENSION fts');
|
||||
@@ -228,7 +300,75 @@ export const initLbug = async (repoId: string, dbPath: string): Promise<void> =>
|
||||
// Extension may not be installed — FTS queries will fail gracefully
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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, 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a connection from the pool.
|
||||
@@ -242,11 +382,16 @@ function checkout(entry: PoolEntry): Promise<lbug.Connection> {
|
||||
return Promise.resolve(entry.available.pop()!);
|
||||
}
|
||||
|
||||
// Grow the pool if under the cap
|
||||
// Pool was pre-warmed to MAX_CONNS_PER_REPO during init. If we're here
|
||||
// with fewer total connections, something leaked — surface the bug rather
|
||||
// than silently creating a connection (which would silence stdout mid-query).
|
||||
const totalConns = entry.available.length + entry.checkedOut;
|
||||
if (totalConns < MAX_CONNS_PER_REPO) {
|
||||
entry.checkedOut++;
|
||||
return Promise.resolve(createConnection(entry.db));
|
||||
throw new Error(
|
||||
`Connection pool integrity error: expected ${MAX_CONNS_PER_REPO} ` +
|
||||
`connections but found ${totalConns} (${entry.available.length} available, ` +
|
||||
`${entry.checkedOut} checked out)`
|
||||
);
|
||||
}
|
||||
|
||||
// At capacity — queue the caller with a timeout.
|
||||
@@ -266,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()!;
|
||||
@@ -299,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);
|
||||
@@ -370,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);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const VALID_NODE_LABELS = new Set([
|
||||
]);
|
||||
|
||||
/** Valid relation types for impact analysis filtering */
|
||||
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES']);
|
||||
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']);
|
||||
|
||||
/** 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;
|
||||
@@ -898,7 +898,7 @@ export class LocalBackend {
|
||||
// Categorized incoming refs
|
||||
const incomingRows = await executeParameterized(repo.id, `
|
||||
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
|
||||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||||
LIMIT 30
|
||||
`, { symId });
|
||||
@@ -906,7 +906,7 @@ export class LocalBackend {
|
||||
// Categorized outgoing refs
|
||||
const outgoingRows = await executeParameterized(repo.id, `
|
||||
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
|
||||
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
||||
LIMIT 30
|
||||
`, { symId });
|
||||
|
||||
@@ -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
|
||||
@@ -328,6 +335,10 @@ relationships:
|
||||
- IMPORTS: Module imports
|
||||
- EXTENDS: Class inheritance
|
||||
- IMPLEMENTS: Interface implementation
|
||||
- HAS_METHOD: Class/Struct/Interface owns a Method
|
||||
- HAS_PROPERTY: Class/Struct/Interface owns a Property (field)
|
||||
- ACCESSES: Function/Method reads or writes a Property (reason: 'read' or 'write')
|
||||
- OVERRIDES: Method overrides another Method (MRO)
|
||||
- MEMBER_OF: Symbol belongs to community
|
||||
- STEP_IN_PROCESS: Symbol is step N in process
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
GetPromptRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { GITNEXUS_TOOLS } from './tools.js';
|
||||
import { realStdoutWrite } from './core/lbug-adapter.js';
|
||||
import type { LocalBackend } from './local/local-backend.js';
|
||||
import { getResourceDefinitions, getResourceTemplates, readResource } from './resources.js';
|
||||
|
||||
@@ -276,24 +277,45 @@ Follow these steps:
|
||||
export async function startMCPServer(backend: LocalBackend): Promise<void> {
|
||||
const server = createMCPServer(backend);
|
||||
|
||||
// Connect to stdio transport
|
||||
const transport = new CompatibleStdioServerTransport();
|
||||
// Use the shared stdout reference captured at module-load time by the
|
||||
// lbug-adapter. Avoids divergence if anything patches stdout between
|
||||
// module load and server start.
|
||||
const _safeStdout = new Proxy(process.stdout, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'write') return realStdoutWrite;
|
||||
const val = Reflect.get(target, prop, receiver);
|
||||
return typeof val === 'function' ? val.bind(target) : val;
|
||||
}
|
||||
});
|
||||
const transport = new CompatibleStdioServerTransport(process.stdin, _safeStdout);
|
||||
await server.connect(transport);
|
||||
|
||||
// Graceful shutdown helper
|
||||
let shuttingDown = false;
|
||||
const shutdown = async () => {
|
||||
const shutdown = async (exitCode = 0) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
try { await backend.disconnect(); } catch {}
|
||||
try { await server.close(); } catch {}
|
||||
process.exit(0);
|
||||
process.exit(exitCode);
|
||||
};
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// Log crashes to stderr so they aren't silently lost.
|
||||
// uncaughtException is fatal — shut down.
|
||||
// unhandledRejection is logged but kept non-fatal (availability-first):
|
||||
// killing the server for one missed catch would be worse than logging it.
|
||||
process.on('uncaughtException', (err) => {
|
||||
process.stderr.write(`GitNexus MCP uncaughtException: ${err?.stack || err}\n`);
|
||||
shutdown(1);
|
||||
});
|
||||
process.on('unhandledRejection', (reason: any) => {
|
||||
process.stderr.write(`GitNexus MCP unhandledRejection: ${reason?.stack || reason}\n`);
|
||||
});
|
||||
|
||||
// Handle stdio errors — stdin close means the parent process is gone
|
||||
process.stdin.on('end', shutdown);
|
||||
process.stdin.on('error', () => shutdown());
|
||||
|
||||
@@ -78,7 +78,7 @@ SCHEMA:
|
||||
- Nodes: File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process
|
||||
- Multi-language nodes (use backticks): \`Struct\`, \`Enum\`, \`Trait\`, \`Impl\`, etc.
|
||||
- All edges via single CodeRelation table with 'type' property
|
||||
- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS
|
||||
- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS
|
||||
- Edge properties: type (STRING), confidence (DOUBLE), reason (STRING), step (INT32)
|
||||
|
||||
EXAMPLES:
|
||||
@@ -94,6 +94,12 @@ EXAMPLES:
|
||||
• Find all methods of a class:
|
||||
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.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
|
||||
|
||||
• Find method overrides (MRO resolution):
|
||||
MATCH (winner:Method)-[r:CodeRelation {type: 'OVERRIDES'}]->(loser:Method) RETURN winner.name, winner.filePath, loser.filePath, r.reason
|
||||
|
||||
@@ -119,12 +125,14 @@ TIPS:
|
||||
{
|
||||
name: 'context',
|
||||
description: `360-degree view of a single code symbol.
|
||||
Shows categorized incoming/outgoing references (calls, imports, extends, implements), process participation, and file location.
|
||||
Shows categorized incoming/outgoing references (calls, imports, extends, implements, methods, properties, overrides), process participation, and file location.
|
||||
|
||||
WHEN TO USE: After query() to understand a specific symbol in depth. When you need to know all callers, callees, and what execution flows a symbol participates in.
|
||||
AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/process/{processName} for full execution trace.
|
||||
|
||||
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.`,
|
||||
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 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: {
|
||||
@@ -200,7 +208,9 @@ Depth groups:
|
||||
- d=2: LIKELY AFFECTED (indirect)
|
||||
- d=3: MAY NEED TESTING (transitive)
|
||||
|
||||
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES
|
||||
TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes.
|
||||
|
||||
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES, ACCESSES
|
||||
Confidence: 1.0 = certain, <0.8 = fuzzy match`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
@@ -208,7 +218,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match`,
|
||||
target: { type: 'string', description: 'Name of function, class, or file to analyze' },
|
||||
direction: { type: 'string', description: 'upstream (what depends on this) or downstream (what this depends on)' },
|
||||
maxDepth: { type: 'number', description: 'Max relationship depth (default: 3)', default: 3 },
|
||||
relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES (default: usage-based)' },
|
||||
relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES, ACCESSES (default: usage-based, ACCESSES excluded by default)' },
|
||||
includeTests: { type: 'boolean', description: 'Include test files (default: false)' },
|
||||
minConfidence: { type: 'number', description: 'Minimum confidence 0-1 (default: 0.7)' },
|
||||
repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' },
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#include "user.h"
|
||||
|
||||
void processUser() {
|
||||
auto user = getUser("alice");
|
||||
user.save();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "service.h"
|
||||
#include "user.h"
|
||||
#include "repo.h"
|
||||
|
||||
void processUser() {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
class City {
|
||||
public:
|
||||
std::string zipCode;
|
||||
|
||||
std::string getName() {
|
||||
return "city";
|
||||
}
|
||||
};
|
||||
|
||||
class Address {
|
||||
public:
|
||||
City city;
|
||||
std::string street;
|
||||
|
||||
void save() {
|
||||
// persist address
|
||||
}
|
||||
};
|
||||
|
||||
class User {
|
||||
public:
|
||||
std::string name;
|
||||
Address address;
|
||||
|
||||
std::string greet() {
|
||||
return name;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
#include "models.h"
|
||||
|
||||
void processUser(User user) {
|
||||
// 2-level chain: user.address → Address, then .save() → Address#save
|
||||
user.address.save();
|
||||
|
||||
// 3-level chain: user.address → Address, .city → City, .getName() → City#getName
|
||||
user.address.city.getName();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
class Address {
|
||||
public:
|
||||
std::string city;
|
||||
|
||||
void save() {
|
||||
// persist address
|
||||
}
|
||||
};
|
||||
|
||||
class User {
|
||||
public:
|
||||
std::string name;
|
||||
Address address;
|
||||
|
||||
std::string greet() {
|
||||
return name;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
#include "models.h"
|
||||
|
||||
void processUser(User user) {
|
||||
// Field-access chain: user.address → Address, then .save() → Address#save
|
||||
user.address.save();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "models.h"
|
||||
|
||||
void processChain() {
|
||||
auto user = getUser();
|
||||
auto addr = user.address;
|
||||
auto city = addr.getCity();
|
||||
city.save();
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
class Address {
|
||||
public:
|
||||
std::string city;
|
||||
|
||||
void save() {
|
||||
// persist address
|
||||
}
|
||||
};
|
||||
|
||||
class User {
|
||||
public:
|
||||
Address* address; // raw pointer member field
|
||||
Address& ref_address; // reference member field
|
||||
std::string name;
|
||||
|
||||
std::string greet() {
|
||||
return name;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
#include "models.h"
|
||||
|
||||
void processUser(User user) {
|
||||
// Pointer member field access: user.address->save()
|
||||
user.address->save();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
class Address {
|
||||
public:
|
||||
std::string city;
|
||||
};
|
||||
|
||||
class User {
|
||||
public:
|
||||
std::string name;
|
||||
Address address;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
#include "models.h"
|
||||
|
||||
void updateUser(User& user) {
|
||||
user.name = "Alice";
|
||||
user.address = Address();
|
||||
user.name += " Smith";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace DeepFieldChain;
|
||||
|
||||
public class City
|
||||
{
|
||||
public string ZipCode { get; set; }
|
||||
|
||||
public string GetName()
|
||||
{
|
||||
return "city";
|
||||
}
|
||||
}
|
||||
|
||||
public class Address
|
||||
{
|
||||
public City City { get; set; }
|
||||
public string Street { get; set; }
|
||||
|
||||
public void Save()
|
||||
{
|
||||
// persist address
|
||||
}
|
||||
}
|
||||
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public Address Address { get; set; }
|
||||
|
||||
public string Greet()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace DeepFieldChain;
|
||||
|
||||
public class Service
|
||||
{
|
||||
public static void ProcessUser(User user)
|
||||
{
|
||||
// 2-level chain: user.Address → Address, then .Save() → Address#Save
|
||||
user.Address.Save();
|
||||
|
||||
// 3-level chain: user.Address → Address, .City → City, .GetName() → City#GetName
|
||||
user.Address.City.GetName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace FieldTypes;
|
||||
|
||||
public class Address
|
||||
{
|
||||
public string City { get; set; }
|
||||
|
||||
public void Save()
|
||||
{
|
||||
// persist address
|
||||
}
|
||||
}
|
||||
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public Address Address { get; set; }
|
||||
|
||||
public string Greet()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace FieldTypes;
|
||||
|
||||
public class Service
|
||||
{
|
||||
public static void ProcessUser(User user)
|
||||
{
|
||||
// Field-access chain: user.Address → Address, then .Save() → Address#Save
|
||||
user.Address.Save();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
public class Address {
|
||||
public string City { get; set; }
|
||||
}
|
||||
|
||||
public class User {
|
||||
public string Name { get; set; }
|
||||
public Address Address { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
public class UserService {
|
||||
public void UpdateUser(User user) {
|
||||
user.Name = "Alice";
|
||||
user.Address = new Address();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export class Address {
|
||||
city: string;
|
||||
|
||||
save(): void {
|
||||
// persist address
|
||||
}
|
||||
}
|
||||
|
||||
export class User {
|
||||
name: string;
|
||||
address: Address;
|
||||
|
||||
greet(): string {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class Config {
|
||||
static DEFAULT: Config = new Config();
|
||||
|
||||
validate(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { User, Config } from './models';
|
||||
|
||||
function processUser(user: User) {
|
||||
// Field-access chain: user.address resolves to Address, then .save() resolves to Address#save
|
||||
user.address.save();
|
||||
}
|
||||
|
||||
function validateConfig() {
|
||||
// Static field access: Config.DEFAULT resolves to Config, then .validate() resolves to Config#validate
|
||||
Config.DEFAULT.validate();
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module example.com/callresult
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (u *User) Save() bool {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import "example.com/go-deep-field-chain/models"
|
||||
|
||||
func processUser(user models.User) {
|
||||
// 2-level chain: user.Address → Address, then .Save() → Address#Save
|
||||
user.Address.Save()
|
||||
|
||||
// 3-level chain: user.Address → Address, .City → City, .GetName() → City#GetName
|
||||
user.Address.City.GetName()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module example.com/go-deep-field-chain
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
type City struct {
|
||||
ZipCode string
|
||||
}
|
||||
|
||||
func (c *City) GetName() string {
|
||||
return "city"
|
||||
}
|
||||
|
||||
type Address struct {
|
||||
City City
|
||||
Street string
|
||||
}
|
||||
|
||||
func (a *Address) Save() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
Address Address
|
||||
}
|
||||
|
||||
func (u *User) Greet() string {
|
||||
return u.Name
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package main
|
||||
|
||||
import "example.com/go-field-types/models"
|
||||
|
||||
func processUser(user models.User) {
|
||||
// Field-access chain: user.Address → Address, then .Save() → Address#Save
|
||||
user.Address.Save()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module example.com/go-field-types
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
type Address struct {
|
||||
City string
|
||||
}
|
||||
|
||||
func (a *Address) Save() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
Address Address
|
||||
}
|
||||
|
||||
func (u *User) Greet() string {
|
||||
return u.Name
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module example.com/methodchain
|
||||
|
||||
go 1.21
|
||||
+21
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import "example.com/go-mixed-chain/models"
|
||||
|
||||
func processWithService(svc *models.UserService) {
|
||||
svc.GetUser().Address.Save()
|
||||
}
|
||||
|
||||
func processWithUser(user *models.User) {
|
||||
user.GetAddress().City.GetName()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module example.com/go-mixed-chain
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,32 @@
|
||||
package models
|
||||
|
||||
type City struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (c *City) GetName() string {
|
||||
return c.Name
|
||||
}
|
||||
|
||||
type Address struct {
|
||||
City City
|
||||
Street string
|
||||
}
|
||||
|
||||
func (a *Address) Save() {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
Address Address
|
||||
}
|
||||
|
||||
func (u *User) GetAddress() *Address {
|
||||
return &u.Address
|
||||
}
|
||||
|
||||
type UserService struct{}
|
||||
|
||||
func (s *UserService) GetUser() *User {
|
||||
return &User{}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
type Address struct {
|
||||
City string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
Address Address
|
||||
}
|
||||
|
||||
func updateUser(user *User) {
|
||||
user.Name = "Alice"
|
||||
user.Address = Address{City: "NYC"}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
public class App {
|
||||
static User getUser(String name) {
|
||||
return new User(name);
|
||||
}
|
||||
|
||||
void processUser() {
|
||||
var user = getUser("alice");
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
public class User {
|
||||
private String name;
|
||||
|
||||
public User(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public boolean save() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import models.User;
|
||||
|
||||
public class App {
|
||||
public static void processUser(User user) {
|
||||
// 2-level chain: user.address → Address, then .save() → Address#save
|
||||
user.address.save();
|
||||
|
||||
// 3-level chain: user.address → Address, .city → City, .getName() → City#getName
|
||||
user.address.city.getName();
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package models;
|
||||
|
||||
public class Address {
|
||||
public City city;
|
||||
public String street;
|
||||
|
||||
public void save() {
|
||||
// persist address
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package models;
|
||||
|
||||
public class City {
|
||||
public String zipCode;
|
||||
|
||||
public String getName() {
|
||||
return "city";
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package models;
|
||||
|
||||
public class User {
|
||||
public String name;
|
||||
public Address address;
|
||||
|
||||
public String greet() {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import models.User;
|
||||
|
||||
public class App {
|
||||
public static void processUser(User user) {
|
||||
// Field-access chain: user.address → Address, then .save() → Address#save
|
||||
user.address.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package models;
|
||||
|
||||
public class Address {
|
||||
public String city;
|
||||
|
||||
public void save() {
|
||||
// persist address
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package models;
|
||||
|
||||
public class User {
|
||||
public String name;
|
||||
public Address address;
|
||||
|
||||
public String greet() {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import services.UserService;
|
||||
import models.User;
|
||||
|
||||
public class App {
|
||||
public static void processWithService(UserService svc) {
|
||||
svc.getUser().address.save();
|
||||
}
|
||||
|
||||
public static void processWithUser(User user) {
|
||||
user.getAddress().city.getName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package models;
|
||||
|
||||
public class Address {
|
||||
public City city;
|
||||
|
||||
public void save() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package models;
|
||||
|
||||
public class City {
|
||||
public String getName() {
|
||||
return "city";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package models;
|
||||
|
||||
public class User {
|
||||
public Address address;
|
||||
|
||||
public Address getAddress() {
|
||||
return this.address;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package services;
|
||||
|
||||
import models.User;
|
||||
|
||||
public class UserService {
|
||||
public User getUser() {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import models.User;
|
||||
import models.Address;
|
||||
|
||||
public class App {
|
||||
public static void updateUser(User user) {
|
||||
user.name = "Alice";
|
||||
user.address = new Address();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package models;
|
||||
|
||||
public class Address {
|
||||
public String city;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user